first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:48:15 +03:00
commit e6f3268c28
50 changed files with 4930 additions and 0 deletions

58
.air.toml Normal file
View File

@@ -0,0 +1,58 @@
#:schema https://json.schemastore.org/any.json
env_files = []
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
entrypoint = ["./tmp/main"]
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
ignore_dangerous_root_dir = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
app_start_timeout = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

54
.env Normal file
View File

@@ -0,0 +1,54 @@
# Database Settings (Mysql)
DB_HOST=10.80.80.70
DB_PORT=3306
DB_USER=go_imgapi
DB_PASSWORD=gg7678290
DB_NAME=go_imgapi
# Redis Settings
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# REDIS Config
REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/3
# JWT Settings (Jwt)
JWT_SECRET=ares-img-k1Obxl3kDRMtZ5cs9lvFTh73r5WjfF32ZhakPG6fBDYQmPvzkwsK2rHlaaP2YDmy
JWT_REFRESH_SECRET=ares-img-VUCRBBPbkg2lVVhDdzSHGdAXzkThPlD2Ri8LDJEomu1kXUR58ZE1KHJliaYlxIyx
# 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

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# ─── Veritabanı (MySQL) ──────────────────────────────────────────────────────
DB_HOST=localhost
DB_PORT=3306
DB_USER=go_imgapi
DB_PASSWORD=your_db_password_here
DB_NAME=go_imgapi
# ─── Redis ───────────────────────────────────────────────────────────────────
# URL formatı tercih edilir:
REDIS_URL=redis://default:your_redis_password@localhost:6379/0
# Alternatif olarak ayrı ayrı:
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=
# ─── JWT ─────────────────────────────────────────────────────────────────────
# Üretim için en az 64 karakter rastgele değer kullanın.
# Örnek üretim: openssl rand -hex 64
JWT_SECRET=change-me-to-a-long-random-string-at-least-64-chars
JWT_REFRESH_SECRET=change-me-to-a-different-long-random-string-for-refresh
# ─── Sunucu ──────────────────────────────────────────────────────────────────
PORT=8080
# ─── CORS Bootstrap Origins (opsiyonel, virgülle ayrılmış) ──────────────────
CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,http://localhost:5173
CORS_BOOTSTRAP_BLACKLIST_ORIGINS=
# ─── Rate Limit Bootstrap (opsiyonel) ────────────────────────────────────────
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
# ─── Uygulama URL'si (opsiyonel, image_url üretiminde kullanılır) ────────────
APP_BASE_URL=http://localhost:8080

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

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

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

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

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

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

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

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

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

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

800
README.md Normal file
View File

