first commit
58
.air.toml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#:schema https://json.schemastore.org/any.json
|
||||||
|
|
||||||
|
env_files = []
|
||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
entrypoint = ["./tmp/main"]
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
ignore_dangerous_root_dir = false
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
app_port = 0
|
||||||
|
app_start_timeout = 0
|
||||||
|
enabled = false
|
||||||
|
proxy_port = 0
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
54
.env
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 | 1–100 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:"-"`
|
||||||
|
}
|
||||||
48
accounts/models/account.go
Normal 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
|
||||||
|
|
||||||
|
}
|
||||||
65
accounts/models/models_test.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||||
421
images/handlers.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
34
tmp/test_db.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
uploads/035c16c8-8ecb-4e11-91a5-6df4a4991019.jpg
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
uploads/1d507678-376e-4a0e-a730-2f44c664df42.avif
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
uploads/35e477d4-7bc3-42ff-9dd5-1cbf0fe064d2.avif
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
uploads/44f9bab5-c8ea-4589-9150-ac2455aa8b23.avif
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
uploads/9ab44247-08af-435a-b7e4-55bbf0a1c171.avif
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
uploads/a3992401-66d6-4de4-a6a7-aec02ac4de2a.webp
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
uploads/b089423c-8acc-4e78-a003-ad63b607c452.jpg
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
uploads/c5148f85-a96a-4b4c-b7f7-4c151d057f95.avif
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
uploads/e5f39d8f-a083-40ff-bf31-f23621fc2b64.avif
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
uploads/f8f39e0a-3951-4eaa-9848-eaf38c6c8fb8.jpg
Normal file
|
After Width: | Height: | Size: 863 KiB |