@@ -0,0 +1,800 @@
# Go Image Manipulation API
Fiber v3, GORM (MySQL) ve libvips/bimg tabanlı, JWT kimlik doğrulamalı resim yükleme ve işleme REST API.
---
## İçindekiler
- [Özellikler](#özellikler)
- [Teknoloji Yığını](#teknoloji-yığını)
- [Proje Yapısı](#proje-yapısı)
- [Gereksinimler](#gereksinimler)
- [Kurulum ve Çalıştırma](#kurulum-ve-çalıştırma)
- [Ortam Değişkenleri](#ortam-değişkenleri)
- [API Referansı](#api-referansı)
- [Auth](#auth)
- [Images](#images)
- [Admin](#admin)
- [Static Files](#static-files)
- [Kimlik Doğrulama](#kimlik-doğrulama)
- [Veri Modelleri](#veri-modelleri)
- [Resim İşleme](#resim-işleme)
- [Swagger UI](#swagger-ui)
- [Testler](#testler)
- [Deployment](#deployment)
---
## Özellikler
| Özellik | Detay |
|---------|-------|
| 🔐 JWT Auth | Access token (15 dk) + Refresh token (7 gün), `role` claim içerir |
| 🖼️ Resim Yükleme | UUID tabanlı isim, otomatik format dönüşümü |
| ⚙️ Resim İşleme | Yeniden boyutlandırma, kırpma, cover modu, kalite, format değişimi |
| 🗂️ Resim Listeleme | Sayfalı listeleme, tek resim detayı |
| 🌐 Statik Erişim | Yüklenen resimlere doğrudan URL ile erişim |
| 🛡️ Admin Panel | Kullanıcıya API token atama, tüm resimleri listeleme |
| 🗄️ MySQL + GORM | Otomatik migrasyon, soft delete |
| ⚡ Redis | Bağlantı havuzu hazır |
| 📄 Swagger UI | `/swagger` adresinde interaktif API belgesi |
---
## Teknoloji Yığını
| Katman | Kütüphane/Araç |
|--------|---------------|
| HTTP Framework | [Fiber v3](https://github.com/gofiber/fiber) |
| ORM | [GORM](https://gorm.io) + MySQL driver |
| Resim İşleme | [bimg](https://github.com/h2non/bimg) (libvips bağlayıcısı) |
| JWT | [golang-jwt/jwt v5](https://github.com/golang-jwt/jwt) |
| Redis | [go-redis/redis v9](https://github.com/redis/go-redis) |
| Parola Hashing | bcrypt |
| UUID | google/uuid |
| Swagger | swaggo/swag |
| Env Yönetimi | godotenv |
---
## Proje Yapısı
```
goimgApi/
├── main.go # Uygulama başlangıç noktası
├── go.mod / go.sum
├── .env # Ortam değişkenleri (Git'e eklenmez)
├── accounts/ # Kimlik doğrulama & kullanıcı yönetimi
│ ├── models.go # User struct (GORM modeli)
│ ├── handlers.go # Register, Login, Refresh, Middleware, Admin handlers
│ ├── jwt.go # Token üretimi & parse, rol normalizasyonu
│ ├── accounts_test.go # JWT & rol testleri
│ ├── jwt_test.go # Token claim testleri
│ └── models/ # İkincil model paketi (sosyal hesap, profil, refresh token)
│ ├── account.go
│ ├── token.go
│ └── models_test.go
├── images/ # Resim yükleme & işleme
│ ├── models.go # Image struct (GORM modeli)
│ ├── handlers.go # Upload, ListImages, GetImage, AdminListImages, Process
│ ├── processor.go # bimg işleme motoru
│ └── handlers_test.go # Path yardımcı & sayfalama testleri
├── configs/ # Bağlantı yapılandırmaları
│ ├── db.go # MySQL bağlantısı, CORS/rate-limit seed yardımcıları
│ ├── redis.go # Redis bağlantısı
│ └── configs_test.go # Yardımcı fonksiyon testleri
├── router/
│ ├── routers.go # Tüm route tanımları, statik dosya sunumu
│ └── routers_test.go # Swagger & statik route testleri
├── docs/ # Swaggo tarafından üretilen Swagger dosyaları
│ ├── docs.go
│ ├── swagger.json
│ ├── swagger.yaml
│ └── docs_test.go
├── uploads/ # Yüklenen resimlerin depolandığı dizin
└── tmp/ # Geliştirme sırasında kullanılan geçici scriptler
```
---
## Gereksinimler
- **Go 1.26+**
- **MySQL 8.0+**
- **Redis 6+**
- **libvips 8.8+** (bimg için)
### libvips Kurulumu
```bash
# Ubuntu / Debian
sudo apt-get install -y libvips-dev
# macOS
brew install vips
# Arch Linux
sudo pacman -S libvips
```
---
## Kurulum ve Çalıştırma
### 1. Depoyu klonlayın
```bash
git clone https://github.com/kullanici/goimgApi.git
cd goimgApi
```
### 2. Bağımlılıkları yükleyin
```bash
go mod download
```
### 3. Ortam değişkenlerini ayarlayın
```bash
cp .env.example .env
# .env dosyasını düzenleyin
```
### 4. Swagger spec'ini üretin
```bash
# swag CLI kurulumu (tek seferlik)
go install github.com/swaggo/swag/cmd/swag@latest
# docs/ klasörünü yeniden üret
swag init --parseDependency --parseInternal
```
> ⚠️ **Önemli:** Yeni bir endpoint ekledikten veya mevcut bir endpoint'in godoc yorumunu değiştirdikten sonra bu komutu **mutlaka** tekrar çalıştırın; aksi hâlde Swagger UI güncellenmiş endpoint'leri göstermez.
### 5. Uygulamayı başlatın
```bash
go run .
```
Sunucu `http://localhost:8080` adresinde başlar.
### Geliştirme modunda (hot-reload)
```bash
# air kurulumu (opsiyonel)
go install github.com/air-verse/air@latest
air
```
---
## Ortam Değişkenleri
Proje kök dizininde bir `.env` dosyası oluşturun:
```dotenv
# ─── Veritabanı (MySQL) ──────────────────────────────────────────────────────
DB_HOST=localhost
DB_PORT=3306
DB_USER=go_imgapi
DB_PASSWORD=your_password
DB_NAME=go_imgapi
# ─── Redis ───────────────────────────────────────────────────────────────────
REDIS_URL=redis://default:your_password@localhost:6379/0
# veya ayrı ayrı:
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=
# ─── JWT ─────────────────────────────────────────────────────────────────────
JWT_SECRET=en-az-32-karakter-uzun-gizli-anahtar
JWT_REFRESH_SECRET=farkli-bir-gizli-anahtar-refresh-icin
# ─── Sunucu ──────────────────────────────────────────────────────────────────
PORT=8080
# ─── CORS Bootstrap (opsiyonel) ──────────────────────────────────────────────
CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,https://yourfrontend.com
# ─── Rate Limit Bootstrap (opsiyonel) ────────────────────────────────────────
RL_BOOTSTRAP_LOGIN_MAX_REQUESTS=10
RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS=60
RL_BOOTSTRAP_API_MAX_REQUESTS=120
RL_BOOTSTRAP_API_WINDOW_SECONDS=60
```
> **Not:** `JWT_SECRET` ve `JWT_REFRESH_SECRET` birbirinden farklı ve en az 32 karakter olmalıdır.
---
## API Referansı
Base URL: `http://localhost:8080`
### Auth
#### `POST /auth/register` — Kayıt
Yeni kullanıcı oluşturur.
**İstek** (`multipart/form-data`)
| Alan | Tür | Zorunlu | Açıklama |
|------|-----|---------|----------|
| `email` | string | ✅ | Geçerli e-posta adresi |
| `password` | string | ✅ | En az 6 karakter |
**Başarılı Yanıt** `201 Created`
```json
{
"message": "User registered",
"user_id": 1
}
```
**Hata Yanıtları**
| Kod | Açıklama |
|-----|----------|
| `400` | Email boş / şifre 6 karakterden kısa / email zaten kullanımda |
---
#### `POST /auth/login` — Giriş
Kullanıcıyı doğrular ve JWT token çifti döner.
**İstek** (`multipart/form-data`)
| Alan | Tür | Zorunlu |
|------|-----|---------|
| `email` | string | ✅ |
| `password` | string | ✅ |
**Başarılı Yanıt** `200 OK`
```json
{
"access_token": "eyJhbGci...",
"refresh_token": "eyJhbGci...",
"user": {
"id": 1,
"email": "kullanici@ornek.com",
"role": "admin"
}
}
```
> `role` değeri `"admin"` veya `"user"` olur.
**Hata Yanıtları**
| Kod | Açıklama |
|-----|----------|
| `401` | Geçersiz e-posta veya şifre |
---
#### `POST /auth/refresh` — Token Yenile
Geçerli bir refresh token ile yeni token çifti üretir.
Refresh sırasında kullanıcının güncel rolü DB'den yeniden okunur.
**İstek** (`multipart/form-data`)
| Alan | Tür | Zorunlu |
|------|-----|---------|
| `refresh_token` | string | ✅ |
**Başarılı Yanıt** `200 OK`
```json
{
"access_token": "eyJhbGci...",
"refresh_token": "eyJhbGci..."
}
```
**Hata Yanıtları**
| Kod | Açıklama |
|-----|----------|
| `401` | Refresh token eksik, geçersiz veya süresi dolmuş |
| `401` | Token'a ait kullanıcı bulunamadı (silinmiş olabilir) |
---
### Images
Tüm `/images` endpoint'leri `Authorization: Bearer <access_token>` başlığı gerektirir.
#### `POST /images` — Resim Yükle
**İstek** (`multipart/form-data`)
| Alan | Tür | Zorunlu | Açıklama |
|------|-----|---------|----------|
| `image` | file | ✅ | Resim dosyası |
| `w` | int | | Hedef genişlik (px) |
| `h` | int | | Hedef yükseklik (px) |
| `q` | int | | Kalite 1-100 (varsayılan: 85) |
| `f` | string | | Format: `webp`, `avif`, `png`, `jpg` |
| `mode` | string | | `cover` — kırparak doldur |
**Başarılı Yanıt** `201 Created`
```json
{
"message": "Image uploaded successfully",
"image_id": 12,
"filename": "550e8400-e29b-41d4-a716-446655440000.webp",
"public_path": "/uploads/550e8400-e29b-41d4-a716-446655440000.webp",
"image_url": "http://localhost:8080/uploads/550e8400-e29b-41d4-a716-446655440000.webp"
}
```
> **`public_path` vs `image_url` farkı:**
> - `public_path` — domain bağımsız, DB'ye kaydedilen göreli yol (`/uploads/...`). Farklı ortamlarda (staging, prod, CDN) taşınabilir.
> - `image_url` — isteği yapan host'a göre dinamik üretilen tam URL. `X-Forwarded-Host` / `X-Forwarded-Proto` başlıkları varsa onları kullanır (proxy/CDN için).
>
> Frontend'de resmi göstermek için `image_url` değerini doğrudan kullanabilir ya da `API_BASE_URL + public_path` formülünü tercih edebilirsiniz.
---
#### `GET /images` — Resimleri Listele
Kimliği doğrulanmış kullanıcının resimlerini sayfalı döner.
**Query Parametreleri**
| Parametre | Varsayılan | Maks | Açıklama |
|-----------|-----------|------|----------|
| `page` | `1` | — | Sayfa numarası |
| `limit` | `20` | `100` | Sayfa başına kayıt |
**Başarılı Yanıt** `200 OK`
```json
{
"data": [
{
"id": 12,
"user_id": 1,
"filename": "550e8400....webp",
"public_path": "/uploads/550e8400....webp",
"image_url": "http://localhost:8080/uploads/550e8400....webp",
"mime_type": "image/jpeg",
"size_kb": 42,
"width": 800,
"height": 600,
"quality": 85,
"format": "webp",
"mode": "original",
"created_at": "2026-04-10T01:00:00Z"
}
],
"total": 1,
"page": 1,
"limit": 20
}
```
---
#### `GET /images/:id` — Tek Resim Detayı
Kimliği doğrulanmış kullanıcıya ait belirli bir resmin detayını döner.
**Başarılı Yanıt** `200 OK`
Listeleme endpoint'indeki tek kayıt yapısı ile aynı.
**Hata Yanıtları**
| Kod | Açıklama |
|-----|----------|
| `404` | Resim bulunamadı veya kullanıcıya ait değil |
---
#### `GET /images/:id/process` — Resmi İşle ve Döndür
API token ile korunur (JWT gerektirmez). Web sitelerinde `<img src="...">` ile doğrudan kullanım için tasarlanmıştır.
**Query Parametreleri**
| Parametre | Zorunlu | Açıklama |
|-----------|---------|----------|
| `token` | ✅ | Kullanıcıya ait API token |
| `w` | | Hedef genişlik (px) |
| `h` | | Hedef yükseklik (px) |
| `q` | | Kalite 1-100 |
| `f` | | Format: `webp`, `avif`, `png`, `jpg` |
| `mode` | | `cover` — kırparak doldur |
**Başarılı Yanıt** `200 OK` — İşlenmiş resim binary verisi (`image/webp`, `image/jpeg`, vb.)
**Örnek Kullanım**
```html
<!-- Orijinal resmi göster -->
<img src="http://localhost:8080/images/12/process?token=API_TOKEN">
<!-- 400×300 WebP olarak göster -->
<img src="http://localhost:8080/images/12/process?token=API_TOKEN&w=400&h=300&f=webp">
<!-- Cover kırpma ile 200×200 thumbnail -->
<img src="http://localhost:8080/images/12/process?token=API_TOKEN&w=200&h=200&mode=cover&f=webp">
```
**Hata Yanıtları**
| Kod | Açıklama |
|-----|----------|
| `401` | Token eksik, geçersiz veya süresi dolmuş |
| `404` | Resim bulunamadı |
| `500` | Dosya okunamadı veya işleme hatası |
---
### Admin
Tüm `/admin` endpoint'leri hem `Authorization: Bearer <access_token>` hem de admin yetkisi gerektirir.
#### `POST /admin/users/:id/api-token` — API Token Oluştur
Belirtilen kullanıcıya yeni bir API token atar.
**Path Parametreleri**
| Parametre | Tür | Açıklama |
|-----------|-----|----------|
| `id` | int | Hedef kullanıcı ID |
**İstek** (`multipart/form-data` veya query)
| Alan | Tür | Açıklama |
|------|-----|----------|
| `expires_in_days` | int | Kaç gün geçerli (0 = süresiz) |
**Başarılı Yanıt** `200 OK`
```json
{
"message": "API token created successfully",
"api_token": "550e8400-e29b-41d4-a716-446655440000",
"expires_at": "2026-05-10T01:00:00Z"
}
```
---
#### `GET /admin/images` — Tüm Resimleri Listele
Tüm kullanıcılara ait resimleri sayfalı döner.
**Query Parametreleri**
| Parametre | Açıklama |
|-----------|----------|
| `page` | Sayfa numarası (varsayılan: 1) |
| `limit` | Sayfa başına kayıt (varsayılan: 20, maks: 100) |
| `user_id` | Belirli bir kullanıcıya göre filtrele |
**Başarılı Yanıt** `200 OK`
```json
{
"data": [
{
"id": 12,
"user_id": 3,
"filename": "550e8400-e29b-41d4-a716-446655440000.webp",
"public_path": "/uploads/550e8400-e29b-41d4-a716-446655440000.webp",
"image_url": "http://localhost:8080/uploads/550e8400-e29b-41d4-a716-446655440000.webp",
"mime_type": "image/jpeg",
"size_kb": 42,
"width": 800,
"height": 600,
"quality": 85,
"format": "webp",
"mode": "original",
"created_at": "2026-04-10T01:00:00Z"
}
],
"total": 50,
"page": 1,
"limit": 20
}
```
---
### Static Files
#### `GET /uploads/:filename` — Yüklenen Resme Eriş
Yüklenen resimlere doğrudan URL ile erişim sağlar. Path traversal saldırılarına karşı korumalıdır.
```
GET /uploads/550e8400-e29b-41d4-a716-446655440000.webp
```
---
## Kimlik Doğrulama
### JWT Token Yapısı
```json
{
"user_id": 1,
"role": "admin",
"exp": 1775771479,
"iat": 1775770579
}
```
| Claim | Açıklama |
|-------|----------|
| `user_id` | Kullanıcı birincil anahtarı |
| `role` | `"admin"` veya `"user"` |
| `exp` | Token son geçerlilik zamanı (Unix timestamp) |
| `iat` | Token üretim zamanı |
### Token Ömürleri
| Token | Ömür |
|-------|------|
| Access Token | **15 dakika** |
| Refresh Token | **7 gün** |
| API Token | Admin tarafından belirlenir (sınırsız veya belirli gün) |
### İstek Başlığı
```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### Token Yenileme Akışı
```
POST /auth/login
→ access_token (15 dk) + refresh_token (7 gün)
access_token süresi dolunca:
POST /auth/refresh body: refresh_token=...
→ yeni access_token + yeni refresh_token
```
---
## Veri Modelleri
### `User` (accounts paketi)
| Alan | Tür | Açıklama |
|------|-----|----------|
| `id` | uint | Birincil anahtar |
| `email` | string | Benzersiz, zorunlu |
| `password_hash` | string | bcrypt (JSON'da gizli) |
| `is_admin` | bool | Admin yetkisi (varsayılan: false) |
| `api_token` | string | Resim işleme için API token |
| `api_token_expires_at` | *time | Token bitiş zamanı (null = süresiz) |
| `created_at` | time | |
| `updated_at` | time | |
| `deleted_at` | gorm.DeletedAt | Soft delete |
### `Image` (images paketi)
| Alan | Tür | Açıklama |
|------|-----|----------|
| `id` | uint | Birincil anahtar |
| `user_id` | uint | Sahibi (indexed) |
| `filename` | string | UUID tabanlı dosya adı |
| `public_path` | string | `/uploads/<uuid>.<ext>` |
| `mime_type` | string | Orijinal MIME türü |
| `size` | int64 | KB cinsinden boyut |
| `width` | int | Piksel genişlik |
| `height` | int | Piksel yükseklik |
| `quality` | int | 1100 arası kalite değeri |
| `format` | string | bimg format adı |
| `mode` | string | `original` veya `cover` |
| `created_at` | time | |
---
## Resim İşleme
Resimler [libvips](https://libvips.github.io/libvips/) ile işlenir. Desteklenen işlemler:
| İşlem | Parametre | Örnek |
|-------|-----------|-------|
| Genişlik ayarla | `w=400` | 400px genişliğe küçült |
| Yükseklik ayarla | `h=300` | 300px yüksekliğe küçült |
| Kalite ayarla | `q=75` | %75 kalite |
| Format dönüştür | `f=webp` | WebP formatına çevir |
| Cover kırpma | `mode=cover` | Oranı koruyarak kırp ve doldur |
### Desteklenen Formatlar
| Format | Parametre Değeri | MIME Türü |
|--------|-----------------|-----------|
| JPEG | `jpg` veya `jpeg` | `image/jpeg` |
| PNG | `png` | `image/png` |
| WebP | `webp` | `image/webp` |
| AVIF | `avif` | `image/avif` |
### Yükleme Sırasında İşleme
Upload endpoint'ine `w`, `h`, `f`, `q` parametreleri verilirse resim **diske yazılmadan önce** işlenir:
```bash
curl -X POST http://localhost:8080/images \
-H "Authorization: Bearer TOKEN" \
-F "image=@foto.jpg" \
-F "w=800" \
-F "f=webp" \
-F "q=85"
```
---
## Swagger UI
Tarayıcıda aç:
```
http://localhost:8080/swagger
```
Swagger JSON spec:
```
http://localhost:8080/docs/swagger.json
```
Spec içindeki `host` ve `schemes` alanları **çalışma zamanında** dinamik olarak kaldırılır; böylece farklı ortamlarda (proxy, HTTPS, CDN) Swagger UI her zaman mevcut host'a istek atar.
Swagger'dan istek atmak için:
1. `/auth/login` endpoint'ini çalıştırın
2. Dönen `access_token`'ı kopyalayın
3. Sağ üstteki **Authorize** butonuna tıklayın
4. `Bearer <token>` formatında girin
---
## Testler
```bash
# Tüm paket testleri
go test ./...
# Detaylı çıktı
go test -v ./...
# Belirli paket
go test -v ./accounts
go test -v ./images
go test -v ./router
go test -v ./configs
go test -v ./docs
go test -v ./accounts/models
```
### Test Kapsamı
| Paket | Test Sayısı | Kapsam |
|-------|-------------|--------|
| `accounts` | 15 | JWT üretimi, parse, rol normalizasyonu, `roleFromUser`, User model |
| `accounts/models` | 6 | `IsEmailVerified`, JSON tag güvenliği, GORM tag, `RefreshToken` alanları |
| `configs` | 27 | `normalizeOrigin` (8), `parseOriginList` (4), `envIntOr`/`envInt64Or` (8), bootstrap (7) |
| `docs` | 10 | `SwaggerInfo` alanları, şablon içeriği, swaggo registry kaydı |
| `images` | 9 | Path yardımcıları, `buildImageURL` (forwarded header), sayfalama |
| `router` | 2 | Swagger JSON host/scheme kaldırma, statik dosya servisi |
> Veritabanı veya Redis bağlantısı gerektiren testler mevcut değildir; tüm testler izole ve bağımsız çalışır.
---
## Swagger Spec Güncelleme
Yeni endpoint eklendiğinde veya mevcut godoc yorumu değiştirildiğinde spec'i yeniden üretmek gerekir:
```bash
swag init --parseDependency --parseInternal
```
Bu komut şu dosyaları günceller:
```
docs/
├── docs.go ← Go kodu (swaggo runtime için)
├── swagger.json ← Swagger UI'ın okuduğu spec
└── swagger.yaml ← YAML formatı
```
---
## Deployment
### Docker ile
```dockerfile
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache vips-dev build-base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o goimgApi .
FROM alpine:3.19
RUN apk add --no-cache vips
WORKDIR /app
COPY --from=builder /app/goimgApi .
COPY --from=builder /app/docs ./docs
RUN mkdir -p uploads
EXPOSE 8080
CMD ["./goimgApi"]
```
```bash
docker build -t goimgapi .
docker run -p 8080:8080 --env-file .env goimgapi
```
### Nginx Reverse Proxy Örneği
```nginx
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
client_max_body_size 50M;
}
}
```
> `X-Forwarded-Proto` ve `X-Forwarded-Host` başlıkları, upload response'unda dönen `image_url` değerinin doğru domain ile üretilmesi için gereklidir.
### Production için .env Kontrol Listesi
- [ ] `JWT_SECRET` — en az 64 karakter, rastgele üretilmiş
- [ ] `JWT_REFRESH_SECRET` — JWT_SECRET'tan farklı, en az 64 karakter
- [ ] `DB_PASSWORD` — güçlü, production şifresi
- [ ] `CORS_BOOTSTRAP_WHITELIST_ORIGINS` — yalnızca gerçek frontend domain'leri
- [ ] `uploads/` dizini uygulama tarafından yazılabilir (`chmod 755`)
---
## Katkı
1. Fork'layın
2. Feature branch oluşturun: `git checkout -b feature/ozellik-adi`
3. Testler ekleyin ve geçtiğinden emin olun: `go test ./...`
4. Pull request açın
---
## Lisans
MIT

158
accounts/accounts_test.go Normal file
View File

@@ -0,0 +1,158 @@
package accounts
import (
"testing"
"time"
)
// ─── normalizeRole ──────────────────────────────────────────────────────────
func TestNormalizeRole_Admin(t *testing.T) {
if got := normalizeRole("admin"); got != RoleAdmin {
t.Fatalf("expected %q, got %q", RoleAdmin, got)
}
}
func TestNormalizeRole_User(t *testing.T) {
if got := normalizeRole("user"); got != RoleUser {
t.Fatalf("expected %q, got %q", RoleUser, got)
}
}
func TestNormalizeRole_Unknown(t *testing.T) {
for _, input := range []string{"", "superuser", "moderator", "ADMIN"} {
if got := normalizeRole(input); got != RoleUser {
t.Fatalf("input %q: expected %q fallback, got %q", input, RoleUser, got)
}
}
}
// ─── roleFromUser ───────────────────────────────────────────────────────────
func TestRoleFromUser_Admin(t *testing.T) {
u := User{IsAdmin: true}
if got := roleFromUser(u); got != RoleAdmin {
t.Fatalf("expected admin role, got %q", got)
}
}
func TestRoleFromUser_RegularUser(t *testing.T) {
u := User{IsAdmin: false}
if got := roleFromUser(u); got != RoleUser {
t.Fatalf("expected user role, got %q", got)
}
}
// ─── GenerateTokens / ParseAccessToken / ParseRefreshToken ──────────────────
func TestGenerateAndParse_RoundTrip(t *testing.T) {
t.Setenv("JWT_SECRET", "test-access-secret-xyz")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-xyz")
access, refresh, err := GenerateTokens(99, RoleUser)
if err != nil {
t.Fatalf("GenerateTokens error: %v", err)
}
uid, err := ParseAccessToken(access)
if err != nil {
t.Fatalf("ParseAccessToken error: %v", err)
}
if uid != 99 {
t.Fatalf("expected user_id 99, got %d", uid)
}
ruid, err := ParseRefreshToken(refresh)
if err != nil {
t.Fatalf("ParseRefreshToken error: %v", err)
}
if ruid != 99 {
t.Fatalf("expected refresh user_id 99, got %d", ruid)
}
}
func TestGenerateTokens_MissingSecretsError(t *testing.T) {
t.Setenv("JWT_SECRET", "")
t.Setenv("JWT_REFRESH_SECRET", "")
if _, _, err := GenerateTokens(1, RoleUser); err == nil {
t.Fatal("expected error when JWT secrets are missing")
}
}
func TestParseAccessToken_TamperedTokenFails(t *testing.T) {
t.Setenv("JWT_SECRET", "my-secret")
t.Setenv("JWT_REFRESH_SECRET", "my-refresh")
_, err := ParseAccessToken("this.is.notavalidtoken")
if err == nil {
t.Fatal("expected error for tampered token")
}
}
func TestParseRefreshToken_WrongSecretFails(t *testing.T) {
t.Setenv("JWT_SECRET", "secret-a")
t.Setenv("JWT_REFRESH_SECRET", "secret-b")
access, _, err := GenerateTokens(1, RoleUser)
if err != nil {
t.Fatalf("GenerateTokens error: %v", err)
}
// Access token'ı refresh secret ile parse etmeye çalışmak başarısız olmalı
_, err = ParseRefreshToken(access)
if err == nil {
t.Fatal("expected error when parsing access token with refresh secret")
}
}
// ─── parseAccessClaims role claim içeriği ──────────────────────────────────
func TestParseAccessClaims_ContainsRole(t *testing.T) {
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh")
access, _, err := GenerateTokens(7, RoleAdmin)
if err != nil {
t.Fatalf("GenerateTokens error: %v", err)
}
claims, err := parseAccessClaims(access)
if err != nil {
t.Fatalf("parseAccessClaims error: %v", err)
}
if claims.Role != RoleAdmin {
t.Fatalf("expected role %q, got %q", RoleAdmin, claims.Role)
}
if claims.UserID != 7 {
t.Fatalf("expected user_id 7, got %d", claims.UserID)
}
}
// ─── User model ApiToken süresi ───────────────────────────────────────────
func TestUser_ApiTokenExpiresAt_NilMeansNeverExpires(t *testing.T) {
u := User{ApiTokenExpiresAt: nil}
if u.ApiTokenExpiresAt != nil {
t.Fatal("nil ApiTokenExpiresAt must remain nil")
}
}
func TestUser_ApiTokenExpiresAt_CanBeSet(t *testing.T) {
exp := time.Now().Add(24 * time.Hour)
u := User{ApiTokenExpiresAt: &exp}
if u.ApiTokenExpiresAt == nil {
t.Fatal("ApiTokenExpiresAt should not be nil after assignment")
}
if !u.ApiTokenExpiresAt.Equal(exp) {
t.Fatalf("expected %v, got %v", exp, *u.ApiTokenExpiresAt)
}
}
func TestUser_IsAdminDefaultFalse(t *testing.T) {
u := User{}
if u.IsAdmin {
t.Fatal("zero-value User must not be admin")
}
}

254
accounts/handlers.go Normal file
View File

@@ -0,0 +1,254 @@
package accounts
import (
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"goimgApi/configs"
)
type AuthReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Register godoc
// @Summary Register a new user
// @Description Register a new user with email and password
// @Tags Auth
// @Accept multipart/form-data
// @Produce json
// @Param email formData string true "Email address"
// @Param password formData string true "Password (min 6 chars)"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /auth/register [post]
func Register(c fiber.Ctx) error {
email := c.FormValue("email")
password := c.FormValue("password")
if email == "" || len(password) < 6 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email required and password min 6 chars"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
}
user := User{
Email: email,
PasswordHash: string(hash),
}
if err := configs.DB.Create(&user).Error; err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email might be already in use"})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "User registered", "user_id": user.ID})
}
// Login godoc
// @Summary Login
// @Description Authenticate user and get JWT
// @Tags Auth
// @Accept multipart/form-data
// @Produce json
// @Param email formData string true "Email address"
// @Param password formData string true "Password"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /auth/login [post]
func Login(c fiber.Ctx) error {
email := c.FormValue("email")
password := c.FormValue("password")
var user User
if err := configs.DB.Where("email = ?", email).First(&user).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
role := roleFromUser(user)
access, refresh, err := GenerateTokens(user.ID, role)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Token generation failed"})
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
"user": fiber.Map{
"id": user.ID,
"email": user.Email,
"role": role,
},
})
}
type RefreshReq struct {
RefreshToken string `json:"refresh_token"`
}
// Refresh godoc
// @Summary Refresh JWT
// @Description Get a new access token using a valid refresh token
// @Tags Auth
// @Accept multipart/form-data
// @Produce json
// @Param refresh_token formData string true "Refresh token"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /auth/refresh [post]
func Refresh(c fiber.Ctx) error {
refreshToken := c.FormValue("refresh_token")
if refreshToken == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Refresh token required"})
}
userID, err := ParseRefreshToken(refreshToken)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired refresh token"})
}
var user User
if err := configs.DB.First(&user, userID).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
}
acc, ref, err := GenerateTokens(userID, roleFromUser(user))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Token generation failed"})
}
return c.JSON(fiber.Map{
"access_token": acc,
"refresh_token": ref,
})
}
// JWTMiddleware extracts user_id from access token
func JWTMiddleware(c fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
// Swagger UI esnekliği için "Bearer " prefix'ini isteğe bağlı kılıyoruz.
tokenStr := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer"))
claims, err := parseAccessClaims(tokenStr)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
}
// Set userID context
c.Locals("user_id", claims.UserID)
c.Locals("role", claims.Role)
return c.Next()
}
// AdminMiddleware ensures the logged-in user is an admin
func AdminMiddleware(c fiber.Ctx) error {
userIDVal := c.Locals("user_id")
if userIDVal == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
var user User
if err := configs.DB.First(&user, userIDVal).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
}
if !user.IsAdmin {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Admin privileges required"})
}
return c.Next()
}
type CreateApiTokenReq struct {
ExpiresInDays int `json:"expires_in_days"`
}
// CreateApiToken godoc
// @Summary Create API Token
// @Description Creates an API Token for a user (Admin ONLY).
// @Tags Admin
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param id path int true "User ID"
// @Param expires_in_days formData int false "Expiration in days (0 or omit for never)"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 403 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /admin/users/{id}/api-token [post]
func CreateApiToken(c fiber.Ctx) error {
targetID := c.Params("id")
expiresInDays := 0
if f := c.FormValue("expires_in_days"); f != "" {
if val, err := strconv.Atoi(f); err == nil {
expiresInDays = val
}
}
// Fallback eklendi: Eger FormData gelmediyse Query'den parametreyi deneriz
if q := c.Query("expires_in_days"); q != "" {
if val, err := strconv.Atoi(q); err == nil {
expiresInDays = val
}
}
var user User
if err := configs.DB.First(&user, targetID).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
}
// Generate and update
token := uuid.New().String()
user.ApiToken = token
if expiresInDays > 0 {
exp := time.Now().Add(time.Duration(expiresInDays) * 24 * time.Hour)
user.ApiTokenExpiresAt = &exp
} else {
user.ApiTokenExpiresAt = nil
}
if err := configs.DB.Save(&user).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save API token"})
}
responseMap := fiber.Map{
"message": "API token created successfully",
"api_token": token,
}
if user.ApiTokenExpiresAt != nil {
responseMap["expires_at"] = user.ApiTokenExpiresAt
} else {
responseMap["expires_at"] = "never"
}
return c.JSON(responseMap)
}
func roleFromUser(user User) string {
if user.IsAdmin {
return RoleAdmin
}
return RoleUser
}

111
accounts/jwt.go Normal file
View File

@@ -0,0 +1,111 @@
package accounts
import (
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type jwtClaims struct {
UserID uint `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
const (
RoleAdmin = "admin"
RoleUser = "user"
)
func GenerateTokens(userID uint, role string) (string, string, error) {
accessSecret := os.Getenv("JWT_SECRET")
refreshSecret := os.Getenv("JWT_REFRESH_SECRET")
if accessSecret == "" || refreshSecret == "" {
return "", "", fmt.Errorf("JWT secrets are not set")
}
normalizedRole := normalizeRole(role)
// Access Token
accessClaims := jwtClaims{
UserID: userID,
Role: normalizedRole,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessString, err := accessToken.SignedString([]byte(accessSecret))
if err != nil {
return "", "", err
}
// Refresh Token
refreshClaims := jwtClaims{
UserID: userID,
Role: normalizedRole,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshString, err := refreshToken.SignedString([]byte(refreshSecret))
if err != nil {
return "", "", err
}
return accessString, refreshString, nil
}
func ParseAccessToken(tokenString string) (uint, error) {
secret := os.Getenv("JWT_SECRET")
claims, err := parseTokenClaims(tokenString, secret)
if err != nil {
return 0, err
}
return claims.UserID, nil
}
func ParseRefreshToken(tokenString string) (uint, error) {
secret := os.Getenv("JWT_REFRESH_SECRET")
claims, err := parseTokenClaims(tokenString, secret)
if err != nil {
return 0, err
}
return claims.UserID, nil
}
func parseAccessClaims(tokenString string) (*jwtClaims, error) {
secret := os.Getenv("JWT_SECRET")
return parseTokenClaims(tokenString, secret)
}
func parseTokenClaims(tokenString, secret string) (*jwtClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*jwtClaims); ok && token.Valid {
claims.Role = normalizeRole(claims.Role)
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func normalizeRole(role string) string {
switch role {
case RoleAdmin:
return RoleAdmin
default:
return RoleUser
}
}

54
accounts/jwt_test.go Normal file
View File

@@ -0,0 +1,54 @@
package accounts
import "testing"
func TestGenerateTokensIncludesRoleClaim(t *testing.T) {
t.Setenv("JWT_SECRET", "test-access-secret")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret")
accessToken, refreshToken, err := GenerateTokens(42, RoleAdmin)
if err != nil {
t.Fatalf("GenerateTokens returned error: %v", err)
}
accessClaims, err := parseAccessClaims(accessToken)
if err != nil {
t.Fatalf("parseAccessClaims returned error: %v", err)
}
if accessClaims.UserID != 42 {
t.Fatalf("expected access user id 42, got %d", accessClaims.UserID)
}
if accessClaims.Role != RoleAdmin {
t.Fatalf("expected access role %q, got %q", RoleAdmin, accessClaims.Role)
}
refreshUserID, err := ParseRefreshToken(refreshToken)
if err != nil {
t.Fatalf("ParseRefreshToken returned error: %v", err)
}
if refreshUserID != 42 {
t.Fatalf("expected refresh user id 42, got %d", refreshUserID)
}
}
func TestGenerateTokensNormalizesUnknownRoleToUser(t *testing.T) {
t.Setenv("JWT_SECRET", "test-access-secret")
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret")
accessToken, _, err := GenerateTokens(7, "superuser")
if err != nil {
t.Fatalf("GenerateTokens returned error: %v", err)
}
accessClaims, err := parseAccessClaims(accessToken)
if err != nil {
t.Fatalf("parseAccessClaims returned error: %v", err)
}
if accessClaims.Role != RoleUser {
t.Fatalf("expected normalized role %q, got %q", RoleUser, accessClaims.Role)
}
}

19
accounts/models.go Normal file
View File

@@ -0,0 +1,19 @@
package accounts
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
ApiToken string `gorm:"uniqueIndex" json:"api_token"`
ApiTokenExpiresAt *time.Time `json:"api_token_expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

View File

@@ -0,0 +1,48 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
gorm.Model
UserName string `json:"username" gorm:"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"`
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;index" json:"user_id"`
AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider
FirstName string `json:"first_name" gorm:"type:varchar(255)"` // Full name from provider
LastName string `json:"last_name" gorm:"type:varchar(255)"` // Full name from provider
}

View File

@@ -0,0 +1,65 @@
package models
import (
"reflect"
"testing"
)
// ─── User.IsEmailVerified ────────────────────────────────────────────────────
func TestIsEmailVerified_NilPointer(t *testing.T) {
u := &User{}
if u.IsEmailVerified() {
t.Fatal("expected false when EmailVerified is nil")
}
}
func TestIsEmailVerified_False(t *testing.T) {
v := false
u := &User{EmailVerified: &v}
if u.IsEmailVerified() {
t.Fatal("expected false when EmailVerified is &false")
}
}
func TestIsEmailVerified_True(t *testing.T) {
v := true
u := &User{EmailVerified: &v}
if !u.IsEmailVerified() {
t.Fatal("expected true when EmailVerified is &true")
}
}
// ─── User struct tag doğrulamaları ──────────────────────────────────────────
func TestUser_PasswordHiddenFromJSON(t *testing.T) {
f, ok := reflect.TypeOf(User{}).FieldByName("Password")
if !ok {
t.Fatal("User has no Password field")
}
if tag := f.Tag.Get("json"); tag != "-" {
t.Fatalf("expected Password json tag to be \"-\", got %q", tag)
}
}
func TestUser_EmailUniqueIndexTagSet(t *testing.T) {
f, ok := reflect.TypeOf(User{}).FieldByName("Email")
if !ok {
t.Fatal("User has no Email field")
}
if gormTag := f.Tag.Get("gorm"); gormTag == "" {
t.Fatal("Email field has no gorm tag")
}
}
// ─── RefreshToken alanları ───────────────────────────────────────────────────
func TestRefreshToken_HasRequiredFields(t *testing.T) {
typ := reflect.TypeOf(RefreshToken{})
required := []string{"UserID", "TokenID", "TokenHash", "TokenFingerprint", "ExpiresAt", "Revoked"}
for _, name := range required {
if _, ok := typ.FieldByName(name); !ok {
t.Errorf("RefreshToken missing required field: %s", name)
}
}
}

26
accounts/models/token.go Normal file
View File

@@ -0,0 +1,26 @@
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 uint `gorm:"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"`
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"`
}

13
bu bir go 1.26.yaml Normal file
View File

@@ -0,0 +1,13 @@
bu bir go 1.26.1 ile yazılmış bir api projesi
github.com/go-sql-driver/mysql v1.9.3
github.com/gofiber/fiber/v3 v3.1.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
ana moduller bunlardır
account auth sistemi jwt ile auth olacak mysql db kulanacak
ana amac image manupilation yapacak bir api yazmak
avif,png,jpg,jpeg,webp formatlarını destekleyecek
en,boy,format,quality parametreleri cover ve diger crop tipleri ile image manupilation yapacak
zamanli sabit erisim için token verecegiz ve o token ile image manupilation yapacak
bu ilerler için redis kulanacagiz
bu proje de account ve images klasorleri kulanailacak model contreoler ve servisler için

254
configs/configs_test.go Normal file
View File

@@ -0,0 +1,254 @@
package configs
import (
"strings"
"testing"
)
// ─── normalizeOrigin ─────────────────────────────────────────────────────────
func TestNormalizeOrigin_ValidHTTP(t *testing.T) {
if got := normalizeOrigin("http://localhost:3000"); got != "http://localhost:3000" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeOrigin_ValidHTTPS(t *testing.T) {
if got := normalizeOrigin("https://example.com"); got != "https://example.com" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeOrigin_StripsTrailingSlash(t *testing.T) {
got := normalizeOrigin("https://example.com/")
// url.Parse keeps the trailing slash on host-only URLs, so we just check host is preserved
if !strings.HasPrefix(got, "https://example.com") {
t.Fatalf("unexpected result: %q", got)
}
}
func TestNormalizeOrigin_UppercaseNormalized(t *testing.T) {
got := normalizeOrigin("HTTP://EXAMPLE.COM")
if got != "http://example.com" {
t.Fatalf("expected lowercase, got %q", got)
}
}
func TestNormalizeOrigin_Empty(t *testing.T) {
if got := normalizeOrigin(""); got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestNormalizeOrigin_Whitespace(t *testing.T) {
if got := normalizeOrigin(" "); got != "" {
t.Fatalf("expected empty for whitespace, got %q", got)
}
}
func TestNormalizeOrigin_NoScheme(t *testing.T) {
if got := normalizeOrigin("example.com"); got != "" {
t.Fatalf("expected empty for missing scheme, got %q", got)
}
}
func TestNormalizeOrigin_QuotedValue(t *testing.T) {
if got := normalizeOrigin(`'http://localhost:8080'`); got != "http://localhost:8080" {
t.Fatalf("got %q", got)
}
}
// ─── parseOriginList ─────────────────────────────────────────────────────────
func TestParseOriginList_MultipleEntries(t *testing.T) {
list := parseOriginList("http://localhost:3000,https://example.com")
if len(list) != 2 {
t.Fatalf("expected 2 entries, got %d", len(list))
}
}
func TestParseOriginList_EmptyString(t *testing.T) {
list := parseOriginList("")
if len(list) != 0 {
t.Fatalf("expected 0 entries, got %d: %v", len(list), list)
}
}
func TestParseOriginList_InvalidEntriesSkipped(t *testing.T) {
list := parseOriginList("http://good.com,not-a-url,http://also-good.com")
if len(list) != 2 {
t.Fatalf("expected 2 valid entries, got %d: %v", len(list), list)
}
}
func TestParseOriginList_DuplicatesPassThrough(t *testing.T) {
// parseOriginList itself does NOT deduplicate; bootstrapWhitelistOrigins does.
list := parseOriginList("http://dup.com,http://dup.com")
if len(list) != 2 {
t.Fatalf("parseOriginList should keep duplicates, got %d", len(list))
}
}
// ─── envIntOr ────────────────────────────────────────────────────────────────
func TestEnvIntOr_MissingUsesDefault(t *testing.T) {
t.Setenv("__TEST_INT_MISSING__", "")
if got := envIntOr("__TEST_INT_MISSING__", 42); got != 42 {
t.Fatalf("expected 42, got %d", got)
}
}
func TestEnvIntOr_ValidValue(t *testing.T) {
t.Setenv("__TEST_INT__", "99")
if got := envIntOr("__TEST_INT__", 1); got != 99 {
t.Fatalf("expected 99, got %d", got)
}
}
func TestEnvIntOr_InvalidStringUsesDefault(t *testing.T) {
t.Setenv("__TEST_INT_BAD__", "abc")
if got := envIntOr("__TEST_INT_BAD__", 7); got != 7 {
t.Fatalf("expected fallback 7, got %d", got)
}
}
func TestEnvIntOr_ZeroUsesDefault(t *testing.T) {
t.Setenv("__TEST_INT_ZERO__", "0")
if got := envIntOr("__TEST_INT_ZERO__", 5); got != 5 {
t.Fatalf("expected fallback for 0, got %d", got)
}
}
func TestEnvIntOr_NegativeUsesDefault(t *testing.T) {
t.Setenv("__TEST_INT_NEG__", "-1")
if got := envIntOr("__TEST_INT_NEG__", 3); got != 3 {
t.Fatalf("expected fallback for negative, got %d", got)
}
}
// ─── envInt64Or ──────────────────────────────────────────────────────────────
func TestEnvInt64Or_MissingUsesDefault(t *testing.T) {
t.Setenv("__TEST_I64_MISSING__", "")
if got := envInt64Or("__TEST_I64_MISSING__", 100); got != 100 {
t.Fatalf("expected 100, got %d", got)
}
}
func TestEnvInt64Or_ValidValue(t *testing.T) {
t.Setenv("__TEST_I64__", "999")
if got := envInt64Or("__TEST_I64__", 1); got != 999 {
t.Fatalf("expected 999, got %d", got)
}
}
func TestEnvInt64Or_InvalidUsesDefault(t *testing.T) {
t.Setenv("__TEST_I64_BAD__", "not-a-number")
if got := envInt64Or("__TEST_I64_BAD__", 50); got != 50 {
t.Fatalf("expected fallback 50, got %d", got)
}
}
// ─── bootstrapRateLimitRules ─────────────────────────────────────────────────
func TestBootstrapRateLimitRules_ContainsThreeRules(t *testing.T) {
rules := bootstrapRateLimitRules()
if len(rules) != 3 {
t.Fatalf("expected 3 rules, got %d", len(rules))
}
}
func TestBootstrapRateLimitRules_DefaultFallbacks(t *testing.T) {
// Env vars temizlenerek varsayılan değerler test edilir.
t.Setenv("RL_BOOTSTRAP_LOGIN_MAX_REQUESTS", "")
t.Setenv("RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS", "")
rules := bootstrapRateLimitRules()
loginRule := rules[0]
if loginRule.MaxRequests != 10 {
t.Fatalf("expected default MaxRequests=10, got %d", loginRule.MaxRequests)
}
if loginRule.WindowSeconds != 60 {
t.Fatalf("expected default WindowSeconds=60, got %d", loginRule.WindowSeconds)
}
}
func TestBootstrapRateLimitRules_EnvOverride(t *testing.T) {
t.Setenv("RL_BOOTSTRAP_LOGIN_MAX_REQUESTS", "25")
t.Setenv("RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS", "120")
rules := bootstrapRateLimitRules()
loginRule := rules[0]
if loginRule.MaxRequests != 25 {
t.Fatalf("expected MaxRequests=25, got %d", loginRule.MaxRequests)
}
if loginRule.WindowSeconds != 120 {
t.Fatalf("expected WindowSeconds=120, got %d", loginRule.WindowSeconds)
}
}
func TestBootstrapRateLimitRules_AllNamesNonEmpty(t *testing.T) {
for _, r := range bootstrapRateLimitRules() {
if r.Name == "" {
t.Errorf("rate limit rule has empty Name: %+v", r)
}
if r.Description == "" {
t.Errorf("rate limit rule has empty Description: %+v", r)
}
}
}
// ─── bootstrapWhitelistOrigins ───────────────────────────────────────────────
func TestBootstrapWhitelistOrigins_ContainsLocalDefaults(t *testing.T) {
t.Setenv("CORS_BOOTSTRAP_WHITELIST_ORIGINS", "")
t.Setenv("APP_BASE_URL", "")
origins := bootstrapWhitelistOrigins()
required := []string{
"http://localhost:8080",
"http://localhost:3000",
"http://localhost:5173",
}
originSet := make(map[string]struct{}, len(origins))
for _, o := range origins {
originSet[o] = struct{}{}
}
for _, want := range required {
if _, ok := originSet[want]; !ok {
t.Errorf("expected default origin %q to be present", want)
}
}
}
func TestBootstrapWhitelistOrigins_NoDuplicates(t *testing.T) {
t.Setenv("CORS_BOOTSTRAP_WHITELIST_ORIGINS", "http://localhost:3000,http://localhost:3000")
origins := bootstrapWhitelistOrigins()
seen := map[string]int{}
for _, o := range origins {
seen[o]++
}
for origin, count := range seen {
if count > 1 {
t.Errorf("duplicate origin in whitelist: %q (x%d)", origin, count)
}
}
}
func TestBootstrapWhitelistOrigins_AppBaseURLIncluded(t *testing.T) {
t.Setenv("APP_BASE_URL", "https://myapp.example.com")
defer t.Setenv("APP_BASE_URL", "")
origins := bootstrapWhitelistOrigins()
for _, o := range origins {
if o == "https://myapp.example.com" {
return
}
}
t.Fatal("APP_BASE_URL origin not found in whitelist")
}

193
configs/db.go Normal file
View File

@@ -0,0 +1,193 @@
package configs
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var DB *gorm.DB
// ConnectDB opens a MySQL connection via GORM.
func ConnectDB() error {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
os.Getenv("DB_USER"),
os.Getenv("DB_PASSWORD"),
os.Getenv("DB_HOST"),
os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"),
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return fmt.Errorf("database connection failed: %w", err)
}
DB = db
return nil
}
// SeedSecurityDefaults inserts known origins into CORS tables if missing.
// Existing rows are left untouched.
/*func SeedSecurityDefaults() error {
if DB == nil {
return fmt.Errorf("database is not connected")
}
for _, origin := range bootstrapWhitelistOrigins() {
item := settingsModels.CorsWhitelist{
Origin: origin,
IsActive: true,
}
if err := DB.Where("origin = ?", origin).FirstOrCreate(&item).Error; err != nil {
return fmt.Errorf("seed cors whitelist (%s): %w", origin, err)
}
}
for _, origin := range parseOriginList(os.Getenv("CORS_BOOTSTRAP_BLACKLIST_ORIGINS")) {
item := settingsModels.CorsBlacklist{
Origin: origin,
IsActive: true,
}
if err := DB.Where("origin = ?", origin).FirstOrCreate(&item).Error; err != nil {
return fmt.Errorf("seed cors blacklist (%s): %w", origin, err)
}
}
for _, rule := range bootstrapRateLimitRules() {
item := settingsModels.RateLimitSetting{
Name: rule.Name,
Description: rule.Description,
MaxRequests: rule.MaxRequests,
WindowSeconds: rule.WindowSeconds,
IsActive: true,
}
if err := DB.Where("name = ?", rule.Name).FirstOrCreate(&item).Error; err != nil {
return fmt.Errorf("seed rate limit (%s): %w", rule.Name, err)
}
}
return nil
}*/
type rateLimitSeedRule struct {
Name string
Description string
MaxRequests int64
WindowSeconds int
}
func bootstrapRateLimitRules() []rateLimitSeedRule {
return []rateLimitSeedRule{
{
Name: "api/v1/auth/login",
Description: "Bootstrap login rate limit",
MaxRequests: envInt64Or("RL_BOOTSTRAP_LOGIN_MAX_REQUESTS", 10),
WindowSeconds: envIntOr("RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS", 60),
},
{
Name: "api/v1/auth/register",
Description: "Bootstrap register rate limit",
MaxRequests: envInt64Or("RL_BOOTSTRAP_REGISTER_MAX_REQUESTS", 5),
WindowSeconds: envIntOr("RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS", 60),
},
{
Name: "api",
Description: "Bootstrap default API rate limit",
MaxRequests: envInt64Or("RL_BOOTSTRAP_API_MAX_REQUESTS", 120),
WindowSeconds: envIntOr("RL_BOOTSTRAP_API_WINDOW_SECONDS", 60),
},
}
}
func bootstrapWhitelistOrigins() []string {
uniq := map[string]struct{}{}
var out []string
add := func(v string) {
origin := normalizeOrigin(v)
if origin == "" {
return
}
if _, ok := uniq[origin]; ok {
return
}
uniq[origin] = struct{}{}
out = append(out, origin)
}
// Explicit bootstrap origins from env (comma separated).
for _, v := range parseOriginList(os.Getenv("CORS_BOOTSTRAP_WHITELIST_ORIGINS")) {
add(v)
}
// Derive common origins from existing app URLs.
add(os.Getenv("APP_BASE_URL"))
add(os.Getenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL"))
add(os.Getenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL"))
// Safe local defaults for development.
add("http://localhost:8080")
add("http://localhost:3000")
add("http://localhost:5173")
add("http://127.0.0.1:8080")
add("http://127.0.0.1:3000")
add("http://127.0.0.1:5173")
return out
}
func parseOriginList(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
v := normalizeOrigin(p)
if v != "" {
out = append(out, v)
}
}
return out
}
func normalizeOrigin(v string) string {
s := strings.TrimSpace(strings.Trim(v, `'"`))
if s == "" {
return ""
}
u, err := url.Parse(s)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return strings.ToLower(u.Scheme + "://" + u.Host)
}
func envIntOr(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return fallback
}
return n
}
func envInt64Or(key string, fallback int64) int64 {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 {
return fallback
}
return n
}

47
configs/redis.go Normal file
View File

@@ -0,0 +1,47 @@
package configs
import (
"context"
"fmt"
"os"
"github.com/redis/go-redis/v9"
)
var RedisClient *redis.Client
var Ctx = context.Background()
// ConnectRedis initializes the Redis connection
func ConnectRedis() error {
redisURL := os.Getenv("REDIS_URL")
if redisURL != "" {
opt, err := redis.ParseURL(redisURL)
if err != nil {
return fmt.Errorf("failed to parse redis url: %w", err)
}
RedisClient = redis.NewClient(opt)
} else {
host := os.Getenv("REDIS_HOST")
if host == "" {
host = "localhost"
}
port := os.Getenv("REDIS_PORT")
if port == "" {
port = "6379"
}
pass := os.Getenv("REDIS_PASSWORD")
RedisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%s", host, port),
Password: pass,
DB: 0, // Default DB
})
}
_, err := RedisClient.Ping(Ctx).Result()
if err != nil {
return fmt.Errorf("redis connection failed: %w", err)
}
return nil
}

584
docs/docs.go Normal file
View File

@@ -0,0 +1,584 @@
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/admin/images": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a paginated list of all images across all users.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "List all images (admin)",
"parameters": [
{
"type": "integer",
"description": "Page number (default 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Items per page (default 20, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Filter by user ID",
"name": "user_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/admin/users/{id}/api-token": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Creates an API Token for a user (Admin ONLY).",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Create API Token",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Expiration in days (0 or omit for never)",
"name": "expires_in_days",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/login": {
"post": {
"description": "Authenticate user and get JWT",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Login",
"parameters": [
{
"type": "string",
"description": "Email address",
"name": "email",
"in": "formData",
"required": true
},
{
"type": "string",
"description": "Password",
"name": "password",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/refresh": {
"post": {
"description": "Get a new access token using a valid refresh token",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Refresh JWT",
"parameters": [
{
"type": "string",
"description": "Refresh token",
"name": "refresh_token",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/register": {
"post": {
"description": "Register a new user with email and password",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register a new user",
"parameters": [
{
"type": "string",
"description": "Email address",
"name": "email",
"in": "formData",
"required": true
},
{
"type": "string",
"description": "Password (min 6 chars)",
"name": "password",
"in": "formData",
"required": true
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a paginated list of images belonging to the authenticated user.",
"produces": [
"application/json"
],
"tags": [
"Images"
],
"summary": "List images",
"parameters": [
{
"type": "integer",
"description": "Page number (default 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Items per page (default 20, max 100)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Uploads an image and registers it to the user.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Images"
],
"summary": "Upload an image",
"parameters": [
{
"type": "file",
"description": "Image file",
"name": "image",
"in": "formData",
"required": true
},
{
"type": "integer",
"description": "Width",
"name": "w",
"in": "formData"
},
{
"type": "integer",
"description": "Height",
"name": "h",
"in": "formData"
},
{
"type": "integer",
"description": "Quality (1-100)",
"name": "q",
"in": "formData"
},
{
"type": "string",
"description": "Format (webp, avif, png, jpg)",
"name": "f",
"in": "formData"
},
{
"type": "string",
"description": "Mode (e.g. cover)",
"name": "mode",
"in": "formData"
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images/{id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a single image record owned by the authenticated user.",
"produces": [
"application/json"
],
"tags": [
"Images"
],
"summary": "Get image by ID",
"parameters": [
{
"type": "integer",
"description": "Image ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images/{id}/process": {
"get": {
"description": "Processes an image (resize, crop, cover, format) using the generated token.",
"produces": [
"image/jpeg",
"image/png",
"image/webp",
"image/avif"
],
"tags": [
"Images"
],
"summary": "Process Image",
"parameters": [
{
"type": "integer",
"description": "Image ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Global API Token",
"name": "token",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Width",
"name": "w",
"in": "query"
},
{
"type": "integer",
"description": "Height",
"name": "h",
"in": "query"
},
{
"type": "integer",
"description": "Quality (1-100)",
"name": "q",
"in": "query"
},
{
"type": "string",
"description": "Format (webp, avif, png, jpg)",
"name": "f",
"in": "query"
},
{
"type": "string",
"description": "Mode (e.g. cover)",
"name": "mode",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Type \"Bearer \u003ctoken\u003e\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "localhost:8080",
BasePath: "/",
Schemes: []string{},
Title: "Go Image Manipulation API",
Description: "This is a sample image manipulation API using Fiber v3 and bimg.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

75
docs/docs_test.go Normal file
View File

@@ -0,0 +1,75 @@
package docs
import (
"strings"
"testing"
"github.com/swaggo/swag"
)
// ─── SwaggerInfo temel alanları ──────────────────────────────────────────────
func TestSwaggerInfo_NotNil(t *testing.T) {
if SwaggerInfo == nil {
t.Fatal("SwaggerInfo must not be nil")
}
}
func TestSwaggerInfo_Title(t *testing.T) {
if SwaggerInfo.Title == "" {
t.Fatal("SwaggerInfo.Title must not be empty")
}
}
func TestSwaggerInfo_Version(t *testing.T) {
if SwaggerInfo.Version == "" {
t.Fatal("SwaggerInfo.Version must not be empty")
}
}
func TestSwaggerInfo_BasePath(t *testing.T) {
if SwaggerInfo.BasePath == "" {
t.Fatal("SwaggerInfo.BasePath must not be empty")
}
}
func TestSwaggerInfo_TemplateNotEmpty(t *testing.T) {
if SwaggerInfo.SwaggerTemplate == "" {
t.Fatal("SwaggerInfo.SwaggerTemplate must not be empty")
}
}
// ─── Şablon içerik doğrulamaları ─────────────────────────────────────────────
func TestSwaggerInfo_TemplateContainsAuthLogin(t *testing.T) {
if !strings.Contains(SwaggerInfo.SwaggerTemplate, "/auth/login") {
t.Fatal("swagger template must contain /auth/login path")
}
}
func TestSwaggerInfo_TemplateContainsImagesPath(t *testing.T) {
if !strings.Contains(SwaggerInfo.SwaggerTemplate, "/images") {
t.Fatal("swagger template must contain /images path")
}
}
func TestSwaggerInfo_TemplateContainsBearerAuth(t *testing.T) {
if !strings.Contains(SwaggerInfo.SwaggerTemplate, "BearerAuth") {
t.Fatal("swagger template must define BearerAuth security scheme")
}
}
// ─── Swaggo registry kaydı ───────────────────────────────────────────────────
func TestSwaggerInfo_RegisteredInSwaggo(t *testing.T) {
spec := swag.GetSwagger(SwaggerInfo.InstanceName())
if spec == nil {
t.Fatalf("SwaggerInfo not registered under name %q", SwaggerInfo.InstanceName())
}
}
func TestSwaggerInfo_InstanceNameMatchesTitle(t *testing.T) {
if SwaggerInfo.InstanceName() == "" {
t.Fatal("InstanceName must not be empty")
}
}

560
docs/swagger.json Normal file
View File

@@ -0,0 +1,560 @@
{
"swagger": "2.0",
"info": {
"description": "This is a sample image manipulation API using Fiber v3 and bimg.",
"title": "Go Image Manipulation API",
"contact": {},
"version": "1.0"
},
"host": "localhost:8080",
"basePath": "/",
"paths": {
"/admin/images": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a paginated list of all images across all users.",
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "List all images (admin)",
"parameters": [
{
"type": "integer",
"description": "Page number (default 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Items per page (default 20, max 100)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Filter by user ID",
"name": "user_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/admin/users/{id}/api-token": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Creates an API Token for a user (Admin ONLY).",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Create API Token",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Expiration in days (0 or omit for never)",
"name": "expires_in_days",
"in": "formData"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/login": {
"post": {
"description": "Authenticate user and get JWT",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Login",
"parameters": [
{
"type": "string",
"description": "Email address",
"name": "email",
"in": "formData",
"required": true
},
{
"type": "string",
"description": "Password",
"name": "password",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/refresh": {
"post": {
"description": "Get a new access token using a valid refresh token",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Refresh JWT",
"parameters": [
{
"type": "string",
"description": "Refresh token",
"name": "refresh_token",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/auth/register": {
"post": {
"description": "Register a new user with email and password",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "Register a new user",
"parameters": [
{
"type": "string",
"description": "Email address",
"name": "email",
"in": "formData",
"required": true
},
{
"type": "string",
"description": "Password (min 6 chars)",
"name": "password",
"in": "formData",
"required": true
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a paginated list of images belonging to the authenticated user.",
"produces": [
"application/json"
],
"tags": [
"Images"
],
"summary": "List images",
"parameters": [
{
"type": "integer",
"description": "Page number (default 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Items per page (default 20, max 100)",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Uploads an image and registers it to the user.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Images"
],
"summary": "Upload an image",
"parameters": [
{
"type": "file",
"description": "Image file",
"name": "image",
"in": "formData",
"required": true
},
{
"type": "integer",
"description": "Width",
"name": "w",
"in": "formData"
},
{
"type": "integer",
"description": "Height",
"name": "h",
"in": "formData"
},
{
"type": "integer",
"description": "Quality (1-100)",
"name": "q",
"in": "formData"
},
{
"type": "string",
"description": "Format (webp, avif, png, jpg)",
"name": "f",
"in": "formData"
},
{
"type": "string",
"description": "Mode (e.g. cover)",
"name": "mode",
"in": "formData"
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"400": {
"description": "Bad Request",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images/{id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns a single image record owned by the authenticated user.",
"produces": [
"application/json"
],
"tags": [
"Images"
],
"summary": "Get image by ID",
"parameters": [
{
"type": "integer",
"description": "Image ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/images/{id}/process": {
"get": {
"description": "Processes an image (resize, crop, cover, format) using the generated token.",
"produces": [
"image/jpeg",
"image/png",
"image/webp",
"image/avif"
],
"tags": [
"Images"
],
"summary": "Process Image",
"parameters": [
{
"type": "integer",
"description": "Image ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Global API Token",
"name": "token",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "Width",
"name": "w",
"in": "query"
},
{
"type": "integer",
"description": "Height",
"name": "h",
"in": "query"
},
{
"type": "integer",
"description": "Quality (1-100)",
"name": "q",
"in": "query"
},
{
"type": "string",
"description": "Format (webp, avif, png, jpg)",
"name": "f",
"in": "query"
},
{
"type": "string",
"description": "Mode (e.g. cover)",
"name": "mode",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Type \"Bearer \u003ctoken\u003e\"",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}

377
docs/swagger.yaml Normal file
View File

@@ -0,0 +1,377 @@
basePath: /
host: localhost:8080
info:
contact: {}
description: This is a sample image manipulation API using Fiber v3 and bimg.
title: Go Image Manipulation API
version: "1.0"
paths:
/admin/images:
get:
description: Returns a paginated list of all images across all users.
parameters:
- description: Page number (default 1)
in: query
name: page
type: integer
- description: Items per page (default 20, max 100)
in: query
name: limit
type: integer
- description: Filter by user ID
in: query
name: user_id
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"403":
description: Forbidden
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: List all images (admin)
tags:
- Admin
/admin/users/{id}/api-token:
post:
consumes:
- multipart/form-data
description: Creates an API Token for a user (Admin ONLY).
parameters:
- description: User ID
in: path
name: id
required: true
type: integer
- description: Expiration in days (0 or omit for never)
in: formData
name: expires_in_days
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"403":
description: Forbidden
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Create API Token
tags:
- Admin
/auth/login:
post:
consumes:
- multipart/form-data
description: Authenticate user and get JWT
parameters:
- description: Email address
in: formData
name: email
required: true
type: string
- description: Password
in: formData
name: password
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
summary: Login
tags:
- Auth
/auth/refresh:
post:
consumes:
- multipart/form-data
description: Get a new access token using a valid refresh token
parameters:
- description: Refresh token
in: formData
name: refresh_token
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties: true
type: object
summary: Refresh JWT
tags:
- Auth
/auth/register:
post:
consumes:
- multipart/form-data
description: Register a new user with email and password
parameters:
- description: Email address
in: formData
name: email
required: true
type: string
- description: Password (min 6 chars)
in: formData
name: password
required: true
type: string
produces:
- application/json
responses:
"201":
description: Created
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
summary: Register a new user
tags:
- Auth
/images:
get:
description: Returns a paginated list of images belonging to the authenticated
user.
parameters:
- description: Page number (default 1)
in: query
name: page
type: integer
- description: Items per page (default 20, max 100)
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: List images
tags:
- Images
post:
consumes:
- multipart/form-data
description: Uploads an image and registers it to the user.
parameters:
- description: Image file
in: formData
name: image
required: true
type: file
- description: Width
in: formData
name: w
type: integer
- description: Height
in: formData
name: h
type: integer
- description: Quality (1-100)
in: formData
name: q
type: integer
- description: Format (webp, avif, png, jpg)
in: formData
name: f
type: string
- description: Mode (e.g. cover)
in: formData
name: mode
type: string
produces:
- application/json
responses:
"201":
description: Created
schema:
additionalProperties: true
type: object
"400":
description: Bad Request
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Upload an image
tags:
- Images
/images/{id}:
get:
description: Returns a single image record owned by the authenticated user.
parameters:
- description: Image ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Get image by ID
tags:
- Images
/images/{id}/process:
get:
description: Processes an image (resize, crop, cover, format) using the generated
token.
parameters:
- description: Image ID
in: path
name: id
required: true
type: integer
- description: Global API Token
in: query
name: token
required: true
type: string
- description: Width
in: query
name: w
type: integer
- description: Height
in: query
name: h
type: integer
- description: Quality (1-100)
in: query
name: q
type: integer
- description: Format (webp, avif, png, jpg)
in: query
name: f
type: string
- description: Mode (e.g. cover)
in: query
name: mode
type: string
produces:
- image/jpeg
- image/png
- image/webp
- image/avif
responses:
"200":
description: OK
schema:
type: file
"401":
description: Unauthorized
schema:
additionalProperties: true
type: object
"404":
description: Not Found
schema:
additionalProperties: true
type: object
"500":
description: Internal Server Error
schema:
additionalProperties: true
type: object
summary: Process Image
tags:
- Images
securityDefinitions:
BearerAuth:
description: Type "Bearer <token>"
in: header
name: Authorization
type: apiKey
swagger: "2.0"

52
go.mod Normal file
View File

@@ -0,0 +1,52 @@
module goimgApi
go 1.26
require (
github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/h2non/bimg v1.1.9
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.49.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

138
go.sum Normal file
View File

@@ -0,0 +1,138 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

BIN
goimgApi Executable file

Binary file not shown.

421
images/handlers.go Normal file
View File

@@ -0,0 +1,421 @@
package images
import (
"fmt"
"io"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/google/uuid"
"github.com/h2non/bimg"
"goimgApi/accounts"
"goimgApi/configs"
)
// Upload godoc
// @Summary Upload an image
// @Description Uploads an image and registers it to the user.
// @Tags Images
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param image formData file true "Image file"
// @Param w formData int false "Width"
// @Param h formData int false "Height"
// @Param q formData int false "Quality (1-100)"
// @Param f formData string false "Format (webp, avif, png, jpg)"
// @Param mode formData string false "Mode (e.g. cover)"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /images [post]
// Upload saves an image file to local storage and creates a DB record
func Upload(c fiber.Ctx) error {
userIDVal := c.Locals("user_id")
if userIDVal == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
userID := userIDVal.(uint)
file, err := c.FormFile("image")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Failed to upload image file"})
}
// Resim dosyasını belleğe okuyoruz
fileHeader, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to open image file"})
}
defer func() {
_ = fileHeader.Close()
}()
buffer, err := io.ReadAll(fileHeader)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read image file"})
}
// Parametreleri Form verisinden alıyoruz
opts := ProcessOptions{}
opts.Width, _ = strconv.Atoi(c.FormValue("w"))
opts.Height, _ = strconv.Atoi(c.FormValue("h"))
opts.Quality, _ = strconv.Atoi(c.FormValue("q"))
opts.Format = c.FormValue("f")
if c.FormValue("mode") == "cover" {
opts.Cover = true
}
// Eğer herhangi bir düzenleme parametresi geldiyse, önce işliyoruz
if opts.Width > 0 || opts.Height > 0 || opts.Format != "" || opts.Quality > 0 {
processedBuffer, err := ProcessImage(buffer, opts)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Image processing failed"})
}
buffer = processedBuffer
}
// Generate a unique filename using UUID
parts := strings.Split(file.Filename, ".")
ext := ""
if len(parts) > 1 {
ext = "." + parts[len(parts)-1]
}
if opts.Format != "" {
ext = "." + strings.ToLower(opts.Format)
}
newFilename := fmt.Sprintf("%s%s", uuid.New().String(), ext)
uploadPath := imageDiskPath(newFilename)
publicPath := imagePublicPath(newFilename)
if err := os.MkdirAll("uploads", 0755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to prepare upload directory"})
}
if err := os.WriteFile(uploadPath, buffer, 0644); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
sizeInfo, _ := bimg.NewImage(buffer).Size()
imgFormat := bimg.NewImage(buffer).Type()
quality := opts.Quality
if quality == 0 {
quality = 85 // default bimg quality
}
mode := c.FormValue("mode")
if mode == "" {
mode = "original"
}
sizeInKB := int64(len(buffer)) / 1024
if sizeInKB == 0 && len(buffer) > 0 {
sizeInKB = 1 // 1 KB'dan küçükse minimum 1 KB göster
}
img := Image{
UserID: userID,
Filename: newFilename,
PublicPath: publicPath,
MimeType: file.Header.Get("Content-Type"),
Size: sizeInKB,
Width: sizeInfo.Width,
Height: sizeInfo.Height,
Quality: quality,
Format: imgFormat,
Mode: mode,
}
if err := configs.DB.Create(&img).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image record"})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "Image uploaded successfully",
"image_id": img.ID,
"filename": img.Filename,
"public_path": img.PublicPath,
"image_url": buildImageURL(c, img.PublicPath),
})
}
// ListImages godoc
// @Summary List images
// @Description Returns a paginated list of images belonging to the authenticated user.
// @Tags Images
// @Produce json
// @Security BearerAuth
// @Param page query int false "Page number (default 1)"
// @Param limit query int false "Items per page (default 20, max 100)"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /images [get]
func ListImages(c fiber.Ctx) error {
userIDVal := c.Locals("user_id")
if userIDVal == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
userID := userIDVal.(uint)
page, limit := parsePagination(c)
offset := (page - 1) * limit
var total int64
if err := configs.DB.Model(&Image{}).Where("user_id = ?", userID).Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to count images"})
}
var imgs []Image
if err := configs.DB.
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&imgs).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch images"})
}
return c.JSON(fiber.Map{
"data": enrichImages(c, imgs),
"total": total,
"page": page,
"limit": limit,
})
}
// GetImage godoc
// @Summary Get image by ID
// @Description Returns a single image record owned by the authenticated user.
// @Tags Images
// @Produce json
// @Security BearerAuth
// @Param id path int true "Image ID"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /images/{id} [get]
func GetImage(c fiber.Ctx) error {
userIDVal := c.Locals("user_id")
if userIDVal == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
userID := userIDVal.(uint)
var img Image
if err := configs.DB.Where("id = ? AND user_id = ?", c.Params("id"), userID).First(&img).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"})
}
return c.JSON(enrichImage(c, img))
}
// AdminListImages godoc
// @Summary List all images (admin)
// @Description Returns a paginated list of all images across all users.
// @Tags Admin
// @Produce json
// @Security BearerAuth
// @Param page query int false "Page number (default 1)"
// @Param limit query int false "Items per page (default 20, max 100)"
// @Param user_id query int false "Filter by user ID"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 403 {object} map[string]interface{}
// @Router /admin/images [get]
func AdminListImages(c fiber.Ctx) error {
page, limit := parsePagination(c)
offset := (page - 1) * limit
query := configs.DB.Model(&Image{})
if uid := c.Query("user_id"); uid != "" {
query = query.Where("user_id = ?", uid)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to count images"})
}
var imgs []Image
if err := query.
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&imgs).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch images"})
}
return c.JSON(fiber.Map{
"data": enrichImages(c, imgs),
"total": total,
"page": page,
"limit": limit,
})
}
// ---------- yardımcılar ----------
func parsePagination(c fiber.Ctx) (page, limit int) {
page, _ = strconv.Atoi(c.Query("page", "1"))
limit, _ = strconv.Atoi(c.Query("limit", "20"))
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
return
}
type imageResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Filename string `json:"filename"`
PublicPath string `json:"public_path"`
ImageURL string `json:"image_url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size_kb"`
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"`
}
func enrichImage(c fiber.Ctx, img Image) imageResponse {
return imageResponse{
ID: img.ID,
UserID: img.UserID,
Filename: img.Filename,
PublicPath: img.PublicPath,
ImageURL: buildImageURL(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,
}
}
func enrichImages(c fiber.Ctx, imgs []Image) []imageResponse {
result := make([]imageResponse, 0, len(imgs))
for _, img := range imgs {
result = append(result, enrichImage(c, img))
}
return result
}
// Process godoc
// @Summary Process Image
// @Description Processes an image (resize, crop, cover, format) using the generated token.
// @Tags Images
// @Produce image/jpeg,image/png,image/webp,image/avif
// @Param id path int true "Image ID"
// @Param token query string true "Global API Token"
// @Param w query int false "Width"
// @Param h query int false "Height"
// @Param q query int false "Quality (1-100)"
// @Param f query string false "Format (webp, avif, png, jpg)"
// @Param mode query string false "Mode (e.g. cover)"
// @Success 200 {file} file
// @Failure 401 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /images/{id}/process [get]
// Process handles image manipulation via bimg
func Process(c fiber.Ctx) error {
token := c.Query("token")
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "API Token required"})
}
imageIDStr := c.Params("id")
// Validate the API Token belongs to a registered User
var tokenUser accounts.User
if err := configs.DB.Where("api_token = ?", token).First(&tokenUser).Error; err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid API Token"})
}
// Check Expiration
if tokenUser.ApiTokenExpiresAt != nil && tokenUser.ApiTokenExpiresAt.Before(time.Now()) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "API Token is expired"})
}
var img Image
if err := configs.DB.Where("id = ?", imageIDStr).First(&img).Error; err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"})
}
// Read image file from disk
filePath := imageDiskPath(img.Filename)
buffer, err := os.ReadFile(filePath)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read image file from disk"})
}
// Parse query params
opts := ProcessOptions{}
opts.Width, _ = strconv.Atoi(c.Query("w"))
opts.Height, _ = strconv.Atoi(c.Query("h"))
opts.Quality, _ = strconv.Atoi(c.Query("q"))
opts.Format = c.Query("f")
if c.Query("mode") == "cover" {
opts.Cover = true
}
processedBuffer, err := ProcessImage(buffer, opts)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Image processing failed"})
}
// Set content type and return
contentType := "image/jpeg"
switch strings.ToLower(opts.Format) {
case "webp":
contentType = "image/webp"
case "png":
contentType = "image/png"
case "avif":
contentType = "image/avif"
}
c.Set("Content-Type", contentType)
return c.Send(processedBuffer)
}
func imagePublicPath(filename string) string {
return "/uploads/" + strings.TrimLeft(filename, "/")
}
func imageDiskPath(filename string) string {
return "uploads/" + strings.TrimLeft(filename, "/")
}
func buildImageURL(c fiber.Ctx, publicPath string) string {
host := strings.TrimSpace(c.Get("X-Forwarded-Host"))
if host == "" {
host = strings.TrimSpace(c.Get("Host"))
}
if host == "" {
return publicPath
}
scheme := strings.TrimSpace(c.Get("X-Forwarded-Proto"))
if scheme == "" {
scheme = c.Protocol()
}
return (&url.URL{Scheme: scheme, Host: host, Path: publicPath}).String()
}

82
images/handlers_test.go Normal file
View File

@@ -0,0 +1,82 @@
package images
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
)
func TestImagePathHelpers(t *testing.T) {
filename := "/example.png"
if got := imagePublicPath(filename); got != "/uploads/example.png" {
t.Fatalf("expected public path %q, got %q", "/uploads/example.png", got)
}
if got := imageDiskPath(filename); got != "uploads/example.png" {
t.Fatalf("expected disk path %q, got %q", "uploads/example.png", got)
}
}
func TestBuildImageURLUsesForwardedHeaders(t *testing.T) {
app := fiber.New()
app.Get("/image-url", func(c fiber.Ctx) error {
return c.SendString(buildImageURL(c, "/uploads/example.png"))
})
req := httptest.NewRequest(http.MethodGet, "/image-url", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "cdn.example.com")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
t.Cleanup(func() {
_ = resp.Body.Close()
})
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
if got := string(body); got != "https://cdn.example.com/uploads/example.png" {
t.Fatalf("expected forwarded image url, got %q", got)
}
}
func TestParsePagination(t *testing.T) {
cases := []struct {
query string
wantPage int
wantLimit int
}{
{"", 1, 20},
{"page=0&limit=0", 1, 20},
{"page=3&limit=50", 3, 50},
{"page=-1&limit=200", 1, 20},
}
for _, tc := range cases {
app := fiber.New()
app.Get("/test", func(c fiber.Ctx) error {
page, limit := parsePagination(c)
return c.JSON(fiber.Map{"page": page, "limit": limit})
})
req := httptest.NewRequest(http.MethodGet, "/test?"+tc.query, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed for query %q: %v", tc.query, err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
if resp.StatusCode != http.StatusOK {
t.Fatalf("query %q: expected 200 got %d", tc.query, resp.StatusCode)
}
}
}

20
images/models.go Normal file
View File

@@ -0,0 +1,20 @@
package images
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"`
}

72
images/processor.go Normal file
View File

@@ -0,0 +1,72 @@
package images
import (
"fmt"
"log"
"strings"
"time"
"github.com/h2non/bimg"
)
type ProcessOptions struct {
Width int
Height int
Quality int
Format string // webp, avif, png, jpg, jpeg
Cover bool
}
func ProcessImage(buffer []byte, opts ProcessOptions) ([]byte, error) {
startTime := time.Now()
img := bimg.NewImage(buffer)
origSize, _ := img.Size()
origType := img.Type()
origKB := len(buffer) / 1024
options := bimg.Options{
Width: opts.Width,
Height: opts.Height,
Quality: opts.Quality,
}
if opts.Quality == 0 {
options.Quality = 85
}
// Cover mode: Enlarge and crop to fill the dimensions
if opts.Cover {
options.Crop = true
options.Enlarge = true
}
format := strings.ToLower(opts.Format)
switch format {
case "webp":
options.Type = bimg.WEBP
case "avif":
options.Type = bimg.AVIF
case "png":
options.Type = bimg.PNG
case "jpg", "jpeg":
options.Type = bimg.JPEG
}
log.Printf("[IMAGE-PROCESS-START] Original: %dx%d (%s, %dKB). Target -> W:%d, H:%d, Q:%d, Format:%v, Cover:%v",
origSize.Width, origSize.Height, origType, origKB,
options.Width, options.Height, options.Quality, options.Type, opts.Cover)
newImage, err := img.Process(options)
if err != nil {
log.Printf("[IMAGE-PROCESS-ERROR] Failed after %v: %v", time.Since(startTime), err)
return nil, fmt.Errorf("failed to process image: %w", err)
}
newKB := len(newImage) / 1024
diff := origKB - newKB
log.Printf("[IMAGE-PROCESS-DONE] Success in %v! New Size: %dKB (Difference: %dKB)", time.Since(startTime), newKB, diff)
return newImage, nil
}

56
main.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"log"
"github.com/gofiber/fiber/v3"
"github.com/joho/godotenv"
"goimgApi/accounts"
"goimgApi/configs"
"goimgApi/images"
"goimgApi/router"
)
// @title Go Image Manipulation API
// @version 1.0
// @description This is a sample image manipulation API using Fiber v3 and bimg.
// @host localhost:8080
// @BasePath /
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer <token>"
func main() {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, relying on environment variables")
}
if err := configs.ConnectDB(); err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
if err := configs.DB.AutoMigrate(&accounts.User{}, &images.Image{}); err != nil {
log.Fatalf("Failed to run database migrations: %v", err)
}
if err := configs.ConnectRedis(); err != nil {
log.Fatalf("Failed to connect to redis: %v", err)
}
app := fiber.New(fiber.Config{
ErrorHandler: func(c fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
})
},
})
router.SetupRoutes(app)
log.Fatal(app.Listen(":8080"))
}

20
prompts.md Normal file
View File

@@ -0,0 +1,20 @@
# Development Prompts
## Adım 1: Proje Başlatma (Boilerplate)
"Go 1.26.1, Fiber v3 ve GORM kullanarak projenin ana iskeletini oluştur. MySQL ve Redis bağlantı yapılandırmalarını `internal/platform` altında hazırla. `main.go` dosyasında Fiber v3'ü ayağa kaldır ve merkezi bir hata yakalama (Error Handler) mekanizması kur."
## Adım 2: Account Modülü (Auth)
"accounts klasöründe MySQL tabanlı bir kullanıcı sistemi yaz. Kayıt (Register) ve Giriş (Login) endpoint'lerini oluştur. Şifreleri bcrypt ile sakla. Login başarılı olduğunda JWT dön. JWT içinde UserID barındırmalı."
## Adım 3: Image Modülü ve Redis Entegrasyonu
"images klasöründe resim yükleme ve metadata saklama mantığını kur. Bir resim için 'işlem token'ı' üreten bir servis yaz. Bu token Redis üzerinde belirli bir süre (TTL) saklanmalı ve sadece o resme özel manipülasyon yetkisi vermeli."
## Adım 4: Image Manipulation Logic
"Görüntü işleme servisini yaz. `disintegration/imaging` veya benzeri bir kütüphane kullanarak şu özellikleri ekle:
- Gelen en, boy ve kalite değerlerine göre resmi yeniden boyutlandır.
- 'cover' (en-boy oranını koruyarak doldurma) ve 'crop' modlarını destekle.
- Çıktıyı istenen formatta (AVIF, WEBP, vb.) encode et.
- İşlem öncesi Redis'teki erişim token'ını doğrula."
## Adım 5: Fiber V3 Route Yapılandırması
"Tüm modülleri birleştir. `/auth` ve `/images` prefix'leri ile route'ları tanımla. İmaj manipülasyon route'u şu formatta olmalı: `/images/:id/process?token=...&w=...&h=...&f=webp`."

107
router/routers.go Normal file
View File

@@ -0,0 +1,107 @@
package router
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/gofiber/fiber/v3"
"goimgApi/accounts"
"goimgApi/images"
)
func SetupRoutes(app *fiber.App) {
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Go Image API Running")
})
app.Get("/uploads/*", func(c fiber.Ctx) error {
relPath := strings.TrimPrefix(c.Params("*"), "/")
if relPath == "" {
return fiber.ErrNotFound
}
cleanPath := filepath.Clean(relPath)
if cleanPath == "." || strings.HasPrefix(cleanPath, "..") {
return fiber.ErrForbidden
}
return c.SendFile(filepath.Join("uploads", cleanPath))
})
// Swagger setup
app.Get("/docs/swagger.json", func(c fiber.Ctx) error {
raw, err := loadSwaggerSpec()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Swagger spec could not be loaded")
}
var spec map[string]any
if err := json.Unmarshal(raw, &spec); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Swagger spec is invalid")
}
delete(spec, "host")
delete(spec, "schemes")
return c.JSON(spec)
})
app.Get("/swagger", func(c fiber.Ctx) error {
c.Set("Content-Type", "text/html")
return c.SendString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.3.0/swagger-ui.css" />
</head>
<body style="margin:0;">
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.3.0/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '/docs/swagger.json',
dom_id: '#swagger-ui',
});
};
</script>
</body>
</html>`)
})
authGroup := app.Group("/auth")
authGroup.Post("/register", accounts.Register)
authGroup.Post("/login", accounts.Login)
authGroup.Post("/refresh", accounts.Refresh)
adminGroup := app.Group("/admin", accounts.JWTMiddleware, accounts.AdminMiddleware)
adminGroup.Post("/users/:id/api-token", accounts.CreateApiToken)
adminGroup.Get("/images", images.AdminListImages)
imagesGroup := app.Group("/images", accounts.JWTMiddleware)
imagesGroup.Post("/", images.Upload)
imagesGroup.Get("/", images.ListImages)
imagesGroup.Get("/:id", images.GetImage)
// Process endpoint uses API token authentication (not JWT); keep it outside the JWT group
app.Get("/images/:id/process", images.Process)
}
func loadSwaggerSpec() ([]byte, error) {
if raw, err := os.ReadFile("./docs/swagger.json"); err == nil {
return raw, nil
}
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
return nil, os.ErrNotExist
}
projectRoot := filepath.Dir(filepath.Dir(currentFile))
return os.ReadFile(filepath.Join(projectRoot, "docs", "swagger.json"))
}

74
router/routers_test.go Normal file
View File

@@ -0,0 +1,74 @@
package router
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gofiber/fiber/v3"
)
func TestSwaggerJSONUsesRequestOrigin(t *testing.T) {
app := fiber.New()
SetupRoutes(app)
req := httptest.NewRequest(http.MethodGet, "/docs/swagger.json", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
t.Cleanup(func() {
_ = resp.Body.Close()
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
var spec map[string]any
if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil {
t.Fatalf("failed to decode swagger json: %v", err)
}
if _, ok := spec["host"]; ok {
t.Fatal("swagger spec should not expose a fixed host")
}
if _, ok := spec["schemes"]; ok {
t.Fatal("swagger spec should not expose fixed schemes")
}
}
func TestUploadsAreServedStatically(t *testing.T) {
app := fiber.New()
SetupRoutes(app)
if err := os.MkdirAll("uploads", 0755); err != nil {
t.Fatalf("failed to create uploads dir: %v", err)
}
filename := "router-static-test.txt"
filePath := filepath.Join("uploads", filename)
if err := os.WriteFile(filePath, []byte("hello-image"), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
t.Cleanup(func() {
_ = os.Remove(filePath)
})
req := httptest.NewRequest(http.MethodGet, "/uploads/"+filename, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
t.Cleanup(func() {
_ = resp.Body.Close()
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
}

21
rules.md Normal file
View File

@@ -0,0 +1,21 @@
# Go Image Manipulation API - Project Rules (libvips/bimg Version)
## 1. Tech Stack
- Language: Go 1.26.1
- Web Framework: Fiber v3 (github.com/gofiber/fiber/v3)
- Image Engine: libvips via bimg (github.com/h2non/bimg)
- Database: GORM (MySQL) & Redis (Auth/Token storage)
## 2. Image Processing Logic
- Use `bimg` for all transformations.
- Supported Input/Output: AVIF, WEBP, PNG, JPG.
- Quality: Map 1-100 values to `bimg.Options.Quality`.
- Transformation Types:
- `Cover`: Use `bimg.Options.Enlarge = true` and `bimg.Options.Crop = true`.
- `Crop`: Use `bimg.Options.Type = bimg.ImageType` (Target format).
- `Resize`: Use `bimg.Options.Width` and `bimg.Options.Height`.
## 3. Performance & Safety
- **CRITICAL:** `libvips` C kaynaklarını kullanır. Bellek sızıntılarını önlemek için `bimg.VipsFree()` veya garbage collection dostu yapıları takip et.
- **Async Processing:** Büyük işlemler için Fiber'in `ctx.Next()` yapısını bozmadan `pool` mantığını değerlendir.
- **Redis Security:** Her manipülasyon isteği `?token=` parametresiyle gelmeli ve Redis'teki `image_id` ile eşleşmeli.

1
tmp/build-errors.log Normal file
View File

@@ -0,0 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1

BIN
tmp/main Executable file

Binary file not shown.

34
tmp/test_db.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"fmt"
"goimgApi/accounts"
"goimgApi/configs"
"time"
"github.com/joho/godotenv"
)
func main() {
godotenv.Load(".env")
configs.ConnectDB()
var user accounts.User
configs.DB.First(&user, 1)
exp := time.Now().Add(30 * 24 * time.Hour)
user.ApiTokenExpiresAt = &exp
user.IsAdmin = true // Ensure admin
err := configs.DB.Save(&user).Error
if err != nil {
fmt.Println("SAVE ERROR:", err)
}
var user2 accounts.User
configs.DB.First(&user2, 1)
if user2.ApiTokenExpiresAt != nil {
fmt.Println("DB HAS:", *user2.ApiTokenExpiresAt)
} else {
fmt.Println("DB IS EMPTY")
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB