commit 60db80892b447350050fc2fea0eb5ecc5809d1d4 Author: Beyhan Oğur Date: Sun Apr 26 21:45:19 2026 +0300 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ed479c5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cdd92e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.idea +.vscode +tmp +uploads +node_modules +dist +.DS_Store +main +.env +docker-compose.yml +Dockerfile diff --git a/.env b/.env new file mode 100644 index 0000000..0bba571 --- /dev/null +++ b/.env @@ -0,0 +1,65 @@ +### Db Configuration +DB_URL="gofiber:gg7678290@tcp(10.80.80.70:3306)/gofiber?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=30s&writeTimeout=30s&multiStatements=true" +########################## +# Redis Configuration +REDIS_HOST=10.80.80.70 +REDIS_PORT=6379 +REDIS_USER=default +REDIS_PASSWORD=gg7678290 +REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/4 +############################# +# JWT Secret +JWT_SECRET=go-fibere-CT286MautyK9TWfz8SPSIWJTXV83mwLRwriLvSbpcMZuDLxywaHWP9Ju7xRoTS2 +############################# +# 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 +############################# +# App Genel Ayarları +PORT=8080 +################################ +# AVATANE IMAGES +AVATAR_H=150 +AVATAR_W=150 +AVATAR_Q=90 +AVATAR_B=cover +AVATAR_F=webp +####################### +# Home IMAGES +HOME_IMAGE_H=400 +HOME_IMAGE_W=400 +HOME_IMAGE_Q=90 +HOME_IMAGE_B=cover +HOME_IMAGE_F=webp +####################### +# Aboutme IMAGES +ABOUTME_IMAGE_H=400 +ABOUTME_IMAGE_W=400 +ABOUTME_IMAGE_Q=90 +ABOUTME_IMAGE_B=cover +ABOUTME_IMAGE_F=webp +####################### +# MyService IMAGES +SERVICE_IMAGE_H=256 +SERVICE_IMAGE_W=256 +SERVICE_IMAGE_Q=90 +SERVICE_IMAGE_B=cover +SERVICE_IMAGE_F=webp +####################### +# BANNER IMAGES +BANNER_IMAGE_H=700 +BANNER_IMAGE_W=1920 +BANNER_IMAGE_Q=85 +BANNER_IMAGE_B=cover +BANNER_IMAGE_F=webp +################################ +################################ +CORS_DEBUG=true +VITE_API_BASE_URL=http://localhost:8080 +FRONTEND_URL=http://localhost:3000 +SESSION_SECRET=go-fiber-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b0501a --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test +tmp/ +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ +tmp +# Go workspace file +go.work +go.work.sum diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9df0cd1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# Build stage +FROM golang:1.25.7-alpine AS builder + +WORKDIR /app + +# Build-time args (passed from docker-compose build) +ARG DB_URL +ARG REDIS_URL +ARG REDIS_HOST +ARG REDIS_PORT +ARG EMAIL_HOST +ARG EMAIL_PORT + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download all dependencies +RUN go mod download + +# Copy the source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o main . + +# Final stage +FROM alpine:latest + +WORKDIR /app + +# Install necessary runtime dependencies +RUN apk add --no-cache ca-certificates tzdata + +# Set runtime ENV from build args (1:1 usage) +ENV DB_URL=${DB_URL} +ENV REDIS_URL=${REDIS_URL} +ENV REDIS_HOST=${REDIS_HOST} +ENV REDIS_PORT=${REDIS_PORT} +ENV EMAIL_HOST=${EMAIL_HOST} +ENV EMAIL_PORT=${EMAIL_PORT} + +# Copy the binary from builder +COPY --from=builder /app/main . + + + +# Copy docs and views for static serving if not mounted +COPY docs ./docs +COPY views ./views + +# Create uploads directory +RUN mkdir -p uploads + +# Expose the port +EXPOSE 8080 + +# Command to run the executable +CMD ["./main"] diff --git a/Prpmpt.md b/Prpmpt.md new file mode 100644 index 0000000..d1c8dcd --- /dev/null +++ b/Prpmpt.md @@ -0,0 +1,27 @@ +github.com/go-playground/validator/v10 v10.30.1 +github.com/go-sql-driver/mysql v1.9.3 +github.com/gofiber/fiber/v3 v3.0.0 +github.com/golang-jwt/jwt/v5 v5.3.1 +github.com/joho/godotenv v1.5.1 +golang.org/x/crypto v0.48.0 +gorm.io/driver/mysql v1.6.0 +gorm.io/gorm v1.31.1 + +projedikde kullanılacak paketler bunlar şuan +paketlerinin versiyonlarının olduğu go.mod dosyasında görünüyor. +başka bir paket eklenmesi gerekirse go.mod dosyasına eklenmeli. +paketlerin versiyonlarini kesinlik ile değiştirmak yok !! + +Uygulamada Yapmak istegim User için bir register ve login işlemi yapmak istiyorum. +Backend api hizmeti verek jwt token access_tokne ve refresh_token olacak. +access_token 120 dakika süre ile refresh_token 30 gün süre ile geçerli olacak. +access_token ve refresh_token için jwt token oluşturulacak. +access_token ve refresh_token için jwt token oluşturulurken user id ve email bilgileri is_admin bilgisi +Profile modelinin içindeki FirstName,LastName kullanılacak. + +Github ve Google login register için gereken alt yapi ve endpoint apileri olusturulacak. + +bunlari yarken benim kums oldugum klasor yapisi kullanilacak. +ve mumkun olduğuca her işlem basit anlaşilir tutulacak. + + diff --git a/client.rest b/client.rest new file mode 100644 index 0000000..2da0ca5 --- /dev/null +++ b/client.rest @@ -0,0 +1,71 @@ +### Get all heroes (no auth) +GET http://localhost:8080/api/v1/heroes +Accept: application/json + +### Get active heroes (no auth) +GET http://localhost:8080/api/v1/hero +Accept: application/json + +### Update hero (JSON) — requires admin token +PUT http://localhost:8080/api/v1/hero/1 +Content-Type: application/json +Authorization: Bearer {{ADMIN_TOKEN}} + +{ + "title": "updated-via-rest", + "is_active": false +} + +### Update hero (multipart/form-data) — send file + is_active=false +PUT http://localhost:8080/api/v1/hero/1 +Authorization: Bearer {{ADMIN_TOKEN}} +Content-Type: multipart/form-data; boundary=---011000010111000001101001 + +Content-Disposition: form-data; name="title" + +multipart-update +Content-Disposition: form-data; name="is_active" + +false +Content-Disposition: form-data; name="image"; filename="test.jpg" +Content-Type: image/jpeg + +< ./path/to/test.jpg + +### Delete hero (admin) +DELETE http://localhost:8080/api/v1/hero/1 +Authorization: Bearer {{ADMIN_TOKEN}} + +# Equivalent curl examples: +# +# curl GET all heroes +# curl -sS http://localhost:8080/api/v1/heroes | jq '.' +# +# curl update JSON +# curl -X PUT "http://localhost:8080/api/v1/hero/1" -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"title":"test","is_active":false}' +# +# curl multipart (with image) +# curl -X PUT "http://localhost:8080/api/v1/hero/1" -H "Authorization: Bearer " -F "title=multipart-test" -F "is_active=false" -F "image=@/absolute/path/to/image.jpg" + +### Update user (multipart/form-data) — upload avatar + fields (admin) +PUT http://localhost:8080/api/v1/users/2 +Authorization: Bearer {{ADMIN_TOKEN}} +Content-Type: multipart/form-data; boundary=---011000010111000001101001 + +-----011000010111000001101001 +Content-Disposition: form-data; name="first_name" + +Ayse +-----011000010111000001101001 +Content-Disposition: form-data; name="email_verified" + +false +-----011000010111000001101001 +Content-Disposition: form-data; name="avatar"; filename="avatar.jpg" +Content-Type: image/jpeg + +< ./path/to/avatar.jpg +-----011000010111000001101001-- + +# curl equivalent: +# curl -X PUT "http://localhost:8080/api/v1/users/2" -H "Authorization: Bearer " -F "first_name=Ayse" -F "email_verified=false" -F "avatar=@/absolute/path/to/avatar.jpg" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d799f95 --- /dev/null +++ b/config/config.go @@ -0,0 +1,235 @@ +package configs + +import ( + "log" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +type Config struct { + Env string // örn. development, production + Port string + DBUrl string + JWTSecret string + AppURL string // örn. https://api.example.com - e-posta doğrulama linkleri için kullanılır + GoogleClientID string + GoogleClientSecret string + GithubClientID string + GithubClientSecret string + GoogleRedirectURL string + GithubRedirectURL string + ClientCallbackURL string + OAuthRedirectURL string + RedisUrl string + AccessTokenExpireMinutes int + RefreshTokenExpireDays int + + // Avatar Ayarları + AvatarHeight int + AvatarWidth int + AvatarQuality int + AvatarFormat string + AvatarMode string // cover, contain, resize + + // Ana Sayfa Resim Ayarları + HomeImageHeight int + HomeImageWidth int + HomeImageQuality int + HomeImageFormat string + HomeImageMode string // cover, contain, resize + + // Hakkında Resim Ayarları + AboutImageHeight int + AboutImageWidth int + AboutImageQuality int + AboutImageFormat string + AboutImageMode string // cover, contain, resize + + // Servis Resim Ayarları + ServiceImageHeight int + ServiceImageWidth int + ServiceImageQuality int + ServiceImageFormat string + ServiceImageMode string // cover, contain, resize + + // Gönderi Resim Ayarları + PostImageHeight int + PostImageWidth int + PostImageQuality int + PostImageFormat string + PostImageMode string // cover, contain, resize + + // Gönderi Kategori Resim Ayarları + PostCategoryImageHeight int + PostCategoryImageWidth int + PostCategoryImageQuality int + PostCategoryImageFormat string + PostCategoryImageMode string // cover, contain, resize + + // Site Logo Ayarları + SettingsLogoHeight int + SettingsLogoWidth int + SettingsLogoQuality int + SettingsLogoFormat string + SettingsLogoMode string // cover, contain, resize + + // Afiş Resim Ayarları + BannerImageHeight int + BannerImageWidth int + BannerImageQuality int + BannerImageFormat string + BannerImageMode string // cover, contain, resize + + // Afiş Küçük Resim (Thumb) Ayarları + BannerThumbHeight int + BannerThumbWidth int + BannerThumbQuality int + BannerThumbFormat string + BannerThumbMode string // cover, contain, resize + + // E-posta Ayarları + EmailHost string + EmailPort string + EmailHostUser string + EmailHostPassword string + EmailFrom string + CorsDebug bool +} + +var AppConfig *Config + +func LoadConfig() { + err := godotenv.Load() + if err != nil { + // Eğer proje kök dizininden çalıştırılmıyorsa (örn: cmd/app içinden), üst dizinleri kontrol et + err = godotenv.Load(".env") + //err = godotenv.Load("../../.env") + } + if err != nil { + log.Printf("Uyarı: .env dosyası yüklenirken hata oluştu: %v — sistem ortam değişkenleriyle devam ediliyor", err) + } + + AppConfig = &Config{ + Env: getEnv("APP_ENV", "development"), + Port: getEnv("PORT", "8080"), + DBUrl: getEnv("DB_URL", ""), + JWTSecret: getEnv("JWT_SECRET", "default_secret"), + AppURL: getEnv("APP_URL", "http://localhost:8080"), + GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), + GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), + GithubClientID: getEnv("GITHUB_CLIENT_ID", ""), + GithubClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""), + GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback"), + GithubRedirectURL: getEnv("GITHUB_REDIRECT_URL", "http://localhost:8080/api/v1/auth/github/callback"), + ClientCallbackURL: getEnv("CLIENT_CALLBACK_URL", ""), + OAuthRedirectURL: getEnv("OAUTH_REDIRECT_URL", ""), + RedisUrl: getEnv("REDIS_URL", ""), + AccessTokenExpireMinutes: getEnvAsInt("ACCESS_TOKEN_EXPIRE_MINUTES", 120), // Varsayılan 120 dakika + RefreshTokenExpireDays: getEnvAsInt("REFRESH_TOKEN_EXPIRE_DAYS", 30), // Varsayılan 30 gün + + // Avatar Varsayılanları + AvatarHeight: getEnvAsInt("AVATAR_H", 0), // Varsayılan 0 (otomatik) + AvatarWidth: getEnvAsInt("AVATAR_W", 800), // Varsayılan 800 + AvatarQuality: getEnvAsInt("AVATAR_Q", 80), // Varsayılan 80 + AvatarFormat: getEnv("AVATAR_F", "webp"), // Varsayılan webp + AvatarMode: getEnv("AVATAR_B", "contain"), // Varsayılan contain (Fit) + + // Ana Sayfa Resim Varsayılanları + HomeImageHeight: getEnvAsInt("HOME_IMAGE_H", 0), // Varsayılan 0 (otomatik) + HomeImageWidth: getEnvAsInt("HOME_IMAGE_W", 800), // Varsayılan 800 + HomeImageQuality: getEnvAsInt("HOME_IMAGE_Q", 80), // Varsayılan 80 + HomeImageFormat: getEnv("HOME_IMAGE_F", "webp"), // Varsayılan webp + HomeImageMode: getEnv("HOME_IMAGE_B", "contain"), // Varsayılan contain (Fit) + + // Hakkında Resim Varsayılanları + AboutImageHeight: getEnvAsInt("ABOUTME_IMAGE_H", getEnvAsInt("ABOUT_IMAGE_H", 0)), + AboutImageWidth: getEnvAsInt("ABOUTME_IMAGE_W", getEnvAsInt("ABOUT_IMAGE_W", 800)), + AboutImageQuality: getEnvAsInt("ABOUTME_IMAGE_Q", getEnvAsInt("ABOUT_IMAGE_Q", 80)), + AboutImageFormat: getEnv("ABOUTME_IMAGE_F", getEnv("ABOUT_IMAGE_F", "webp")), + AboutImageMode: getEnv("ABOUTME_IMAGE_B", getEnv("ABOUT_IMAGE_B", "contain")), + + // Servis Resim Varsayılanları + ServiceImageHeight: getEnvAsInt("SERVICE_IMAGE_H", 256), + ServiceImageWidth: getEnvAsInt("SERVICE_IMAGE_W", 256), + ServiceImageQuality: getEnvAsInt("SERVICE_IMAGE_Q", 90), + ServiceImageFormat: getEnv("SERVICE_IMAGE_F", "png"), + ServiceImageMode: getEnv("SERVICE_IMAGE_B", "cover"), + + // Gönderi Resim Varsayılanları + PostImageHeight: getEnvAsInt("POST_IMAGE_H", 450), + PostImageWidth: getEnvAsInt("POST_IMAGE_W", 700), + PostImageQuality: getEnvAsInt("POST_IMAGE_Q", 90), + PostImageFormat: getEnv("POST_IMAGE_F", "webp"), + PostImageMode: getEnv("POST_IMAGE_B", "cover"), + + // Gönderi Kategori Resim Varsayılanları + PostCategoryImageHeight: getEnvAsInt("POST_CATEGORY_IMAGE_H", 300), + PostCategoryImageWidth: getEnvAsInt("POST_CATEGORY_IMAGE_W", 300), + PostCategoryImageQuality: getEnvAsInt("POST_CATEGORY_IMAGE_Q", 85), + PostCategoryImageFormat: getEnv("POST_CATEGORY_IMAGE_F", "png"), + PostCategoryImageMode: getEnv("POST_CATEGORY_IMAGE_B", "cover"), + + // Site Logo Varsayılanları + SettingsLogoHeight: getEnvAsInt("SETTINGS_LOGO_H", 54), + SettingsLogoWidth: getEnvAsInt("SETTINGS_LOGO_W", 165), + SettingsLogoQuality: getEnvAsInt("SETTINGS_LOGO_Q", 85), + SettingsLogoFormat: getEnv("SETTINGS_LOGO_F", "png"), + SettingsLogoMode: getEnv("SETTINGS_LOGO_B", "cover"), + + // Afiş Resim Varsayılanları + BannerImageHeight: getEnvAsInt("BANNER_IMAGE_H", 700), + BannerImageWidth: getEnvAsInt("BANNER_IMAGE_W", 1920), + BannerImageQuality: getEnvAsInt("BANNER_IMAGE_Q", 85), + BannerImageFormat: getEnv("BANNER_IMAGE_F", "webp"), + BannerImageMode: getEnv("BANNER_IMAGE_B", "cover"), + + // Afiş Küçük Resim (Thumb) Varsayılanları + BannerThumbHeight: getEnvAsInt("BANNER_THUMB_H", 48), + BannerThumbWidth: getEnvAsInt("BANNER_THUMB_W", 48), + BannerThumbQuality: getEnvAsInt("BANNER_THUMB_Q", 90), + BannerThumbFormat: getEnv("BANNER_THUMB_F", "png"), + BannerThumbMode: getEnv("BANNER_THUMB_B", "cover"), + + // E-posta Varsayılanları + EmailHost: getEnv("EMAIL_HOST", "localhost"), + EmailPort: getEnv("EMAIL_PORT", "1025"), + EmailHostUser: getEnv("EMAIL_HOST_USER", ""), + EmailHostPassword: getEnv("EMAIL_HOST_PASSWORD", ""), + EmailFrom: getEnv("EMAIL_FROM", "noreply@gauth.local"), + CorsDebug: getEnvAsBool("CORS_DEBUG", false), + } +} + +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return fallback +} + +func getEnvAsInt(key string, fallback int) int { + valueStr := getEnv(key, "") + if valueStr == "" { + return fallback + } + value, err := strconv.Atoi(valueStr) + if err != nil { + return fallback + } + return value +} + +func getEnvAsBool(key string, fallback bool) bool { + valueStr := strings.TrimSpace(getEnv(key, "")) + if valueStr == "" { + return fallback + } + value, err := strconv.ParseBool(valueStr) + if err != nil { + return fallback + } + return value +} diff --git a/controllers/blog_controller.go b/controllers/blog_controller.go new file mode 100644 index 0000000..08f6d02 --- /dev/null +++ b/controllers/blog_controller.go @@ -0,0 +1,1364 @@ +package controllers + +import ( + "encoding/json" + "errors" + "fmt" + database "goFiber/database/config" + "goFiber/database/models" + "mime/multipart" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// reuse package-level `validate` defined in other controller files +// (one global validator instance is sufficient) + +func parseIDsCSV(s string) []uint { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]uint, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + v, err := strconv.ParseUint(p, 10, 64) + if err == nil { + out = append(out, uint(v)) + } + } + return out +} + +// slugify converts a title to a URL-friendly slug +func slugify(s string) string { + // replace common Turkish characters with ASCII equivalents + replacer := strings.NewReplacer( + "Ç", "C", "ç", "c", + "Ğ", "G", "ğ", "g", + "İ", "I", "ı", "i", + "Ö", "O", "ö", "o", + "Ş", "S", "ş", "s", + "Ü", "U", "ü", "u", + ) + s = replacer.Replace(s) + s = strings.ToLower(s) + re := regexp.MustCompile("[^a-z0-9]+") + s = re.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + return s +} + +// UpdateCategoryRequest represents payload for updating a category +// swagger:model UpdateCategoryRequest +type UpdateCategoryRequest struct { + Title *string `json:"title" validate:"omitempty,min=2"` + Description *string `json:"description"` + ParentID *uint `json:"parent_id"` +} + +// makeUniqueSlug ensures the slug is unique in posts table, appending suffix if needed +func makeUniqueSlug(base string) string { + slug := base + var count int64 + i := 1 + for { + database.DB.Model(&models.Post{}).Where("slug = ?", slug).Count(&count) + if count == 0 { + return slug + } + slug = base + "-" + strconv.Itoa(i) + i++ + } +} + +// makeUniqueSlugExclude ensures uniqueness excluding a specific post id (used on updates) +func makeUniqueSlugExclude(base string, excludeID uint64) string { + slug := base + var count int64 + i := 1 + for { + database.DB.Model(&models.Post{}).Where("slug = ? AND id != ?", slug, excludeID).Count(&count) + if count == 0 { + return slug + } + slug = base + "-" + strconv.Itoa(i) + i++ + } +} + +// makeUniqueSlugCategory ensures the slug is unique in categories table +func makeUniqueSlugCategory(base string) string { + slug := base + var count int64 + i := 1 + for { + database.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&count) + if count == 0 { + return slug + } + slug = base + "-" + strconv.Itoa(i) + i++ + } +} + +// makeUniqueSlugCategoryExclude ensures the slug is unique in categories table excluding an id +func makeUniqueSlugCategoryExclude(base string, excludeID uint64) string { + slug := base + var count int64 + i := 1 + for { + database.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", slug, excludeID).Count(&count) + if count == 0 { + return slug + } + slug = base + "-" + strconv.Itoa(i) + i++ + } +} + +func saveUploadedFiles(c fiber.Ctx, files []*multipart.FileHeader) ([]string, error) { + if len(files) == 0 { + return nil, nil + } + saved := make([]string, 0, len(files)) + for _, fh := range files { + filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), fh.Filename) + path := filepath.Join("./uploads/posts", filename) + if err := c.SaveFile(fh, path); err != nil { + return nil, err + } + saved = append(saved, "/uploads/posts/"+filename) + } + return saved, nil +} + +// -------- POSTS -------- + +// GetPosts godoc +// @Summary List posts (public) with pagination +// @Tags Posts +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/posts [get] +func GetPosts(c fiber.Ctx) error { + pageStr := c.Query("page", "1") + perPageStr := c.Query("per_page", "10") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 10 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + var total int64 + database.DB.Model(&models.Post{}).Count(&total) + + var posts []models.Post + database.DB.Preload("Categories").Preload("Tags").Limit(perPage).Offset(offset).Find(&posts) + + return c.JSON(fiber.Map{ + "data": posts, + "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}, + }) +} + +// GetPost godoc +// @Summary Get single post (public) +// @Tags Posts +// @Produce json +// @Param id path int true "Post ID" +// @Success 200 {object} models.PostDoc +// @Failure 404 {object} map[string]string +// @Router /api/v1/posts/{id} [get] +func GetPost(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var post models.Post + if err := database.DB.Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"}) + } + return c.JSON(post) +} + +// CreatePost godoc +// @Summary Create a post (admin only) +// @Tags Posts +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param title formData string true "Title" +// @Param content formData string false "Content" +// @Param category_ids formData string false "Comma separated category ids" +// @Param tag_ids formData string false "Comma separated tag ids" +// @Param images formData file false "Images (multiple allowed)" +// @Success 201 {object} models.PostDoc +// @Failure 400 {object} map[string]string +// @Router /api/v1/posts [post] +func CreatePost(c fiber.Ctx) error { + // Support both multipart/form-data and JSON + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + form, err := c.MultipartForm() + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid multipart form"}) + } + title := firstValue(form.Value, "title") + if title == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "title required"}) + } + content := firstValue(form.Value, "content") + catIDs := parseIDsCSV(firstValue(form.Value, "category_ids")) + tagIDs := parseIDsCSV(firstValue(form.Value, "tag_ids")) + files := form.File["images"] + saved, err := saveUploadedFiles(c, files) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save images"}) + } + imgsJSON, _ := json.Marshal(saved) + baseSlug := slugify(title) + post := models.Post{Title: title, Content: content, Images: string(imgsJSON)} + post.Slug = makeUniqueSlug(baseSlug) + if len(catIDs) > 0 { + var cats []models.Category + database.DB.Find(&cats, catIDs) + post.Categories = cats + } + if len(tagIDs) > 0 { + var tags []models.Tag + database.DB.Find(&tags, tagIDs) + post.Tags = tags + } + if err := database.DB.Create(&post).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create post"}) + } + return c.Status(http.StatusCreated).JSON(post) + } + + // JSON fallback + var input struct { + Title string `json:"title" validate:"required,min=3"` + Content string `json:"content"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` + Images []string `json:"images"` + } + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + imgsJSON, _ := json.Marshal(input.Images) + baseSlug := slugify(input.Title) + post := models.Post{Title: input.Title, Content: input.Content, Images: string(imgsJSON)} + post.Slug = makeUniqueSlug(baseSlug) + if len(input.CategoryIDs) > 0 { + var cats []models.Category + database.DB.Find(&cats, input.CategoryIDs) + post.Categories = cats + } + if len(input.TagIDs) > 0 { + var tags []models.Tag + database.DB.Find(&tags, input.TagIDs) + post.Tags = tags + } + if err := database.DB.Create(&post).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create post"}) + } + return c.Status(http.StatusCreated).JSON(post) +} + +func firstValue(m map[string][]string, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok && len(v) > 0 { + return v[0] + } + return "" +} + +// UpdatePost godoc +// @Summary Update a post (admin only) +// @Tags Posts +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param id path int true "Post ID" +// @Param title formData string false "Title" +// @Param content formData string false "Content" +// @Param category_ids formData string false "Comma separated category ids" +// @Param tag_ids formData string false "Comma separated tag ids" +// @Param images formData file false "Images (multiple allowed)" +// @Success 200 {object} models.PostDoc +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/posts/{id} [put] +func UpdatePost(c fiber.Ctx) error { + // multipart or JSON handling similar to CreatePost + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var post models.Post + if err := database.DB.Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"}) + } + + if strings.HasPrefix(c.Get("Content-Type"), "multipart/form-data") { + form, err := c.MultipartForm() + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid multipart form"}) + } + title := firstValue(form.Value, "title") + if title != "" { + post.Title = title + // regenerate slug on title change + post.Slug = makeUniqueSlugExclude(slugify(title), uint64(post.ID)) + } + content := firstValue(form.Value, "content") + if content != "" { + post.Content = content + } + catIDs := parseIDsCSV(firstValue(form.Value, "category_ids")) + if len(catIDs) > 0 { + var cats []models.Category + database.DB.Find(&cats, catIDs) + if err := database.DB.Model(&post).Association("Categories").Replace(&cats); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update categories"}) + } + } + tagIDs := parseIDsCSV(firstValue(form.Value, "tag_ids")) + if len(tagIDs) > 0 { + var tags []models.Tag + database.DB.Find(&tags, tagIDs) + if err := database.DB.Model(&post).Association("Tags").Replace(&tags); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update tags"}) + } + } + files := form.File["images"] + if len(files) > 0 { + saved, err := saveUploadedFiles(c, files) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save images"}) + } + imgsJSON, _ := json.Marshal(saved) + post.Images = string(imgsJSON) + } + if err := database.DB.Save(&post).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update post"}) + } + return c.JSON(post) + } + + // JSON fallback + var input struct { + Title *string `json:"title" validate:"omitempty,min=3"` + Content *string `json:"content"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` + Images []string `json:"images"` + } + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + if input.Title != nil { + post.Title = *input.Title + post.Slug = makeUniqueSlugExclude(slugify(*input.Title), uint64(post.ID)) + } + if input.Content != nil { + post.Content = *input.Content + } + if input.CategoryIDs != nil { + var cats []models.Category + database.DB.Find(&cats, input.CategoryIDs) + if err := database.DB.Model(&post).Association("Categories").Replace(&cats); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update categories"}) + } + } + if input.TagIDs != nil { + var tags []models.Tag + database.DB.Find(&tags, input.TagIDs) + if err := database.DB.Model(&post).Association("Tags").Replace(&tags); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update tags"}) + } + } + if input.Images != nil { + imgsJSON, _ := json.Marshal(input.Images) + post.Images = string(imgsJSON) + } + if err := database.DB.Save(&post).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update post"}) + } + return c.JSON(post) +} + +// DeletePost godoc +// @Summary Delete a post (admin only) +// @Tags Posts +// @Produce json +// @Security BearerAuth +// @Param id path int true "Post ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/posts/{id} [delete] +func DeletePost(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var post models.Post + if err := database.DB.First(&post, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"}) + } + if err := database.DB.Delete(&post).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete post"}) + } + return c.JSON(fiber.Map{"message": "post deleted"}) +} + +// AdminListPosts godoc +// @Summary List posts (admin) with optional trashed filter +// @Tags Posts +// @Produce json +// @Security BearerAuth +// @Param trashed query string false "Trash filter: none|only|with" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/posts [get] +func AdminListPosts(c fiber.Ctx) error { + trashed := c.Query("trashed", "none") + if trashed != "none" && trashed != "only" && trashed != "with" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"}) + } + + pageStr := c.Query("page", "1") + perPageStr := c.Query("per_page", "10") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 10 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + var total int64 + var posts []models.Post + db := database.DB + + switch trashed { + case "none": + db.Model(&models.Post{}).Count(&total) + db.Preload("Categories").Preload("Tags").Order("id desc").Limit(perPage).Offset(offset).Find(&posts) + case "only": + db.Unscoped().Model(&models.Post{}).Where("deleted_at IS NOT NULL").Count(&total) + db.Unscoped().Preload("Categories").Preload("Tags").Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&posts) + case "with": + db.Unscoped().Model(&models.Post{}).Count(&total) + db.Unscoped().Preload("Categories").Preload("Tags").Order("id desc").Limit(perPage).Offset(offset).Find(&posts) + } + + return c.JSON(fiber.Map{"data": posts, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}}) +} + +// HardDeletePost godoc +// @Summary Permanently delete a post (admin only) +// @Tags Posts +// @Produce json +// @Security BearerAuth +// @Param id path int true "Post ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/posts/{id}/hard [delete] +func HardDeletePost(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid post id"}) + } + + var post models.Post + if err := database.DB.Unscoped().First(&post, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + err = database.DB.Transaction(func(tx *gorm.DB) error { + // clear many2many associations to avoid orphaned join rows + if err := tx.Model(&post).Association("Categories").Clear(); err != nil { + return err + } + if err := tx.Model(&post).Association("Tags").Clear(); err != nil { + return err + } + // permanently delete the post + return tx.Unscoped().Delete(&post).Error + }) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "post hard-delete failed"}) + } + + return c.JSON(fiber.Map{ + "message": "post permanently deleted", + "post_id": id, + }) +} + +// AdminRestorePost godoc +// @Summary Restore soft-deleted post (admin only) +// @Tags Posts +// @Produce json +// @Security BearerAuth +// @Param id path int true "Post ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/posts/{id}/restore [post] +func AdminRestorePost(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid post id"}) + } + + var post models.Post + if err := database.DB.Unscoped().First(&post, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "post not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + // Check if soft-deleted + if !post.DeletedAt.Valid { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "post is not soft-deleted"}) + } + + if err := database.DB.Unscoped().Model(&models.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "post could not be restored"}) + } + + return c.JSON(fiber.Map{ + "message": "post restored successfully", + "post_id": id, + }) +} + +// -------- CATEGORIES -------- + +// ListCategories godoc +// @Summary List categories (public) +// @Tags Categories +// @Produce json +// @Success 200 {array} models.CategoryDoc +// @Router /api/v1/categories [get] +func ListCategories(c fiber.Ctx) error { + var cats []models.Category + // return only root categories (no parent) and preload their immediate children + database.DB.Preload("Children").Where("parent_id IS NULL").Find(&cats) + return c.JSON(cats) +} + +// helper: convert models.Category -> models.CategoryDoc recursively +func categoryToDoc(cat models.Category) models.CategoryDoc { + doc := models.CategoryDoc{ + ID: uint(cat.ID), + Title: cat.Title, + Description: cat.Description, + ParentID: cat.ParentID, + } + if len(cat.Children) > 0 { + children := make([]models.CategoryDoc, 0, len(cat.Children)) + for _, ch := range cat.Children { + children = append(children, categoryToDoc(ch)) + } + doc.Children = children + } + return doc +} + +// GetCategory godoc +// @Summary Get single category (public) +// @Tags Categories +// @Produce json +// @Param id path int true "Category ID" +// @Success 200 {object} models.CategoryDoc +// @Failure 404 {object} map[string]string +// @Router /api/v1/categories/{id} [get] +func GetCategory(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var cat models.Category + if err := database.DB.Preload("Children").First(&cat, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"}) + } + // load children recursively if needed + // (Preload("Children") will load one level; deeper nesting can be loaded if required) + doc := categoryToDoc(cat) + return c.JSON(doc) +} + +// CreateCategoryRequest represents payload for creating a category +type CreateCategoryRequest struct { + Title string `json:"title" validate:"required,min=2"` + Description string `json:"description,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` +} + +// CreateCategory godoc +// @Summary Create category (admin only) +// @Tags Categories +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param data body CreateCategoryRequest true "Category payload" +// @Success 201 {object} models.CategoryDoc +// @Failure 400 {object} map[string]string +// @Router /api/v1/categories [post] +func CreateCategory(c fiber.Ctx) error { + var input CreateCategoryRequest + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + // validate parent exists when provided + if input.ParentID != nil { + var parent models.Category + if err := database.DB.First(&parent, *input.ParentID).Error; err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid parent_id"}) + } + } + cat := models.Category{Title: input.Title, Description: input.Description, ParentID: input.ParentID} + // generate unique slug from title + base := slugify(input.Title) + cat.Slug = makeUniqueSlugCategory(base) + if err := database.DB.Create(&cat).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create category"}) + } + return c.Status(http.StatusCreated).JSON(cat) +} + +// UpdateCategory godoc +// @Summary Update category (admin only) +// @Tags Categories +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Category ID" +// @Param data body UpdateCategoryRequest true "Category payload" +// @Success 200 {object} models.CategoryDoc +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/categories/{id} [put] +func UpdateCategory(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var cat models.Category + if err := database.DB.First(&cat, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"}) + } + var input struct { + Title *string `json:"title" validate:"omitempty,min=2"` + Description *string `json:"description"` + ParentID *uint `json:"parent_id"` + } + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + if input.Title != nil { + cat.Title = *input.Title + // regenerate slug on title change + cat.Slug = makeUniqueSlugCategoryExclude(slugify(*input.Title), uint64(cat.ID)) + } + if input.Description != nil { + cat.Description = *input.Description + } + if input.ParentID != nil { + // prevent setting parent to itself + if *input.ParentID == cat.ID { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "parent_id cannot be the category itself"}) + } + // validate parent exists + var parent models.Category + if err := database.DB.First(&parent, *input.ParentID).Error; err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid parent_id"}) + } + cat.ParentID = input.ParentID + } + if err := database.DB.Save(&cat).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update category"}) + } + return c.JSON(cat) +} + +// DeleteCategory godoc +// @Summary Delete category (admin only) +// @Tags Categories +// @Produce json +// @Security BearerAuth +// @Param id path int true "Category ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/categories/{id} [delete] +func DeleteCategory(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var cat models.Category + if err := database.DB.First(&cat, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"}) + } + if err := database.DB.Delete(&cat).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete category"}) + } + return c.JSON(fiber.Map{"message": "category deleted"}) +} + +// AdminListCategories godoc +// @Summary List categories (admin) with optional trashed filter +// @Tags Categories +// @Produce json +// @Security BearerAuth +// @Param trashed query string false "Trash filter: none|only|with" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/categories [get] +func AdminListCategories(c fiber.Ctx) error { + trashed := c.Query("trashed", "none") + if trashed != "none" && trashed != "only" && trashed != "with" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"}) + } + + pageStr := c.Query("page", "1") + perPageStr := c.Query("per_page", "10") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 10 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + var total int64 + var cats []models.Category + db := database.DB + + switch trashed { + case "none": + db.Model(&models.Category{}).Count(&total) + db.Preload("Children").Order("id desc").Limit(perPage).Offset(offset).Find(&cats) + case "only": + db.Unscoped().Model(&models.Category{}).Where("deleted_at IS NOT NULL").Count(&total) + db.Unscoped().Preload("Children").Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&cats) + case "with": + db.Unscoped().Model(&models.Category{}).Count(&total) + db.Unscoped().Preload("Children").Order("id desc").Limit(perPage).Offset(offset).Find(&cats) + } + + return c.JSON(fiber.Map{"data": cats, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}}) +} + +// HardDeleteCategory godoc +// @Summary Permanently delete a category (admin only) +// @Tags Categories +// @Produce json +// @Security BearerAuth +// @Param id path int true "Category ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/categories/{id}/hard [delete] +func HardDeleteCategory(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid category id"}) + } + + var cat models.Category + if err := database.DB.Unscoped().First(&cat, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + err = database.DB.Transaction(func(tx *gorm.DB) error { + // set parent_id of children to NULL to avoid FK issues + if err := tx.Model(&models.Category{}).Where("parent_id = ?", id).Update("parent_id", nil).Error; err != nil { + return err + } + // clear many2many association with posts to avoid FK constraint errors (post_categories) + if err := tx.Model(&cat).Association("Posts").Clear(); err != nil { + return err + } + return tx.Unscoped().Delete(&cat).Error + }) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category hard-delete failed"}) + } + + return c.JSON(fiber.Map{"message": "category permanently deleted", "category_id": id}) +} + +// AdminRestoreCategory godoc +// @Summary Restore soft-deleted category (admin only) +// @Tags Categories +// @Produce json +// @Security BearerAuth +// @Param id path int true "Category ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/categories/{id}/restore [post] +func AdminRestoreCategory(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid category id"}) + } + + var cat models.Category + if err := database.DB.Unscoped().First(&cat, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if !cat.DeletedAt.Valid { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "category is not soft-deleted"}) + } + + if err := database.DB.Unscoped().Model(&models.Category{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category could not be restored"}) + } + + return c.JSON(fiber.Map{"message": "category restored successfully", "category_id": id}) +} + +// -------- TAGS -------- + +// ListTags godoc +// @Summary List tags (public) +// @Tags Tags +// @Produce json +// @Success 200 {array} models.TagDoc +// @Router /api/v1/tags [get] +func ListTags(c fiber.Ctx) error { + var tags []models.Tag + database.DB.Find(&tags) + return c.JSON(tags) +} + +// CreateTagRequest represents payload for creating a tag +// swagger:model CreateTagRequest +type CreateTagRequest struct { + Name string `json:"name" validate:"required,min=1"` +} + +// UpdateTagRequest represents payload for updating a tag +// swagger:model UpdateTagRequest +type UpdateTagRequest struct { + Name *string `json:"name" validate:"omitempty,min=1"` +} + +// CreateTag godoc +// @Summary Create tag (admin only) +// @Tags Tags +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param data body CreateTagRequest true "Tag payload" +// @Success 201 {object} models.TagDoc +// @Failure 400 {object} map[string]string +// @Router /api/v1/tags [post] +func CreateTag(c fiber.Ctx) error { + var input CreateTagRequest + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + tag := models.Tag{Name: input.Name} + if err := database.DB.Create(&tag).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create tag"}) + } + return c.Status(http.StatusCreated).JSON(tag) +} + +// UpdateTag godoc +// @Summary Update tag (admin only) +// @Tags Tags +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Tag ID" +// @Param data body UpdateTagRequest true "Tag payload" +// @Success 200 {object} models.TagDoc +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/tags/{id} [put] +func UpdateTag(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"}) + } + var input UpdateTagRequest + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + if input.Name != nil { + tag.Name = *input.Name + } + if err := database.DB.Save(&tag).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not update tag"}) + } + return c.JSON(tag) +} + +// DeleteTag godoc +// @Summary Delete tag (admin only) +// @Tags Tags +// @Produce json +// @Security BearerAuth +// @Param id path int true "Tag ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/tags/{id} [delete] +func DeleteTag(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"}) + } + if err := database.DB.Delete(&tag).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete tag"}) + } + return c.JSON(fiber.Map{"message": "tag deleted"}) +} + +// AdminListTags godoc +// @Summary List tags (admin) with optional trashed filter +// @Tags Tags +// @Produce json +// @Security BearerAuth +// @Param trashed query string false "Trash filter: none|only|with" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/tags [get] +func AdminListTags(c fiber.Ctx) error { + trashed := c.Query("trashed", "none") + if trashed != "none" && trashed != "only" && trashed != "with" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"}) + } + + pageStr := c.Query("page", "1") + perPageStr := c.Query("per_page", "10") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 10 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + var total int64 + var tags []models.Tag + db := database.DB + + switch trashed { + case "none": + db.Model(&models.Tag{}).Count(&total) + db.Preload("Posts").Order("id desc").Limit(perPage).Offset(offset).Find(&tags) + case "only": + db.Unscoped().Model(&models.Tag{}).Where("deleted_at IS NOT NULL").Count(&total) + db.Unscoped().Preload("Posts").Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&tags) + case "with": + db.Unscoped().Model(&models.Tag{}).Count(&total) + db.Unscoped().Preload("Posts").Order("id desc").Limit(perPage).Offset(offset).Find(&tags) + } + + return c.JSON(fiber.Map{"data": tags, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}}) +} + +// HardDeleteTag godoc +// @Summary Permanently delete a tag (admin only) +// @Tags Tags +// @Produce json +// @Security BearerAuth +// @Param id path int true "Tag ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/tags/{id}/hard [delete] +func HardDeleteTag(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid tag id"}) + } + + var tag models.Tag + if err := database.DB.Unscoped().First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + err = database.DB.Transaction(func(tx *gorm.DB) error { + // clear many2many association + if err := tx.Model(&tag).Association("Posts").Clear(); err != nil { + return err + } + return tx.Unscoped().Delete(&tag).Error + }) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tag hard-delete failed"}) + } + + return c.JSON(fiber.Map{"message": "tag permanently deleted", "tag_id": id}) +} + +// AdminRestoreTag godoc +// @Summary Restore soft-deleted tag (admin only) +// @Tags Tags +// @Produce json +// @Security BearerAuth +// @Param id path int true "Tag ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/tags/{id}/restore [post] +func AdminRestoreTag(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid tag id"}) + } + + var tag models.Tag + if err := database.DB.Unscoped().First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "tag not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if !tag.DeletedAt.Valid { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "tag is not soft-deleted"}) + } + + if err := database.DB.Unscoped().Model(&models.Tag{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tag could not be restored"}) + } + + return c.JSON(fiber.Map{"message": "tag restored successfully", "tag_id": id}) +} + +// AdminListCategoryViews godoc +// @Summary List category views (admin) with optional trashed filter +// @Tags CategoryViews +// @Produce json +// @Security BearerAuth +// @Param trashed query string false "Trash filter: none|only|with" +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/category-views [get] +func AdminListCategoryViews(c fiber.Ctx) error { + trashed := c.Query("trashed", "none") + if trashed != "none" && trashed != "only" && trashed != "with" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid trashed parameter, must be one of: none, only, with"}) + } + + pageStr := c.Query("page", "1") + perPageStr := c.Query("per_page", "10") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 10 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + var total int64 + var cvs []models.CategoryView + db := database.DB + + switch trashed { + case "none": + db.Model(&models.CategoryView{}).Count(&total) + db.Order("id desc").Limit(perPage).Offset(offset).Find(&cvs) + case "only": + db.Unscoped().Model(&models.CategoryView{}).Where("deleted_at IS NOT NULL").Count(&total) + db.Unscoped().Where("deleted_at IS NOT NULL").Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&cvs) + case "with": + db.Unscoped().Model(&models.CategoryView{}).Count(&total) + db.Unscoped().Order("id desc").Limit(perPage).Offset(offset).Find(&cvs) + } + + return c.JSON(fiber.Map{"data": cvs, "meta": fiber.Map{"page": page, "per_page": perPage, "total": total}}) +} + +// HardDeleteCategoryView godoc +// @Summary Permanently delete a category view (admin only) +// @Tags CategoryViews +// @Produce json +// @Security BearerAuth +// @Param id path int true "CategoryView ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/category-views/{id}/hard [delete] +func HardDeleteCategoryView(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var cv models.CategoryView + if err := database.DB.Unscoped().First(&cv, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category view not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if err := database.DB.Unscoped().Delete(&cv).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category view hard-delete failed"}) + } + + return c.JSON(fiber.Map{"message": "category view permanently deleted", "id": id}) +} + +// AdminRestoreCategoryView godoc +// @Summary Restore soft-deleted category view (admin only) +// @Tags CategoryViews +// @Produce json +// @Security BearerAuth +// @Param id path int true "CategoryView ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/category-views/{id}/restore [post] +func AdminRestoreCategoryView(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var cv models.CategoryView + if err := database.DB.Unscoped().First(&cv, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "category view not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if !cv.DeletedAt.Valid { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "category view is not soft-deleted"}) + } + + if err := database.DB.Unscoped().Model(&models.CategoryView{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "category view could not be restored"}) + } + + return c.JSON(fiber.Map{"message": "category view restored successfully", "id": id}) +} + +// -------- COMMENTS -------- + +// CreateComment godoc +// @Summary Create comment (public) +// @Tags Comments +// @Accept json +// @Produce json +// @Param data body object true "Comment payload" +// @Success 201 {object} models.CommentDoc +// @Failure 400 {object} map[string]string +// @Router /api/v1/comments [post] +func CreateComment(c fiber.Ctx) error { + var input struct { + UserID uint `json:"user_id" validate:"required"` + PostID uint `json:"post_id" validate:"required"` + Body string `json:"body" validate:"required,min=1"` + } + if err := c.Bind().Body(&input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) + } + if err := validate.Struct(input); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + cm := models.Comment{UserID: input.UserID, PostID: input.PostID, Body: input.Body} + if err := database.DB.Create(&cm).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not create comment"}) + } + return c.Status(http.StatusCreated).JSON(cm) +} + +// ListComments godoc +// @Summary List comments for a post (public) +// @Tags Comments +// @Produce json +// @Param post_id query int true "Post ID" +// @Success 200 {array} models.CommentDoc +// @Router /api/v1/comments [get] +func ListComments(c fiber.Ctx) error { + postIDStr := c.Query("post_id") + if postIDStr == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "post_id required"}) + } + postID, err := strconv.ParseUint(postIDStr, 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid post_id"}) + } + var comments []models.Comment + database.DB.Where("post_id = ?", postID).Find(&comments) + return c.JSON(comments) +} + +// DeleteComment godoc +// @Summary Delete comment (admin only) +// @Tags Comments +// @Produce json +// @Security BearerAuth +// @Param id path int true "Comment ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/comments/{id} [delete] +func DeleteComment(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + var cm models.Comment + if err := database.DB.First(&cm, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "comment not found"}) + } + if err := database.DB.Delete(&cm).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not delete comment"}) + } + return c.JSON(fiber.Map{"message": "comment deleted"}) +} diff --git a/controllers/hero_controller.go b/controllers/hero_controller.go new file mode 100644 index 0000000..2bdb976 --- /dev/null +++ b/controllers/hero_controller.go @@ -0,0 +1,210 @@ +package controllers + +import ( + "fmt" + database "goFiber/database/config" + "goFiber/database/models" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/gofiber/fiber/v3" +) + +// GetHero godoc +// @Summary Get active hero/banner +// @Tags Hero +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/hero [get] +func GetHero(c fiber.Ctx) error { + var heroes []models.Hero + // Aktif olan tüm hero'ları getir + if err := database.DB.Where("is_active = ?", true).Find(&heroes).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + if len(heroes) == 0 { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "no active hero found"}) + } + return c.JSON(heroes) +} + +// GetHeroAll godoc +// @Summary Get all heroes +// @Description Returns all hero/banner records (no filter) +// @Tags Hero +// @Produce json +// @Success 200 {array} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/heroes [get] +func GetHeroAll(c fiber.Ctx) error { + var heroes []models.Hero + // Tüm hero'ları getir (filtre yok) + if err := database.DB.Find(&heroes).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + if len(heroes) == 0 { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "no hero found"}) + } + return c.JSON(heroes) +} + +// CreateHero godoc +// @Summary Create new hero/banner (admin only) +// @Tags Hero +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param title formData string false "Title" +// @Param text1 formData string false "Text1" +// @Param text2 formData string false "Text2" +// @Param text4 formData string false "Text4" +// @Param text5 formData string false "Text5" +// @Param color formData string true "Color" +// @Param is_active formData boolean false "Is Active" +// @Param image formData file true "Hero Image" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/hero [post] +func CreateHero(c fiber.Ctx) error { + var hero models.Hero + if err := c.Bind().Body(&hero); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Image upload + file, err := c.FormFile("image") + if err == nil { + if _, err := os.Stat("./uploads/heroes"); os.IsNotExist(err) { + os.MkdirAll("./uploads/heroes", 0755) + } + filename := fmt.Sprintf("%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/heroes", filename) + if err := c.SaveFile(file, filePath); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save image"}) + } + hero.Image = "/uploads/heroes/" + filename + } + + // Eğer sadece bir aktif hero olacaksa, diğerlerini pasife çekebiliriz + //if hero.IsActive { + // database.DB.Model(&models.Hero{}).Where("is_active = ?", true).Update("is_active", false) + //} + + if err := database.DB.Create(&hero).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "hero could not be created"}) + } + + return c.Status(http.StatusCreated).JSON(hero) +} + +// UpdateHero godoc +// @Summary Update hero/banner (admin only) +// @Tags Hero +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param id path int true "Hero ID" +// @Param title formData string false "Title" +// @Param text1 formData string false "Text1" +// @Param text2 formData string false "Text2" +// @Param text4 formData string false "Text4" +// @Param text5 formData string false "Text5" +// @Param color formData string false "Color" +// @Param is_active formData boolean false "Is Active" +// @Param image formData file false "Hero Image" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/hero/{id} [put] +func UpdateHero(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var hero models.Hero + if err := database.DB.First(&hero, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "hero not found"}) + } + + // Log raw request body (works for JSON). For multipart/form-data, also log form values. + //log.Printf("Raw request body: %s\n", string(c.Body())) + //log.Printf("Form title: %s, is_active: %s\n", c.FormValue("title"), c.FormValue("is_active")) + + var updateData models.Hero + if err := c.Bind().Body(&updateData); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + //log.Printf("Received update data: %+v\n", updateData) // Debug log + // Image upload + file, err := c.FormFile("image") + if err == nil { + if _, err := os.Stat("./uploads/heroes"); os.IsNotExist(err) { + os.MkdirAll("./uploads/heroes", 0755) + } + filename := fmt.Sprintf("%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/heroes", filename) + if err := c.SaveFile(file, filePath); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save image"}) + } + updateData.Image = "/uploads/heroes/" + filename + } + + // Eğer bu hero aktif yapılıyorsa diğerlerini pasife çek + //if updateData.IsActive { + // database.DB.Model(&models.Hero{}).Where("id != ?", id).Where("is_active = ?", true).Update("is_active", false) + //} + + // Handle is_active coming from multipart/form-data: parse and update explicitly + if v := c.FormValue("is_active"); v != "" { + if parsed, err := strconv.ParseBool(v); err == nil { + // Ensure boolean field is updated even if it's false (zero value) + if err := database.DB.Model(&hero).Update("is_active", parsed).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "hero could not be updated"}) + } + // reflect into updateData for consistency + updateData.IsActive = parsed + } else { + log.Printf("invalid is_active value: %s", v) + } + } + + if err := database.DB.Model(&hero).Updates(updateData).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "hero could not be updated"}) + } + + return c.JSON(hero) +} + +// DeleteHero godoc +// @Summary Delete hero/banner (admin only) +// @Tags Hero +// @Produce json +// @Security BearerAuth +// @Param id path int true "Hero ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/hero/{id} [delete] +func DeleteHero(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var hero models.Hero + if err := database.DB.First(&hero, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "hero not found"}) + } + + if err := database.DB.Delete(&hero).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "hero could not be deleted"}) + } + + return c.JSON(fiber.Map{"message": "hero deleted successfully"}) +} diff --git a/controllers/security_controller.go b/controllers/security_controller.go new file mode 100644 index 0000000..f315402 --- /dev/null +++ b/controllers/security_controller.go @@ -0,0 +1,565 @@ +package controllers + +import ( + "encoding/json" + "errors" + configs "goFiber/config" + database "goFiber/database/config" + "goFiber/database/models" + "goFiber/middlewares" + "log" + "net/http" + "strconv" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + corsWhitelistCacheKey = "admin:cors:whitelist:list" + corsBlacklistCacheKey = "admin:cors:blacklist:list" + rateLimitCacheKey = "admin:rate_limit:list" + securityCacheTTL = 60 +) + +type CorsWhitelistRequest struct { + Origin string `json:"origin" validate:"required"` + Description string `json:"description"` + IsActive *bool `json:"is_active"` +} + +type CorsBlacklistRequest struct { + Origin string `json:"origin" validate:"required"` + Reason string `json:"reason"` + IsActive *bool `json:"is_active"` +} + +type RateLimitSettingRequest struct { + Name string `json:"name" validate:"required"` + Description string `json:"description"` + MaxRequests int64 `json:"max_requests" validate:"required,min=1"` + WindowSeconds int `json:"window_seconds" validate:"required,min=1"` + IsActive *bool `json:"is_active"` +} + +// ListCorsWhitelists godoc +// @Summary List CORS whitelists (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/admin/cors/whitelist [get] +func ListCorsWhitelists(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var items []models.CorsWhitelist + if cached, err := database.Get(corsWhitelistCacheKey); err == nil { + if unmarshalErr := json.Unmarshal([]byte(cached), &items); unmarshalErr == nil { + securityLogf("[security][cors-whitelist][cache-hit] count=%d", len(items)) + return c.JSON(fiber.Map{"count": len(items), "items": items}) + } + } else if !errors.Is(err, redis.Nil) { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "cache read error"}) + } + + if err := database.DB.Order("id DESC").Find(&items).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + cacheJSON, _ := json.Marshal(items) + _ = database.SetEx(corsWhitelistCacheKey, string(cacheJSON), securityCacheTTL) + securityLogf("[security][cors-whitelist][db-load] count=%d", len(items)) + + return c.JSON(fiber.Map{"count": len(items), "items": items}) +} + +// CreateCorsWhitelist godoc +// @Summary Create CORS whitelist (admin only) +// @Tags Admin Security +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CorsWhitelistRequest true "Whitelist payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/cors/whitelist [post] +func CreateCorsWhitelist(c fiber.Ctx) error { + var req CorsWhitelistRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + item := models.CorsWhitelist{ + Origin: strings.TrimSpace(req.Origin), + Description: strings.TrimSpace(req.Description), + IsActive: boolValue(req.IsActive, true), + CreatedBy: currentActor(c), + } + if err := database.DB.Create(&item).Error; err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "record could not be created"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-whitelist][create] origin=%s by=%s", item.Origin, item.CreatedBy) + return c.Status(http.StatusCreated).JSON(fiber.Map{"item": item}) +} + +// UpdateCorsWhitelist godoc +// @Summary Update CORS whitelist (admin only) +// @Tags Admin Security +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Whitelist ID" +// @Param request body CorsWhitelistRequest true "Whitelist payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/cors/whitelist/{id} [put] +func UpdateCorsWhitelist(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var req CorsWhitelistRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + var item models.CorsWhitelist + if err := database.DB.First(&item, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "record not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + item.Origin = strings.TrimSpace(req.Origin) + item.Description = strings.TrimSpace(req.Description) + item.IsActive = boolValue(req.IsActive, item.IsActive) + item.CreatedBy = currentActor(c) + if err := database.DB.Save(&item).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be updated"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-whitelist][update] id=%d origin=%s by=%s", item.ID, item.Origin, item.CreatedBy) + return c.JSON(fiber.Map{"item": item}) +} + +// DeleteCorsWhitelist godoc +// @Summary Soft delete CORS whitelist (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Param id path int true "Whitelist ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/cors/whitelist/{id} [delete] +func DeleteCorsWhitelist(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + if err := database.DB.Delete(&models.CorsWhitelist{}, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be deleted"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-whitelist][soft-delete] id=%d by=%s", id, currentActor(c)) + return c.JSON(fiber.Map{"message": "soft deleted", "id": id}) +} + +// HardDeleteCorsWhitelist godoc +// @Summary Hard delete CORS whitelist (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Param id path int true "Whitelist ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/cors/whitelist/{id}/hard [delete] +func HardDeleteCorsWhitelist(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + if err := database.DB.Unscoped().Delete(&models.CorsWhitelist{}, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be hard-deleted"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-whitelist][hard-delete] id=%d by=%s", id, currentActor(c)) + return c.JSON(fiber.Map{"message": "hard deleted", "id": id}) +} + +// ListCorsBlacklists godoc +// @Summary List CORS blacklists (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/admin/cors/blacklist [get] +func ListCorsBlacklists(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var items []models.CorsBlacklist + if cached, err := database.Get(corsBlacklistCacheKey); err == nil { + if unmarshalErr := json.Unmarshal([]byte(cached), &items); unmarshalErr == nil { + securityLogf("[security][cors-blacklist][cache-hit] count=%d", len(items)) + return c.JSON(fiber.Map{"count": len(items), "items": items}) + } + } else if !errors.Is(err, redis.Nil) { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "cache read error"}) + } + + if err := database.DB.Order("id DESC").Find(&items).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + cacheJSON, _ := json.Marshal(items) + _ = database.SetEx(corsBlacklistCacheKey, string(cacheJSON), securityCacheTTL) + securityLogf("[security][cors-blacklist][db-load] count=%d", len(items)) + + return c.JSON(fiber.Map{"count": len(items), "items": items}) +} + +// CreateCorsBlacklist godoc +// @Summary Create CORS blacklist (admin only) +// @Tags Admin Security +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CorsBlacklistRequest true "Blacklist payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/cors/blacklist [post] +func CreateCorsBlacklist(c fiber.Ctx) error { + var req CorsBlacklistRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + item := models.CorsBlacklist{ + Origin: strings.TrimSpace(req.Origin), + Reason: strings.TrimSpace(req.Reason), + IsActive: boolValue(req.IsActive, true), + CreatedBy: currentActor(c), + } + if err := database.DB.Create(&item).Error; err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "record could not be created"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-blacklist][create] origin=%s by=%s", item.Origin, item.CreatedBy) + return c.Status(http.StatusCreated).JSON(fiber.Map{"item": item}) +} + +// UpdateCorsBlacklist godoc +// @Summary Update CORS blacklist (admin only) +// @Tags Admin Security +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Blacklist ID" +// @Param request body CorsBlacklistRequest true "Blacklist payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/cors/blacklist/{id} [put] +func UpdateCorsBlacklist(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var req CorsBlacklistRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + var item models.CorsBlacklist + if err := database.DB.First(&item, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "record not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + item.Origin = strings.TrimSpace(req.Origin) + item.Reason = strings.TrimSpace(req.Reason) + item.IsActive = boolValue(req.IsActive, item.IsActive) + item.CreatedBy = currentActor(c) + if err := database.DB.Save(&item).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be updated"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-blacklist][update] id=%d origin=%s by=%s", item.ID, item.Origin, item.CreatedBy) + return c.JSON(fiber.Map{"item": item}) +} + +// DeleteCorsBlacklist godoc +// @Summary Soft delete CORS blacklist (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Param id path int true "Blacklist ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/cors/blacklist/{id} [delete] +func DeleteCorsBlacklist(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + if err := database.DB.Delete(&models.CorsBlacklist{}, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be deleted"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-blacklist][soft-delete] id=%d by=%s", id, currentActor(c)) + return c.JSON(fiber.Map{"message": "soft deleted", "id": id}) +} + +// HardDeleteCorsBlacklist godoc +// @Summary Hard delete CORS blacklist (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Param id path int true "Blacklist ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/cors/blacklist/{id}/hard [delete] +func HardDeleteCorsBlacklist(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + if err := database.DB.Unscoped().Delete(&models.CorsBlacklist{}, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be hard-deleted"}) + } + invalidateSecurityCaches() + securityLogf("[security][cors-blacklist][hard-delete] id=%d by=%s", id, currentActor(c)) + return c.JSON(fiber.Map{"message": "hard deleted", "id": id}) +} + +// ListRateLimitSettings godoc +// @Summary List rate limit settings (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/admin/rate-limit [get] +func ListRateLimitSettings(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var items []models.RateLimitSetting + if cached, err := database.Get(rateLimitCacheKey); err == nil { + if unmarshalErr := json.Unmarshal([]byte(cached), &items); unmarshalErr == nil { + securityLogf("[security][rate-limit][cache-hit] count=%d", len(items)) + return c.JSON(fiber.Map{"count": len(items), "items": items}) + } + } else if !errors.Is(err, redis.Nil) { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "cache read error"}) + } + + if err := database.DB.Order("id DESC").Find(&items).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + cacheJSON, _ := json.Marshal(items) + _ = database.SetEx(rateLimitCacheKey, string(cacheJSON), securityCacheTTL) + securityLogf("[security][rate-limit][db-load] count=%d", len(items)) + + return c.JSON(fiber.Map{"count": len(items), "items": items}) +} + +// CreateRateLimitSetting godoc +// @Summary Create rate limit setting (admin only) +// @Tags Admin Security +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body RateLimitSettingRequest true "Rate limit payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/admin/rate-limit [post] +func CreateRateLimitSetting(c fiber.Ctx) error { + var req RateLimitSettingRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + item := models.RateLimitSetting{ + Name: strings.TrimSpace(req.Name), + Description: strings.TrimSpace(req.Description), + MaxRequests: req.MaxRequests, + WindowSeconds: req.WindowSeconds, + IsActive: boolValue(req.IsActive, true), + UpdatedBy: currentActor(c), + } + if err := database.DB.Create(&item).Error; err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "record could not be created"}) + } + invalidateSecurityCaches() + securityLogf("[security][rate-limit][create] name=%s max=%d window=%ds by=%s", item.Name, item.MaxRequests, item.WindowSeconds, item.UpdatedBy) + return c.Status(http.StatusCreated).JSON(fiber.Map{"item": item}) +} + +// UpdateRateLimitSetting godoc +// @Summary Update rate limit setting (admin only) +// @Tags Admin Security +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Rate limit ID" +// @Param request body RateLimitSettingRequest true "Rate limit payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/rate-limit/{id} [put] +func UpdateRateLimitSetting(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var req RateLimitSettingRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + var item models.RateLimitSetting + if err := database.DB.First(&item, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "record not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + item.Name = strings.TrimSpace(req.Name) + item.Description = strings.TrimSpace(req.Description) + item.MaxRequests = req.MaxRequests + item.WindowSeconds = req.WindowSeconds + item.IsActive = boolValue(req.IsActive, item.IsActive) + item.UpdatedBy = currentActor(c) + if err := database.DB.Save(&item).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be updated"}) + } + invalidateSecurityCaches() + securityLogf("[security][rate-limit][update] id=%d name=%s max=%d window=%ds by=%s", item.ID, item.Name, item.MaxRequests, item.WindowSeconds, item.UpdatedBy) + return c.JSON(fiber.Map{"item": item}) +} + +// DeleteRateLimitSetting godoc +// @Summary Soft delete rate limit setting (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Param id path int true "Rate limit ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/rate-limit/{id} [delete] +func DeleteRateLimitSetting(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + if err := database.DB.Delete(&models.RateLimitSetting{}, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be deleted"}) + } + invalidateSecurityCaches() + securityLogf("[security][rate-limit][soft-delete] id=%d by=%s", id, currentActor(c)) + return c.JSON(fiber.Map{"message": "soft deleted", "id": id}) +} + +// HardDeleteRateLimitSetting godoc +// @Summary Hard delete rate limit setting (admin only) +// @Tags Admin Security +// @Produce json +// @Security BearerAuth +// @Param id path int true "Rate limit ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/rate-limit/{id}/hard [delete] +func HardDeleteRateLimitSetting(c fiber.Ctx) error { + id, err := parseID(c.Params("id")) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + if err := database.DB.Unscoped().Delete(&models.RateLimitSetting{}, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "record could not be hard-deleted"}) + } + invalidateSecurityCaches() + securityLogf("[security][rate-limit][hard-delete] id=%d by=%s", id, currentActor(c)) + return c.JSON(fiber.Map{"message": "hard deleted", "id": id}) +} + +func parseID(param string) (uint, error) { + v, err := strconv.ParseUint(strings.TrimSpace(param), 10, 64) + if err != nil || v == 0 { + return 0, errors.New("invalid id") + } + return uint(v), nil +} + +func invalidateSecurityCaches() { + _ = database.Delete(corsWhitelistCacheKey) + _ = database.Delete(corsBlacklistCacheKey) + _ = database.Delete(rateLimitCacheKey) + _ = database.Delete("cors:active:whitelist") + _ = database.Delete("cors:active:blacklist") +} + +func currentActor(c fiber.Ctx) string { + if claims, ok := middlewares.GetAuthClaims(c); ok && strings.TrimSpace(claims.Email) != "" { + return claims.Email + } + return "system" +} + +func boolValue(v *bool, fallback bool) bool { + if v == nil { + return fallback + } + return *v +} + +func securityLogf(format string, args ...interface{}) { + if configs.AppConfig != nil && configs.AppConfig.CorsDebug { + log.Printf(format, args...) + } +} diff --git a/controllers/setting_controller.go b/controllers/setting_controller.go new file mode 100644 index 0000000..da5fb2c --- /dev/null +++ b/controllers/setting_controller.go @@ -0,0 +1,218 @@ +package controllers + +import ( + "fmt" + database "goFiber/database/config" + "goFiber/database/models" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" +) + +// GetSetting godoc +// @Summary Get site settings +// @Tags Setting +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Router /api/v1/setting [get] +func GetSetting(c fiber.Ctx) error { + var setting models.Setting + // Arkaplanda tek bir aktif ayar varsayıyoruz veya en son ekleneni/güncelleneni + if err := database.DB.Where("is_active = ?", true).Last(&setting).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "no active setting found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + return c.JSON(setting) +} + +// CreateSetting godoc +// @Summary Create new site setting (admin only) +// @Tags Setting +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param title formData string true "Title" +// @Param meta_title formData string true "Meta Title" +// @Param meta_description formData string true "Meta Description" +// @Param phone formData string true "Phone" +// @Param url formData string true "URL" +// @Param email formData string true "Email" +// @Param facebook formData string false "Facebook" +// @Param x formData string false "X" +// @Param instagram formData string false "Instagram" +// @Param whatsapp formData string false "Whatsapp" +// @Param pinterest formData string false "Pinterest" +// @Param linkedin formData string false "Linkedin" +// @Param slogan formData string false "Slogan" +// @Param address formData string false "Address" +// @Param copyright formData string false "Copyright" +// @Param map_embed formData string false "Map Embed" +// @Param is_active formData boolean false "Is Active" +// @Param w_logo formData file false "White Logo" +// @Param b_logo formData file false "Black Logo" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Router /api/v1/setting [post] +func CreateSetting(c fiber.Ctx) error { + var setting models.Setting + if err := c.Bind().Body(&setting); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // White Logo upload + if file, err := c.FormFile("w_logo"); err == nil { + if _, err := os.Stat("./uploads/settings"); os.IsNotExist(err) { + os.MkdirAll("./uploads/settings", 0755) + } + filename := fmt.Sprintf("w_%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/settings", filename) + if err := c.SaveFile(file, filePath); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save w_logo"}) + } + setting.WLogo = "/uploads/settings/" + filename + } + + // Black Logo upload + if file, err := c.FormFile("b_logo"); err == nil { + if _, err := os.Stat("./uploads/settings"); os.IsNotExist(err) { + os.MkdirAll("./uploads/settings", 0755) + } + filename := fmt.Sprintf("b_%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/settings", filename) + if err := c.SaveFile(file, filePath); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save b_logo"}) + } + setting.BLogo = "/uploads/settings/" + filename + } + + // Eğer sadece bir aktif ayar olacaksa, diğerlerini pasife çekebiliriz + if setting.IsActive { + database.DB.Model(&models.Setting{}).Where("is_active = ?", true).Update("is_active", false) + } + + if err := database.DB.Create(&setting).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "setting could not be created"}) + } + + return c.Status(http.StatusCreated).JSON(setting) +} + +// UpdateSetting godoc +// @Summary Update site setting (admin only) +// @Tags Setting +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param id path int true "Setting ID" +// @Param title formData string false "Title" +// @Param meta_title formData string false "Meta Title" +// @Param meta_description formData string false "Meta Description" +// @Param phone formData string false "Phone" +// @Param url formData string false "URL" +// @Param email formData string false "Email" +// @Param facebook formData string false "Facebook" +// @Param x formData string false "X" +// @Param instagram formData string false "Instagram" +// @Param whatsapp formData string false "Whatsapp" +// @Param pinterest formData string false "Pinterest" +// @Param linkedin formData string false "Linkedin" +// @Param slogan formData string false "Slogan" +// @Param address formData string false "Address" +// @Param copyright formData string false "Copyright" +// @Param map_embed formData string false "Map Embed" +// @Param is_active formData boolean false "Is Active" +// @Param w_logo formData file false "White Logo" +// @Param b_logo formData file false "Black Logo" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/setting/{id} [put] +func UpdateSetting(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var setting models.Setting + if err := database.DB.First(&setting, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "setting not found"}) + } + + var updateData models.Setting + if err := c.Bind().Body(&updateData); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // White Logo upload + if file, err := c.FormFile("w_logo"); err == nil { + if _, err := os.Stat("./uploads/settings"); os.IsNotExist(err) { + os.MkdirAll("./uploads/settings", 0755) + } + filename := fmt.Sprintf("w_%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/settings", filename) + if err := c.SaveFile(file, filePath); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save w_logo"}) + } + updateData.WLogo = "/uploads/settings/" + filename + } + + // Black Logo upload + if file, err := c.FormFile("b_logo"); err == nil { + if _, err := os.Stat("./uploads/settings"); os.IsNotExist(err) { + os.MkdirAll("./uploads/settings", 0755) + } + filename := fmt.Sprintf("b_%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/settings", filename) + if err := c.SaveFile(file, filePath); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save b_logo"}) + } + updateData.BLogo = "/uploads/settings/" + filename + } + + // Eğer bu ayar aktif yapılıyorsa diğerlerini pasife çek + if updateData.IsActive { + database.DB.Model(&models.Setting{}).Where("id != ?", id).Where("is_active = ?", true).Update("is_active", false) + } + + if err := database.DB.Model(&setting).Updates(updateData).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "setting could not be updated"}) + } + + return c.JSON(setting) +} + +// DeleteSetting godoc +// @Summary Delete site setting (admin only) +// @Tags Setting +// @Produce json +// @Security BearerAuth +// @Param id path int true "Setting ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/setting/{id} [delete] +func DeleteSetting(c fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid id"}) + } + + var setting models.Setting + if err := database.DB.First(&setting, id).Error; err != nil { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "setting not found"}) + } + + if err := database.DB.Delete(&setting).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "setting could not be deleted"}) + } + + return c.JSON(fiber.Map{"message": "setting deleted successfully"}) +} diff --git a/controllers/user.go b/controllers/user.go new file mode 100644 index 0000000..d8bf82f --- /dev/null +++ b/controllers/user.go @@ -0,0 +1,890 @@ +package controllers + +import ( + "encoding/json" + "errors" + "fmt" + configs "goFiber/config" + database "goFiber/database/config" + "goFiber/database/models" + "goFiber/middlewares" + utils "goFiber/pkg/utis" + "goFiber/services" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v3" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +var validate = validator.New() + +type RegisterRequest struct { + UserName string `json:"username" validate:"required,min=3"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` +} + +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" validate:"required"` +} + +type ResendVerificationRequest struct { + Email string `json:"email" validate:"required,email"` +} + +// UpdateUserRequest represents allowed fields for updating a user +type UpdateUserRequest struct { + UserName string `json:"username,omitempty" example:"jdoe"` + Email string `json:"email,omitempty" example:"jdoe@example.com"` + IsAdmin *bool `json:"is_admin,omitempty" example:"false"` + Password string `json:"password,omitempty" example:"#secret"` + FirstName string `json:"first_name,omitempty" example:"John"` + LastName string `json:"last_name,omitempty" example:"Doe"` + AvatarURL string `json:"avatar_url,omitempty" example:"/uploads/avatar.jpg"` + EmailVerified *bool `json:"email_verified,omitempty" example:"true"` + // Accept avatar file via multipart/form-data with field name "avatar" when using form upload +} + +func GetUser(c fiber.Ctx) error { + return c.Status(fiber.StatusOK).SendString("Get User") +} + +// AdminListUsers godoc +// @Summary List active users (admin only) +// @Tags Users +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/users/list [get] +func AdminListUsers(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var users []models.User + if err := database.DB.Preload("Profile").Order("id DESC").Find(&users).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + return c.JSON(fiber.Map{ + "count": len(users), + "users": users, + }) +} + +// AdminListDeletedUsers godoc +// @Summary List soft-deleted users (admin only) +// @Tags Users +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/users/list/deleted [get] +func AdminListDeletedUsers(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var users []models.User + if err := database.DB.Unscoped(). + Preload("Profile"). + Where("deleted_at IS NOT NULL"). + Order("deleted_at DESC"). + Find(&users).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + return c.JSON(fiber.Map{ + "count": len(users), + "users": users, + }) +} + +func GetUserOne(c fiber.Ctx) error { + return c.Status(fiber.StatusOK).SendString("Get User One") +} + +// UpdateUser godoc +// @Summary Update user (admin only) +// @Tags Users +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param id path int true "User ID" +// @Param username formData string false "Username" +// @Param email formData string false "Email" +// @Param is_admin formData boolean false "Is Admin" +// @Param password formData string false "Password" +// @Param first_name formData string false "First Name" +// @Param last_name formData string false "Last Name" +// @Param email_verified formData boolean false "Email Verified" +// @Param avatar formData file false "Avatar Image" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/users/{id} [put] +func UpdateUser(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"}) + } + + var user models.User + if err := database.DB.Preload("Profile").First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + // Parse incoming JSON or multipart/form-data into map to allow partial updates including false values + var payload map[string]interface{} + + // Prefer detecting multipart by trying to read the multipart form first + if mf, err := c.MultipartForm(); err == nil && mf != nil { + payload = map[string]interface{}{} + // form values + for k, vals := range mf.Value { + if len(vals) > 0 { + payload[k] = vals[0] + } + } + + // handle avatar file if present + if files, ok := mf.File["avatar"]; ok && len(files) > 0 { + file := files[0] + if _, err := os.Stat("./uploads/avatars"); os.IsNotExist(err) { + os.MkdirAll("./uploads/avatars", 0755) + } + filename := fmt.Sprintf("%d_%s", time.Now().Unix(), file.Filename) + filePath := filepath.Join("./uploads/avatars", filename) + if err := c.SaveFile(file, filePath); err == nil { + payload["avatar_url"] = "/uploads/avatars/" + filename + } else { + log.Printf("failed to save avatar: %v", err) + } + } + } else { + // fallback to JSON body + if err := json.Unmarshal(c.Body(), &payload); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + } + + // Prepare updates for user table + userUpdates := map[string]interface{}{} + if v, ok := payload["username"].(string); ok { + userUpdates["user_name"] = v + userUpdates["user_name"] = v + user.UserName = v + } + if v, ok := payload["email"].(string); ok { + userUpdates["email"] = v + user.Email = v + } + if v, ok := payload["is_admin"]; ok { + // handle bool or string representations + switch val := v.(type) { + case bool: + userUpdates["is_admin"] = val + user.IsAdmin = &val + case string: + if parsed, err := strconv.ParseBool(val); err == nil { + userUpdates["is_admin"] = parsed + user.IsAdmin = &parsed + } + } + } + // Handle email_verified explicitly (bool or string) + if v, ok := payload["email_verified"]; ok { + switch val := v.(type) { + case bool: + userUpdates["email_verified"] = val + now := time.Now() + if val { + userUpdates["email_verified_at"] = now + user.EmailVerified = &val + user.EmailVerifiedAt = &now + } else { + userUpdates["email_verified_at"] = nil + user.EmailVerified = &val + user.EmailVerifiedAt = nil + } + case string: + if parsed, err := strconv.ParseBool(val); err == nil { + userUpdates["email_verified"] = parsed + now := time.Now() + if parsed { + userUpdates["email_verified_at"] = now + user.EmailVerified = &parsed + user.EmailVerifiedAt = &now + } else { + userUpdates["email_verified_at"] = nil + user.EmailVerified = &parsed + user.EmailVerifiedAt = nil + } + } + } + } + if v, ok := payload["password"].(string); ok && v != "" { + hashed, err := bcrypt.GenerateFromPassword([]byte(v), bcrypt.DefaultCost) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not hash password"}) + } + userUpdates["password"] = string(hashed) + user.Password = string(hashed) + } + + // Apply user updates if any + if len(userUpdates) > 0 { + if err := database.DB.Model(&user).Updates(userUpdates).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user could not be updated"}) + } + } + + // Handle profile updates (first_name, last_name, avatar_url) + profileUpdates := map[string]interface{}{} + if v, ok := payload["first_name"].(string); ok { + profileUpdates["first_name"] = v + } + if v, ok := payload["last_name"].(string); ok { + profileUpdates["last_name"] = v + } + if v, ok := payload["avatar_url"].(string); ok { + profileUpdates["avatar_url"] = v + } + + if len(profileUpdates) > 0 { + // Profile may be stored as slice; update first profile if exists else create + var profile models.Profile + if len(user.Profile) > 0 { + profile = user.Profile[0] + if err := database.DB.Model(&profile).Updates(profileUpdates).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "profile could not be updated"}) + } + } else { + profile = models.Profile{ + UserID: uint64(user.ID), + } + if v, ok := profileUpdates["first_name"].(string); ok { + profile.FirstName = v + } + if v, ok := profileUpdates["last_name"].(string); ok { + profile.LastName = v + } + if v, ok := profileUpdates["avatar_url"].(string); ok { + profile.AvatarURL = v + } + if err := database.DB.Create(&profile).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "profile could not be created"}) + } + } + } + + // Reload user with profile + if err := database.DB.Preload("Profile").First(&user, id).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + return c.JSON(fiber.Map{"message": "user updated", "user": user}) +} + +// DeleteUser godoc +// @Summary Soft delete user (admin only) +// @Tags Users +// @Produce json +// @Security BearerAuth +// @Param id path int true "User ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/users/{id} [delete] +func DeleteUser(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"}) + } + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if err := database.DB.Delete(&user).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user could not be deleted"}) + } + + return c.JSON(fiber.Map{ + "message": "user soft-deleted successfully", + "user_id": id, + }) +} + +// HardDeleteUser godoc +// @Summary Hard delete user permanently (admin only) +// @Tags Users +// @Produce json +// @Security BearerAuth +// @Param id path int true "User ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/users/{id}/hard [delete] +func HardDeleteUser(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"}) + } + + var user models.User + if err := database.DB.Unscoped().First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + err = database.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Where("user_id = ?", id).Delete(&models.Profile{}).Error; err != nil { + return err + } + if err := tx.Unscoped().Where("user_id = ?", id).Delete(&models.SocialAccount{}).Error; err != nil { + return err + } + return tx.Unscoped().Delete(&models.User{}, id).Error + }) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user hard-delete failed"}) + } + + return c.JSON(fiber.Map{ + "message": "user permanently deleted", + "user_id": id, + }) +} + +// RestoreUser godoc +// @Summary Restore soft-deleted user (admin only) +// @Tags Users +// @Produce json +// @Security BearerAuth +// @Param id path int true "User ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/users/{id}/restore [post] +func RestoreUser(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"}) + } + + var user models.User + if err := database.DB.Unscoped().First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if !user.DeletedAt.Valid { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "user is not soft-deleted"}) + } + + if err := database.DB.Unscoped().Model(&models.User{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user could not be restored"}) + } + + return c.JSON(fiber.Map{ + "message": "user restored successfully", + "user_id": id, + }) +} + +// Register godoc +// @Summary Register user +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Register payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/v1/auth/register [post] +func Register(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var req RegisterRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "password could not be hashed"}) + } + + verifyToken, err := utils.GenerateSecureToken(32) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verify token could not be generated"}) + } + + user := models.User{ + UserName: req.UserName, + Email: req.Email, + Password: string(hashedPassword), + EmailVerifyToken: verifyToken, + } + + if err := database.DB.Create(&user).Error; err != nil { + return c.Status(http.StatusConflict).JSON(fiber.Map{"error": "email already exists"}) + } + + profile := models.Profile{ + UserID: uint64(user.ID), + FirstName: req.FirstName, + LastName: req.LastName, + } + if err := database.DB.Create(&profile).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "profile could not be created"}) + } + + appURL := strings.TrimRight(configs.AppConfig.AppURL, "/") + verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", appURL, url.QueryEscape(verifyToken)) + + emailService := services.NewEmailService() + err = emailService.SendVerificationEmail(user.Email, profile.FirstName, verifyURL) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verification email could not be sent"}) + } + + return c.Status(http.StatusCreated).JSON(fiber.Map{ + "message": "registration successful, please verify your email before login", + "user": fiber.Map{ + "id": user.ID, + "username": user.UserName, + "email": user.Email, + "is_admin": boolPtrValue(user.IsAdmin), + "email_verified": false, + "first_name": profile.FirstName, + "last_name": profile.LastName, + }, + }) +} + +// Login godoc +// @Summary Login user +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Login payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/login [post] +func Login(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var req LoginRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + var user models.User + if err := database.DB.Preload("Profile").Where("email = ?", req.Email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid email or password"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid email or password"}) + } + + if !user.IsEmailVerified() { + return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "please verify your email before login"}) + } + + firstName, lastName := extractProfileName(user.Profile) + jwtService := services.NewJWTService() + accessToken, refreshToken, err := jwtService.GenerateTokenPair( + user.ID, + user.Email, + boolPtrValue(user.IsAdmin), + firstName, + lastName, + ) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tokens could not be generated"}) + } + + return c.JSON(fiber.Map{ + "user": fiber.Map{ + "id": user.ID, + "username": user.UserName, + "email": user.Email, + "is_admin": boolPtrValue(user.IsAdmin), + "first_name": firstName, + "last_name": lastName, + }, + "access_token": accessToken, + "refresh_token": refreshToken, + }) +} + +// RefreshToken godoc +// @Summary Refresh access token +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body RefreshRequest true "Refresh payload" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/refresh [post] +func RefreshToken(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var req RefreshRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + jwtService := services.NewJWTService() + claims, err := jwtService.ValidateToken(req.RefreshToken) + if err != nil || claims.TokenType != services.TokenTypeRefresh { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid refresh token"}) + } + + var user models.User + if err := database.DB.Preload("Profile").First(&user, claims.UserID).Error; err != nil { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "user not found"}) + } + + firstName, lastName := extractProfileName(user.Profile) + accessToken, refreshToken, err := jwtService.GenerateTokenPair( + user.ID, + user.Email, + boolPtrValue(user.IsAdmin), + firstName, + lastName, + ) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tokens could not be generated"}) + } + fmt.Println(accessToken, "Access Token Yenilendi !!!") + return c.JSON(fiber.Map{ + "access_token": accessToken, + "refresh_token": refreshToken, + }) +} + +// VerifyEmail godoc +// @Summary Verify email address with token +// @Tags Auth +// @Produce json +// @Param token query string true "Email verify token" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/auth/verify-email [get] +func VerifyEmail(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + token := strings.TrimSpace(c.Query("token")) + if token == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "token is required"}) + } + + var user models.User + if err := database.DB.Where("email_verify_token = ?", token).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "invalid or expired token"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + now := time.Now() + isVerified := true + user.EmailVerified = &isVerified + user.EmailVerifiedAt = &now + user.EmailVerifyToken = "" + + if err := database.DB.Save(&user).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "email verification could not be saved"}) + } + + return c.JSON(fiber.Map{"message": "email verified successfully"}) +} + +// ResendVerificationEmail godoc +// @Summary Resend verification email +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body ResendVerificationRequest true "Resend verification payload" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/auth/resend-verification [post] +func ResendVerificationEmail(c fiber.Ctx) error { + if database.DB == nil { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"}) + } + + var req ResendVerificationRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + if err := validate.Struct(req); err != nil { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + var user models.User + if err := database.DB.Preload("Profile").Where("email = ?", req.Email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if user.IsEmailVerified() { + return c.JSON(fiber.Map{"message": "email is already verified"}) + } + + verifyToken, err := utils.GenerateSecureToken(32) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verify token could not be generated"}) + } + user.EmailVerifyToken = verifyToken + if err := database.DB.Save(&user).Error; err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verification token could not be saved"}) + } + + firstName, _ := extractProfileName(user.Profile) + appURL := strings.TrimRight(configs.AppConfig.AppURL, "/") + verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", appURL, url.QueryEscape(verifyToken)) + + emailService := services.NewEmailService() + if err := emailService.SendVerificationEmail(user.Email, firstName, verifyURL); err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verification email could not be sent"}) + } + + return c.JSON(fiber.Map{"message": "verification email has been sent"}) +} + +// Me godoc +// @Summary Get current user from token +// @Tags Auth +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/me [get] +func Me(c fiber.Ctx) error { + claims, ok := middlewares.GetAuthClaims(c) + if !ok { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + return c.JSON(fiber.Map{ + "user": fiber.Map{ + "id": claims.UserID, + "email": claims.Email, + "is_admin": claims.IsAdmin, + "first_name": claims.FirstName, + "last_name": claims.LastName, + }, + }) +} + +// AdminOnlyExample godoc +// @Summary Admin-only sample endpoint +// @Tags Auth +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/auth/admin/example [get] +func AdminOnlyExample(c fiber.Ctx) error { + claims, ok := middlewares.GetAuthClaims(c) + if !ok { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + return c.JSON(fiber.Map{ + "message": "only admins can access this endpoint", + "user": claims.Email, + }) +} + +// UserOnlyExample godoc +// @Summary Normal-user-only sample endpoint +// @Tags Auth +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /api/v1/auth/user/example [get] +func UserOnlyExample(c fiber.Ctx) error { + claims, ok := middlewares.GetAuthClaims(c) + if !ok { + return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + return c.JSON(fiber.Map{ + "message": "only normal users can access this endpoint", + "user": claims.Email, + }) +} + +func GoogleAuth(c fiber.Ctx) error { + if configs.AppConfig.GoogleClientID == "" { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "google oauth is not configured"}) + } + + stateToken, err := utils.GenerateSecureToken(16) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "state token could not be generated"}) + } + + authURL := "https://accounts.google.com/o/oauth2/v2/auth?" + url.Values{ + "client_id": []string{configs.AppConfig.GoogleClientID}, + "redirect_uri": []string{configs.AppConfig.GoogleRedirectURL}, + "response_type": []string{"code"}, + "scope": []string{"openid email profile"}, + "state": []string{stateToken}, + }.Encode() + + return c.JSON(fiber.Map{"provider": "google", "auth_url": authURL, "state": stateToken}) +} + +func GoogleAuthCallback(c fiber.Ctx) error { + code := c.Query("code") + if code == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "google callback code is missing"}) + } + + // OAuth token exchange is intentionally left simple for now. + return c.JSON(fiber.Map{ + "provider": "google", + "message": "google callback infrastructure is ready, token exchange can be added next", + "code": code, + "state": c.Query("state"), + }) +} + +func GithubAuth(c fiber.Ctx) error { + if configs.AppConfig.GithubClientID == "" { + return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "github oauth is not configured"}) + } + + stateToken, err := utils.GenerateSecureToken(16) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "state token could not be generated"}) + } + + authURL := "https://github.com/login/oauth/authorize?" + url.Values{ + "client_id": []string{configs.AppConfig.GithubClientID}, + "redirect_uri": []string{configs.AppConfig.GithubRedirectURL}, + "scope": []string{"read:user user:email"}, + "state": []string{stateToken}, + }.Encode() + + return c.JSON(fiber.Map{"provider": "github", "auth_url": authURL, "state": stateToken}) +} + +func GithubAuthCallback(c fiber.Ctx) error { + code := c.Query("code") + if code == "" { + return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "github callback code is missing"}) + } + + // OAuth token exchange is intentionally left simple for now. + return c.JSON(fiber.Map{ + "provider": "github", + "message": "github callback infrastructure is ready, token exchange can be added next", + "code": code, + "state": c.Query("state"), + }) +} + +func extractProfileName(profiles []models.Profile) (string, string) { + if len(profiles) == 0 { + return "", "" + } + return profiles[0].FirstName, profiles[0].LastName +} + +func boolPtrValue(v *bool) bool { + if v == nil { + return false + } + return *v +} diff --git a/database/config/mysql_db.go b/database/config/mysql_db.go new file mode 100644 index 0000000..20b9e8a --- /dev/null +++ b/database/config/mysql_db.go @@ -0,0 +1,39 @@ +package database + +import ( + configs "goFiber/config" + "log" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func ConnectDB() { + dsn := configs.AppConfig.DBUrl + if dsn == "" { + log.Println(".env dosyasında DB_URL ayarlı değil — veritabanı bağlantısı atlanıyor (geliştirme modu)") + return + } + + log.Println("Yapılandırmada DB_URL bulundu, veritabanına bağlanılmaya çalışılıyor...") + + // GORM için MySQL konfigürasyonu + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), // Info seviyesi (performans etkileyebilir); üretimde Error seviyesine alınabilir + PrepareStmt: true, // PrepareStmt performansını artırmak için + NowFunc: func() time.Time { + return time.Now().UTC() + }, + }) + if err != nil { + log.Println("MySQL veritabanı bağlantısı kurulamadı:", err) + return + } + + log.Println("MySQL veritabanı bağlantısı kuruldu.") + DB = db +} diff --git a/database/config/redis_db.go b/database/config/redis_db.go new file mode 100644 index 0000000..001f19f --- /dev/null +++ b/database/config/redis_db.go @@ -0,0 +1,108 @@ +package database + +import ( + "context" + "github.com/redis/go-redis/v9" + config "goFiber/config" + "log" + "time" +) + +var RedisClient *redis.Client +var RedisOptions *redis.Options +var ctx = context.Background() + +func ConnectRedis() { + redisURL := config.AppConfig.RedisUrl + if redisURL == "" { + log.Println("Warning: REDIS_URL is not set, continuing without Redis cache") + return + } + + opt, err := redis.ParseURL(redisURL) + if err != nil { + log.Printf("Warning: Failed to parse Redis URL: %v, continuing without Redis cache", err) + RedisOptions = nil + return + } + + RedisOptions = opt + RedisClient = redis.NewClient(opt) + + // Test connection + _, err = RedisClient.Ping(ctx).Result() + if err != nil { + log.Printf("Warning: Failed to connect to Redis: %v, continuing without Redis cache", err) + RedisClient = nil + RedisOptions = nil + return + } + + log.Println("Connected to Redis successfully") +} + +// Set stores a key-value pair in Redis with expiration +func Set(key string, value interface{}, expiration time.Duration) error { + if RedisClient == nil { + return nil // Gracefully handle when Redis is not available + } + return RedisClient.Set(ctx, key, value, expiration).Err() +} + +// Get retrieves a value from Redis +func Get(key string) (string, error) { + if RedisClient == nil { + return "", redis.Nil // Return Nil error when Redis is not available + } + return RedisClient.Get(ctx, key).Result() +} + +// Delete removes a key from Redis +func Delete(key string) error { + if RedisClient == nil { + return nil + } + return RedisClient.Del(ctx, key).Err() +} + +// Exists checks if a key exists in Redis +func Exists(key string) (bool, error) { + if RedisClient == nil { + return false, nil + } + count, err := RedisClient.Exists(ctx, key).Result() + return count > 0, err +} + +// SetWithJSON stores a JSON-serializable value in Redis +func SetEx(key string, value interface{}, seconds int) error { + if RedisClient == nil { + return nil + } + return RedisClient.Set(ctx, key, value, time.Duration(seconds)*time.Second).Err() +} + +// Increment increments a counter in Redis +func Increment(key string) (int64, error) { + if RedisClient == nil { + return 0, nil + } + return RedisClient.Incr(ctx, key).Result() +} + +// Expire sets expiration time for a key +func Expire(key string, expiration time.Duration) error { + if RedisClient == nil { + return nil + } + return RedisClient.Expire(ctx, key, expiration).Err() +} + +// FlushAll clears all keys in the current database +func FlushAll() error { + if RedisClient == nil { + return nil + } + log.Println("🧹 Clearing Redis Cache...") + return RedisClient.FlushDB(ctx).Err() +} diff --git a/database/migrate/migrate.go b/database/migrate/migrate.go new file mode 100644 index 0000000..607a4d3 --- /dev/null +++ b/database/migrate/migrate.go @@ -0,0 +1,122 @@ +package migrasyon + +import ( + configs "goFiber/config" + database "goFiber/database/config" + "goFiber/database/models" + "log" + "net/url" + "strings" +) + +// Only run AutoMigrate if DB is initialized + +func Migrate() { + if database.DB != nil { + if err := database.DB.AutoMigrate( + &models.User{}, + &models.SocialAccount{}, + &models.Profile{}, + &models.Hero{}, + &models.Setting{}, + &models.CorsWhitelist{}, + &models.CorsBlacklist{}, + &models.RateLimitSetting{}, + &models.Category{}, + &models.Tag{}, + &models.Post{}, + &models.CategoryView{}, + &models.Comment{}, + ); err != nil { + log.Printf("AutoMigrate Yapılamadı !!: %v", err) + } + seedSecurityDefaults() + log.Println("AutoMigrate Yapıldı.") + } else { + log.Println("DB not initialized: skipping AutoMigrate") + } +} + +func seedSecurityDefaults() { + seedRateLimit("register", "Register endpoint default rate limit", 5, 60) + seedRateLimit("login", "Login endpoint default rate limit", 10, 60) + + for _, origin := range defaultWhitelistOrigins() { + seedCorsWhitelist(origin, "default seeded whitelist") + } +} + +func seedRateLimit(name, description string, maxRequests int64, windowSeconds int) { + var existing models.RateLimitSetting + if err := database.DB.Where("name = ?", name).First(&existing).Error; err == nil { + return + } + + item := models.RateLimitSetting{ + Name: name, + Description: description, + MaxRequests: maxRequests, + WindowSeconds: windowSeconds, + IsActive: true, + UpdatedBy: "seed", + } + if err := database.DB.Create(&item).Error; err != nil { + log.Printf("RateLimit seed failed (%s): %v", name, err) + return + } + log.Printf("RateLimit seed created: name=%s max=%d window=%ds", name, maxRequests, windowSeconds) +} + +func seedCorsWhitelist(origin, description string) { + origin = strings.TrimSpace(origin) + if origin == "" { + return + } + + var existing models.CorsWhitelist + if err := database.DB.Where("origin = ?", origin).First(&existing).Error; err == nil { + return + } + + item := models.CorsWhitelist{ + Origin: origin, + Description: description, + IsActive: true, + CreatedBy: "seed", + } + if err := database.DB.Create(&item).Error; err != nil { + log.Printf("CorsWhitelist seed failed (%s): %v", origin, err) + return + } + log.Printf("CorsWhitelist seed created: origin=%s", origin) +} + +func defaultWhitelistOrigins() []string { + origins := []string{ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:8080", + } + + appURL := strings.TrimSpace(configs.AppConfig.AppURL) + if appURL != "" { + if parsed, err := url.Parse(appURL); err == nil && parsed.Scheme != "" && parsed.Host != "" { + origins = append(origins, parsed.Scheme+"://"+parsed.Host) + } + } + + uniq := make(map[string]struct{}) + out := make([]string, 0, len(origins)) + for _, origin := range origins { + origin = strings.TrimSpace(origin) + if origin == "" { + continue + } + if _, ok := uniq[origin]; ok { + continue + } + uniq[origin] = struct{}{} + out = append(out, origin) + } + return out +} diff --git a/database/models/blog.go b/database/models/blog.go new file mode 100644 index 0000000..c5aeb6e --- /dev/null +++ b/database/models/blog.go @@ -0,0 +1,47 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Minimal, temiz GORM modelleri + +type Category struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"` + Description string `json:"description,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` + Parent *Category `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"` + Children []Category `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Posts []Post `gorm:"many2many:post_categories;" json:"posts,omitempty"` +} + +type Tag struct { + gorm.Model + Name string `gorm:"type:varchar(254);not null" json:"name"` + Posts []Post `gorm:"many2many:post_tags;" json:"posts,omitempty"` +} + +type Post struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title"` + Images string `gorm:"type:text;not null" json:"images"` + Content string `gorm:"type:text" json:"content,omitempty"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"` + Categories []Category `gorm:"many2many:post_categories;" json:"categories,omitempty"` + Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty"` +} + +type CategoryView struct { + gorm.Model + CategoryID uint `json:"category_id"` + IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"` +} + +type Comment struct { + gorm.Model + UserID uint `json:"user_id"` + PostID uint `json:"post_id"` + Body string `gorm:"type:text" json:"body,omitempty"` +} diff --git a/database/models/cors.go b/database/models/cors.go new file mode 100644 index 0000000..d3faaf0 --- /dev/null +++ b/database/models/cors.go @@ -0,0 +1,34 @@ +package models + +import ( + "gorm.io/gorm" +) + +// CorsWhitelist - CORS için izin verilen origin'ler +type CorsWhitelist struct { + gorm.Model + Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"` + Description string `gorm:"type:varchar(255)" json:"description"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"` +} + +// CorsBlacklist - CORS için yasaklanan origin'ler +type CorsBlacklist struct { + gorm.Model + Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"` + Reason string `gorm:"type:varchar(255)" json:"reason"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"` +} + +// RateLimitSetting - Rate limit ayarları +type RateLimitSetting struct { + gorm.Model + Name string `gorm:"type:varchar(100);uniqueIndex;not null" json:"name"` // e.g., "login", "register", "api" + Description string `gorm:"type:varchar(255)" json:"description"` + MaxRequests int64 `gorm:"not null" json:"max_requests"` // Max istek sayısı + WindowSeconds int `gorm:"not null" json:"window_seconds"` // Zaman penceresi (saniye) + IsActive bool `gorm:"default:true" json:"is_active"` + UpdatedBy string `gorm:"type:varchar(255)" json:"updated_by,omitempty"` +} diff --git a/database/models/docs_models.go b/database/models/docs_models.go new file mode 100644 index 0000000..ceb2b10 --- /dev/null +++ b/database/models/docs_models.go @@ -0,0 +1,39 @@ +package models + +// Swagger-friendly (light) structs for documentation only. +// These avoid embedding external types (gorm.Model) so `swag` can parse them. + +type CategoryDoc struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` + Children []CategoryDoc `json:"children,omitempty"` +} + +type TagDoc struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +type PostDoc struct { + ID uint `json:"id"` + Title string `json:"title"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + Categories []CategoryDoc `json:"categories,omitempty"` + Tags []TagDoc `json:"tags,omitempty"` +} + +type CommentDoc struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + PostID uint `json:"post_id"` + Body string `json:"body,omitempty"` +} + +type CategoryViewDoc struct { + ID uint `json:"id"` + CategoryID uint `json:"category_id"` + IPAddress string `json:"ip_address,omitempty"` +} diff --git a/database/models/hero.go b/database/models/hero.go new file mode 100644 index 0000000..7843bb3 --- /dev/null +++ b/database/models/hero.go @@ -0,0 +1,19 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Banner model structure +// Represents a banner item with optional thumbnail. +type Hero struct { + gorm.Model + Color string `gorm:"type:varchar(32);not null" json:"color" form:"color"` + Title string `gorm:"type:varchar(254)" json:"title,omitempty" form:"title"` + Text1 string `gorm:"type:varchar(254)" json:"text1,omitempty" form:"text1"` + Text2 string `gorm:"type:varchar(254)" json:"text2,omitempty" form:"text2"` + Text4 string `gorm:"type:varchar(254)" json:"text4,omitempty" form:"text4"` + Text5 string `gorm:"type:varchar(254)" json:"text5,omitempty" form:"text5"` + Image string `gorm:"type:varchar(254)" json:"image" form:"image"` + IsActive bool `gorm:"default:true" json:"is_active" form:"is_active"` +} diff --git a/database/models/setting.go b/database/models/setting.go new file mode 100644 index 0000000..4be6d56 --- /dev/null +++ b/database/models/setting.go @@ -0,0 +1,35 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Setting model structure +// Stores site-wide metadata and contact information. +type Setting struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"` + MetaTitle string `gorm:"type:varchar(254);not null" json:"meta_title" form:"meta_title"` + MetaDescription string `gorm:"type:varchar(254);not null" json:"meta_description" form:"meta_description"` + Phone string `gorm:"type:varchar(254);not null" json:"phone" form:"phone"` + URL string `gorm:"type:varchar(254);not null" json:"url" form:"url"` + Email string `gorm:"type:varchar(254);not null" json:"email" form:"email"` + Facebook string `gorm:"type:varchar(254)" json:"facebook,omitempty" form:"facebook"` + X string `gorm:"type:varchar(254)" json:"x,omitempty" form:"x"` + Instagram string `gorm:"type:varchar(254)" json:"instagram,omitempty" form:"instagram"` + Whatsapp string `gorm:"type:varchar(254)" json:"whatsapp,omitempty" form:"whatsapp"` + Pinterest string `gorm:"type:varchar(254)" json:"pinterest,omitempty" form:"pinterest"` + Linkedin string `gorm:"type:varchar(254)" json:"linkedin,omitempty" form:"linkedin"` + Slogan string `gorm:"type:varchar(254)" json:"slogan,omitempty" form:"slogan"` + Address string `gorm:"type:text" json:"address,omitempty" form:"address"` + Copyright string `gorm:"type:varchar(254)" json:"copyright,omitempty" form:"copyright"` + MapEmbed string `gorm:"type:text" json:"map_embed,omitempty" form:"map_embed"` + WLogo string `gorm:"type:text" json:"w_logo,omitempty" form:"w_logo"` + BLogo string `gorm:"type:text" json:"b_logo,omitempty" form:"b_logo"` + IsActive bool `gorm:"default:false" json:"is_active" form:"is_active"` +} + +// TableName overrides the table name used by Setting to `settings` +func (Setting) TableName() string { + return "settings" +} diff --git a/database/models/user.go b/database/models/user.go new file mode 100644 index 0000000..b461716 --- /dev/null +++ b/database/models/user.go @@ -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"` +} + +// 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:"not null" json:"provider"` // google, github + ProviderID string `gorm:"not null" 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 + +} diff --git a/docker-compose.c.yml b/docker-compose.c.yml new file mode 100644 index 0000000..e87cbd2 --- /dev/null +++ b/docker-compose.c.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + app: + build: + context: . + args: + DB_URL: ${DB_URL} + REDIS_URL: ${REDIS_URL} + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT} + EMAIL_HOST: ${EMAIL_HOST} + EMAIL_PORT: ${EMAIL_PORT} + container_name: gofiber-app + #ports: + # - "8080:8080" + env_file: + - .env + volumes: + - gofiber_uploads:/app/uploads + - gofiber_views:/app/views + - gofiber_docs:/app/docs + restart: unless-stopped + +# Define named volumes used above +volumes: + gofiber_uploads: {} + gofiber_views: {} + gofiber_docs: {} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..176a004 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' + +services: + app: + build: + context: . + args: + DB_URL: ${DB_URL} + REDIS_URL: ${REDIS_URL} + REDIS_HOST: ${REDIS_HOST} + REDIS_PORT: ${REDIS_PORT} + EMAIL_HOST: ${EMAIL_HOST} + EMAIL_PORT: ${EMAIL_PORT} + container_name: gofiber-app + #ports: + # - "8080:8080" + env_file: + - .env + volumes: + - ./uploads:/app/uploads + - ./views:/app/views + - ./docs:/app/docs + networks: + - dokploy-network + restart: unless-stopped + +networks: + dokploy-network: + external: true diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..5d9d09a --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,4110 @@ +// 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}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/admin/categories": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "List categories (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Permanently delete a category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Restore soft-deleted category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/category-views": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "CategoryViews" + ], + "summary": "List category views (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/category-views/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "CategoryViews" + ], + "summary": "Permanently delete a category view (admin only)", + "parameters": [ + { + "type": "integer", + "description": "CategoryView ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/category-views/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "CategoryViews" + ], + "summary": "Restore soft-deleted category view (admin only)", + "parameters": [ + { + "type": "integer", + "description": "CategoryView ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/blacklist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "List CORS blacklists (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Create CORS blacklist (admin only)", + "parameters": [ + { + "description": "Blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsBlacklistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/blacklist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Update CORS blacklist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Blacklist ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsBlacklistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Soft delete CORS blacklist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Blacklist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/blacklist/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Hard delete CORS blacklist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Blacklist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/whitelist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "List CORS whitelists (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Create CORS whitelist (admin only)", + "parameters": [ + { + "description": "Whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsWhitelistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/whitelist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Update CORS whitelist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Whitelist ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsWhitelistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Soft delete CORS whitelist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Whitelist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/whitelist/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Hard delete CORS whitelist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Whitelist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "List posts (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Permanently delete a post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Restore soft-deleted post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/rate-limit": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "List rate limit settings (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Create rate limit setting (admin only)", + "parameters": [ + { + "description": "Rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RateLimitSettingRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/rate-limit/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Update rate limit setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Rate limit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RateLimitSettingRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Soft delete rate limit setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Rate limit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/rate-limit/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Hard delete rate limit setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Rate limit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "List tags (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Permanently delete a tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Restore soft-deleted tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/admin/example": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Admin-only sample endpoint", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get current user from token", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register user", + "parameters": [ + { + "description": "Register payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/resend-verification": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Resend verification email", + "parameters": [ + { + "description": "Resend verification payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.ResendVerificationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/user/example": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Normal-user-only sample endpoint", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Verify email address with token", + "parameters": [ + { + "type": "string", + "description": "Email verify token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categories": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "List categories (public)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CategoryDoc" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Create category (admin only)", + "parameters": [ + { + "description": "Category payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CreateCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categories/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Get single category (public)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Update category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Category payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpdateCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Delete category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "List comments for a post (public)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "post_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CommentDoc" + } + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "Create comment (public)", + "parameters": [ + { + "description": "Comment payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.CommentDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "Delete comment (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/hero": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Get active hero/banner", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Create new hero/banner (admin only)", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData", + "required": true + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "Hero Image", + "name": "image", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/hero/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Update hero/banner (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "Hero Image", + "name": "image", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Delete hero/banner (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/heroes": { + "get": { + "description": "Returns all hero/banner records (no filter)", + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Get all heroes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "List posts (public) with pagination", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Create a post (admin only)", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated category ids", + "name": "category_ids", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated tag ids", + "name": "tag_ids", + "in": "formData" + }, + { + "type": "file", + "description": "Images (multiple allowed)", + "name": "images", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.PostDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Get single post (public)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.PostDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Update a post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated category ids", + "name": "category_ids", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated tag ids", + "name": "tag_ids", + "in": "formData" + }, + { + "type": "file", + "description": "Images (multiple allowed)", + "name": "images", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.PostDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Delete a post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/setting": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Get site settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Create new site setting (admin only)", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta Title", + "name": "meta_title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta Description", + "name": "meta_description", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map Embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "White Logo", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black Logo", + "name": "b_logo", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/setting/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Update site setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta Title", + "name": "meta_title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta Description", + "name": "meta_description", + "in": "formData" + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData" + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData" + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData" + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map Embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "White Logo", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black Logo", + "name": "b_logo", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Delete site setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "List tags (public)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TagDoc" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Create tag (admin only)", + "parameters": [ + { + "description": "Tag payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CreateTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.TagDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Update tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Tag payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpdateTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TagDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Delete tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/list": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "List active users (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/list/deleted": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "List soft-deleted users (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Username", + "name": "username", + "in": "formData" + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Admin", + "name": "is_admin", + "in": "formData" + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "formData" + }, + { + "type": "string", + "description": "First Name", + "name": "first_name", + "in": "formData" + }, + { + "type": "string", + "description": "Last Name", + "name": "last_name", + "in": "formData" + }, + { + "type": "boolean", + "description": "Email Verified", + "name": "email_verified", + "in": "formData" + }, + { + "type": "file", + "description": "Avatar Image", + "name": "avatar", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Soft delete user (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Hard delete user permanently (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Restore soft-deleted user (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "controllers.CorsBlacklistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "controllers.CorsWhitelistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string" + } + } + }, + "controllers.CreateCategoryRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string", + "minLength": 2 + } + } + }, + "controllers.CreateTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "controllers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "controllers.RateLimitSettingRequest": { + "type": "object", + "required": [ + "max_requests", + "name", + "window_seconds" + ], + "properties": { + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "max_requests": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string" + }, + "window_seconds": { + "type": "integer", + "minimum": 1 + } + } + }, + "controllers.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "controllers.RegisterRequest": { + "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string", + "minLength": 3 + } + } + }, + "controllers.ResendVerificationRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "controllers.UpdateCategoryRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string", + "minLength": 2 + } + } + }, + "controllers.UpdateTagRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "models.CategoryDoc": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "models.CommentDoc": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "post_id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.PostDoc": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "content": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TagDoc" + } + }, + "title": { + "type": "string" + } + } + }, + "models.TagDoc": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "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{"http"}, + Title: "AreS Fiber API Server", + Description: "This is a sample server for AreS Fiber API.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..d621634 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,4089 @@ +{ + "schemes": [ + "http" + ], + "swagger": "2.0", + "info": { + "description": "This is a sample server for AreS Fiber API.", + "title": "AreS Fiber API Server", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/api/v1/admin/categories": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "List categories (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Permanently delete a category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Restore soft-deleted category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/category-views": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "CategoryViews" + ], + "summary": "List category views (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/category-views/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "CategoryViews" + ], + "summary": "Permanently delete a category view (admin only)", + "parameters": [ + { + "type": "integer", + "description": "CategoryView ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/category-views/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "CategoryViews" + ], + "summary": "Restore soft-deleted category view (admin only)", + "parameters": [ + { + "type": "integer", + "description": "CategoryView ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/blacklist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "List CORS blacklists (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Create CORS blacklist (admin only)", + "parameters": [ + { + "description": "Blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsBlacklistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/blacklist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Update CORS blacklist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Blacklist ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Blacklist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsBlacklistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Soft delete CORS blacklist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Blacklist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/blacklist/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Hard delete CORS blacklist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Blacklist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/whitelist": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "List CORS whitelists (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Create CORS whitelist (admin only)", + "parameters": [ + { + "description": "Whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsWhitelistRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/whitelist/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Update CORS whitelist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Whitelist ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Whitelist payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CorsWhitelistRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Soft delete CORS whitelist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Whitelist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/cors/whitelist/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Hard delete CORS whitelist (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Whitelist ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "List posts (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Permanently delete a post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Restore soft-deleted post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/rate-limit": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "List rate limit settings (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Create rate limit setting (admin only)", + "parameters": [ + { + "description": "Rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RateLimitSettingRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/rate-limit/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Update rate limit setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Rate limit ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Rate limit payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RateLimitSettingRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Soft delete rate limit setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Rate limit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/rate-limit/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin Security" + ], + "summary": "Hard delete rate limit setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Rate limit ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "List tags (admin) with optional trashed filter", + "parameters": [ + { + "type": "string", + "description": "Trash filter: none|only|with", + "name": "trashed", + "in": "query" + }, + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Permanently delete a tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Restore soft-deleted tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/admin/example": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Admin-only sample endpoint", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get current user from token", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register user", + "parameters": [ + { + "description": "Register payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/resend-verification": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Resend verification email", + "parameters": [ + { + "description": "Resend verification payload", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.ResendVerificationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/user/example": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Normal-user-only sample endpoint", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Verify email address with token", + "parameters": [ + { + "type": "string", + "description": "Email verify token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categories": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "List categories (public)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CategoryDoc" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Create category (admin only)", + "parameters": [ + { + "description": "Category payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CreateCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categories/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Get single category (public)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Update category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Category payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpdateCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Categories" + ], + "summary": "Delete category (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "List comments for a post (public)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "post_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CommentDoc" + } + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "Create comment (public)", + "parameters": [ + { + "description": "Comment payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.CommentDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Comments" + ], + "summary": "Delete comment (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/hero": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Get active hero/banner", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Create new hero/banner (admin only)", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData", + "required": true + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "Hero Image", + "name": "image", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/hero/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Update hero/banner (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "Hero Image", + "name": "image", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Delete hero/banner (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/heroes": { + "get": { + "description": "Returns all hero/banner records (no filter)", + "produces": [ + "application/json" + ], + "tags": [ + "Hero" + ], + "summary": "Get all heroes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "List posts (public) with pagination", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Create a post (admin only)", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated category ids", + "name": "category_ids", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated tag ids", + "name": "tag_ids", + "in": "formData" + }, + { + "type": "file", + "description": "Images (multiple allowed)", + "name": "images", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.PostDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Get single post (public)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.PostDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Update a post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated category ids", + "name": "category_ids", + "in": "formData" + }, + { + "type": "string", + "description": "Comma separated tag ids", + "name": "tag_ids", + "in": "formData" + }, + { + "type": "file", + "description": "Images (multiple allowed)", + "name": "images", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.PostDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Posts" + ], + "summary": "Delete a post (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/setting": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Get site settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Create new site setting (admin only)", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta Title", + "name": "meta_title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta Description", + "name": "meta_description", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map Embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "White Logo", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black Logo", + "name": "b_logo", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/setting/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Update site setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta Title", + "name": "meta_title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta Description", + "name": "meta_description", + "in": "formData" + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData" + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData" + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData" + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map Embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "file", + "description": "White Logo", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black Logo", + "name": "b_logo", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setting" + ], + "summary": "Delete site setting (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "List tags (public)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TagDoc" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Create tag (admin only)", + "parameters": [ + { + "description": "Tag payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CreateTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.TagDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Update tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Tag payload", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UpdateTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TagDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Delete tag (admin only)", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/list": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "List active users (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/list/deleted": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "List soft-deleted users (admin only)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Username", + "name": "username", + "in": "formData" + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Admin", + "name": "is_admin", + "in": "formData" + }, + { + "type": "string", + "description": "Password", + "name": "password", + "in": "formData" + }, + { + "type": "string", + "description": "First Name", + "name": "first_name", + "in": "formData" + }, + { + "type": "string", + "description": "Last Name", + "name": "last_name", + "in": "formData" + }, + { + "type": "boolean", + "description": "Email Verified", + "name": "email_verified", + "in": "formData" + }, + { + "type": "file", + "description": "Avatar Image", + "name": "avatar", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Soft delete user (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/{id}/hard": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Hard delete user permanently (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Restore soft-deleted user (admin only)", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "controllers.CorsBlacklistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "controllers.CorsWhitelistRequest": { + "type": "object", + "required": [ + "origin" + ], + "properties": { + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "origin": { + "type": "string" + } + } + }, + "controllers.CreateCategoryRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string", + "minLength": 2 + } + } + }, + "controllers.CreateTagRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "controllers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "controllers.RateLimitSettingRequest": { + "type": "object", + "required": [ + "max_requests", + "name", + "window_seconds" + ], + "properties": { + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "max_requests": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string" + }, + "window_seconds": { + "type": "integer", + "minimum": 1 + } + } + }, + "controllers.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "controllers.RegisterRequest": { + "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string", + "minLength": 3 + } + } + }, + "controllers.ResendVerificationRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "controllers.UpdateCategoryRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string", + "minLength": 2 + } + } + }, + "controllers.UpdateTagRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "models.CategoryDoc": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "models.CommentDoc": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "post_id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.PostDoc": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CategoryDoc" + } + }, + "content": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TagDoc" + } + }, + "title": { + "type": "string" + } + } + }, + "models.TagDoc": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..6327313 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,2633 @@ +basePath: / +definitions: + controllers.CorsBlacklistRequest: + properties: + is_active: + type: boolean + origin: + type: string + reason: + type: string + required: + - origin + type: object + controllers.CorsWhitelistRequest: + properties: + description: + type: string + is_active: + type: boolean + origin: + type: string + required: + - origin + type: object + controllers.CreateCategoryRequest: + properties: + description: + type: string + parent_id: + type: integer + title: + minLength: 2 + type: string + required: + - title + type: object + controllers.CreateTagRequest: + properties: + name: + minLength: 1 + type: string + required: + - name + type: object + controllers.LoginRequest: + properties: + email: + type: string + password: + type: string + required: + - email + - password + type: object + controllers.RateLimitSettingRequest: + properties: + description: + type: string + is_active: + type: boolean + max_requests: + minimum: 1 + type: integer + name: + type: string + window_seconds: + minimum: 1 + type: integer + required: + - max_requests + - name + - window_seconds + type: object + controllers.RefreshRequest: + properties: + refresh_token: + type: string + required: + - refresh_token + type: object + controllers.RegisterRequest: + properties: + email: + type: string + first_name: + type: string + last_name: + type: string + password: + minLength: 6 + type: string + username: + minLength: 3 + type: string + required: + - email + - first_name + - last_name + - password + - username + type: object + controllers.ResendVerificationRequest: + properties: + email: + type: string + required: + - email + type: object + controllers.UpdateCategoryRequest: + properties: + description: + type: string + parent_id: + type: integer + title: + minLength: 2 + type: string + type: object + controllers.UpdateTagRequest: + properties: + name: + minLength: 1 + type: string + type: object + models.CategoryDoc: + properties: + children: + items: + $ref: '#/definitions/models.CategoryDoc' + type: array + description: + type: string + id: + type: integer + parent_id: + type: integer + title: + type: string + type: object + models.CommentDoc: + properties: + body: + type: string + id: + type: integer + post_id: + type: integer + user_id: + type: integer + type: object + models.PostDoc: + properties: + categories: + items: + $ref: '#/definitions/models.CategoryDoc' + type: array + content: + type: string + id: + type: integer + images: + items: + type: string + type: array + tags: + items: + $ref: '#/definitions/models.TagDoc' + type: array + title: + type: string + type: object + models.TagDoc: + properties: + id: + type: integer + name: + type: string + type: object +host: localhost:8080 +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: This is a sample server for AreS Fiber API. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: AreS Fiber API Server + version: "1.0" +paths: + /api/v1/admin/categories: + get: + parameters: + - description: 'Trash filter: none|only|with' + in: query + name: trashed + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List categories (admin) with optional trashed filter + tags: + - Categories + /api/v1/admin/categories/{id}/hard: + delete: + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Permanently delete a category (admin only) + tags: + - Categories + /api/v1/admin/categories/{id}/restore: + post: + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore soft-deleted category (admin only) + tags: + - Categories + /api/v1/admin/category-views: + get: + parameters: + - description: 'Trash filter: none|only|with' + in: query + name: trashed + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List category views (admin) with optional trashed filter + tags: + - CategoryViews + /api/v1/admin/category-views/{id}/hard: + delete: + parameters: + - description: CategoryView ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Permanently delete a category view (admin only) + tags: + - CategoryViews + /api/v1/admin/category-views/{id}/restore: + post: + parameters: + - description: CategoryView ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore soft-deleted category view (admin only) + tags: + - CategoryViews + /api/v1/admin/cors/blacklist: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List CORS blacklists (admin only) + tags: + - Admin Security + post: + consumes: + - application/json + parameters: + - description: Blacklist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.CorsBlacklistRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create CORS blacklist (admin only) + tags: + - Admin Security + /api/v1/admin/cors/blacklist/{id}: + delete: + parameters: + - description: Blacklist ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Soft delete CORS blacklist (admin only) + tags: + - Admin Security + put: + consumes: + - application/json + parameters: + - description: Blacklist ID + in: path + name: id + required: true + type: integer + - description: Blacklist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.CorsBlacklistRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update CORS blacklist (admin only) + tags: + - Admin Security + /api/v1/admin/cors/blacklist/{id}/hard: + delete: + parameters: + - description: Blacklist ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Hard delete CORS blacklist (admin only) + tags: + - Admin Security + /api/v1/admin/cors/whitelist: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List CORS whitelists (admin only) + tags: + - Admin Security + post: + consumes: + - application/json + parameters: + - description: Whitelist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.CorsWhitelistRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create CORS whitelist (admin only) + tags: + - Admin Security + /api/v1/admin/cors/whitelist/{id}: + delete: + parameters: + - description: Whitelist ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Soft delete CORS whitelist (admin only) + tags: + - Admin Security + put: + consumes: + - application/json + parameters: + - description: Whitelist ID + in: path + name: id + required: true + type: integer + - description: Whitelist payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.CorsWhitelistRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update CORS whitelist (admin only) + tags: + - Admin Security + /api/v1/admin/cors/whitelist/{id}/hard: + delete: + parameters: + - description: Whitelist ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Hard delete CORS whitelist (admin only) + tags: + - Admin Security + /api/v1/admin/posts: + get: + parameters: + - description: 'Trash filter: none|only|with' + in: query + name: trashed + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List posts (admin) with optional trashed filter + tags: + - Posts + /api/v1/admin/posts/{id}/hard: + delete: + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Permanently delete a post (admin only) + tags: + - Posts + /api/v1/admin/posts/{id}/restore: + post: + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore soft-deleted post (admin only) + tags: + - Posts + /api/v1/admin/rate-limit: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List rate limit settings (admin only) + tags: + - Admin Security + post: + consumes: + - application/json + parameters: + - description: Rate limit payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.RateLimitSettingRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create rate limit setting (admin only) + tags: + - Admin Security + /api/v1/admin/rate-limit/{id}: + delete: + parameters: + - description: Rate limit ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Soft delete rate limit setting (admin only) + tags: + - Admin Security + put: + consumes: + - application/json + parameters: + - description: Rate limit ID + in: path + name: id + required: true + type: integer + - description: Rate limit payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.RateLimitSettingRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update rate limit setting (admin only) + tags: + - Admin Security + /api/v1/admin/rate-limit/{id}/hard: + delete: + parameters: + - description: Rate limit ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Hard delete rate limit setting (admin only) + tags: + - Admin Security + /api/v1/admin/tags: + get: + parameters: + - description: 'Trash filter: none|only|with' + in: query + name: trashed + type: string + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List tags (admin) with optional trashed filter + tags: + - Tags + /api/v1/admin/tags/{id}/hard: + delete: + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Permanently delete a tag (admin only) + tags: + - Tags + /api/v1/admin/tags/{id}/restore: + post: + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore soft-deleted tag (admin only) + tags: + - Tags + /api/v1/auth/admin/example: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Admin-only sample endpoint + tags: + - Auth + /api/v1/auth/login: + post: + consumes: + - application/json + parameters: + - description: Login payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Login user + tags: + - Auth + /api/v1/auth/me: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get current user from token + tags: + - Auth + /api/v1/auth/refresh: + post: + consumes: + - application/json + parameters: + - description: Refresh payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Refresh access token + tags: + - Auth + /api/v1/auth/register: + post: + consumes: + - application/json + parameters: + - description: Register payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + summary: Register user + tags: + - Auth + /api/v1/auth/resend-verification: + post: + consumes: + - application/json + parameters: + - description: Resend verification payload + in: body + name: request + required: true + schema: + $ref: '#/definitions/controllers.ResendVerificationRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Resend verification email + tags: + - Auth + /api/v1/auth/user/example: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Normal-user-only sample endpoint + tags: + - Auth + /api/v1/auth/verify-email: + get: + parameters: + - description: Email verify token + in: query + name: token + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Verify email address with token + tags: + - Auth + /api/v1/categories: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.CategoryDoc' + type: array + summary: List categories (public) + tags: + - Categories + post: + consumes: + - application/json + parameters: + - description: Category payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/controllers.CreateCategoryRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.CategoryDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create category (admin only) + tags: + - Categories + /api/v1/categories/{id}: + delete: + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete category (admin only) + tags: + - Categories + get: + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.CategoryDoc' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get single category (public) + tags: + - Categories + put: + consumes: + - application/json + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + - description: Category payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/controllers.UpdateCategoryRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.CategoryDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update category (admin only) + tags: + - Categories + /api/v1/comments: + get: + parameters: + - description: Post ID + in: query + name: post_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.CommentDoc' + type: array + summary: List comments for a post (public) + tags: + - Comments + post: + consumes: + - application/json + parameters: + - description: Comment payload + in: body + name: data + required: true + schema: + type: object + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.CommentDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + summary: Create comment (public) + tags: + - Comments + /api/v1/comments/{id}: + delete: + parameters: + - description: Comment ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete comment (admin only) + tags: + - Comments + /api/v1/hero: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get active hero/banner + tags: + - Hero + post: + consumes: + - multipart/form-data + parameters: + - description: Title + in: formData + name: title + type: string + - description: Text1 + in: formData + name: text1 + type: string + - description: Text2 + in: formData + name: text2 + type: string + - description: Text4 + in: formData + name: text4 + type: string + - description: Text5 + in: formData + name: text5 + type: string + - description: Color + in: formData + name: color + required: true + type: string + - description: Is Active + in: formData + name: is_active + type: boolean + - description: Hero Image + in: formData + name: image + required: true + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create new hero/banner (admin only) + tags: + - Hero + /api/v1/hero/{id}: + delete: + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete hero/banner (admin only) + tags: + - Hero + put: + consumes: + - multipart/form-data + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + - description: Title + in: formData + name: title + type: string + - description: Text1 + in: formData + name: text1 + type: string + - description: Text2 + in: formData + name: text2 + type: string + - description: Text4 + in: formData + name: text4 + type: string + - description: Text5 + in: formData + name: text5 + type: string + - description: Color + in: formData + name: color + type: string + - description: Is Active + in: formData + name: is_active + type: boolean + - description: Hero Image + in: formData + name: image + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update hero/banner (admin only) + tags: + - Hero + /api/v1/heroes: + get: + description: Returns all hero/banner records (no filter) + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get all heroes + tags: + - Hero + /api/v1/posts: + get: + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: List posts (public) with pagination + tags: + - Posts + post: + consumes: + - multipart/form-data + parameters: + - description: Title + in: formData + name: title + required: true + type: string + - description: Content + in: formData + name: content + type: string + - description: Comma separated category ids + in: formData + name: category_ids + type: string + - description: Comma separated tag ids + in: formData + name: tag_ids + type: string + - description: Images (multiple allowed) + in: formData + name: images + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.PostDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create a post (admin only) + tags: + - Posts + /api/v1/posts/{id}: + delete: + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete a post (admin only) + tags: + - Posts + get: + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.PostDoc' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get single post (public) + tags: + - Posts + put: + consumes: + - multipart/form-data + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + - description: Title + in: formData + name: title + type: string + - description: Content + in: formData + name: content + type: string + - description: Comma separated category ids + in: formData + name: category_ids + type: string + - description: Comma separated tag ids + in: formData + name: tag_ids + type: string + - description: Images (multiple allowed) + in: formData + name: images + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.PostDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update a post (admin only) + tags: + - Posts + /api/v1/setting: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get site settings + tags: + - Setting + post: + consumes: + - multipart/form-data + parameters: + - description: Title + in: formData + name: title + required: true + type: string + - description: Meta Title + in: formData + name: meta_title + required: true + type: string + - description: Meta Description + in: formData + name: meta_description + required: true + type: string + - description: Phone + in: formData + name: phone + required: true + type: string + - description: URL + in: formData + name: url + required: true + type: string + - description: Email + in: formData + name: email + required: true + type: string + - description: Facebook + in: formData + name: facebook + type: string + - description: X + in: formData + name: x + type: string + - description: Instagram + in: formData + name: instagram + type: string + - description: Whatsapp + in: formData + name: whatsapp + type: string + - description: Pinterest + in: formData + name: pinterest + type: string + - description: Linkedin + in: formData + name: linkedin + type: string + - description: Slogan + in: formData + name: slogan + type: string + - description: Address + in: formData + name: address + type: string + - description: Copyright + in: formData + name: copyright + type: string + - description: Map Embed + in: formData + name: map_embed + type: string + - description: Is Active + in: formData + name: is_active + type: boolean + - description: White Logo + in: formData + name: w_logo + type: file + - description: Black Logo + in: formData + name: b_logo + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create new site setting (admin only) + tags: + - Setting + /api/v1/setting/{id}: + delete: + parameters: + - description: Setting ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete site setting (admin only) + tags: + - Setting + put: + consumes: + - multipart/form-data + parameters: + - description: Setting ID + in: path + name: id + required: true + type: integer + - description: Title + in: formData + name: title + type: string + - description: Meta Title + in: formData + name: meta_title + type: string + - description: Meta Description + in: formData + name: meta_description + type: string + - description: Phone + in: formData + name: phone + type: string + - description: URL + in: formData + name: url + type: string + - description: Email + in: formData + name: email + type: string + - description: Facebook + in: formData + name: facebook + type: string + - description: X + in: formData + name: x + type: string + - description: Instagram + in: formData + name: instagram + type: string + - description: Whatsapp + in: formData + name: whatsapp + type: string + - description: Pinterest + in: formData + name: pinterest + type: string + - description: Linkedin + in: formData + name: linkedin + type: string + - description: Slogan + in: formData + name: slogan + type: string + - description: Address + in: formData + name: address + type: string + - description: Copyright + in: formData + name: copyright + type: string + - description: Map Embed + in: formData + name: map_embed + type: string + - description: Is Active + in: formData + name: is_active + type: boolean + - description: White Logo + in: formData + name: w_logo + type: file + - description: Black Logo + in: formData + name: b_logo + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update site setting (admin only) + tags: + - Setting + /api/v1/tags: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.TagDoc' + type: array + summary: List tags (public) + tags: + - Tags + post: + consumes: + - application/json + parameters: + - description: Tag payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/controllers.CreateTagRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.TagDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create tag (admin only) + tags: + - Tags + /api/v1/tags/{id}: + delete: + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete tag (admin only) + tags: + - Tags + put: + consumes: + - application/json + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + - description: Tag payload + in: body + name: data + required: true + schema: + $ref: '#/definitions/controllers.UpdateTagRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TagDoc' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update tag (admin only) + tags: + - Tags + /api/v1/users/{id}: + delete: + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Soft delete user (admin only) + tags: + - Users + put: + consumes: + - multipart/form-data + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + - description: Username + in: formData + name: username + type: string + - description: Email + in: formData + name: email + type: string + - description: Is Admin + in: formData + name: is_admin + type: boolean + - description: Password + in: formData + name: password + type: string + - description: First Name + in: formData + name: first_name + type: string + - description: Last Name + in: formData + name: last_name + type: string + - description: Email Verified + in: formData + name: email_verified + type: boolean + - description: Avatar Image + in: formData + name: avatar + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update user (admin only) + tags: + - Users + /api/v1/users/{id}/hard: + delete: + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Hard delete user permanently (admin only) + tags: + - Users + /api/v1/users/{id}/restore: + post: + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore soft-deleted user (admin only) + tags: + - Users + /api/v1/users/list: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List active users (admin only) + tags: + - Users + /api/v1/users/list/deleted: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List soft-deleted users (admin only) + tags: + - Users +schemes: +- http +securityDefinitions: + BearerAuth: + description: Type "Bearer" followed by a space and JWT token. + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e3da24f --- /dev/null +++ b/go.mod @@ -0,0 +1,71 @@ +module goFiber + +go 1.25.0 + +require ( + github.com/go-playground/validator/v10 v10.30.1 + github.com/gofiber/fiber/v3 v3.0.0 + github.com/gofiber/storage/redis/v3 v3.4.3 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.17.3 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.16.6 + go.uber.org/zap v1.27.1 + golang.org/x/crypto v0.48.0 + golang.org/x/sys v0.41.0 // indirect + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 +) + +require github.com/prometheus/client_golang v1.23.2 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gofiber/contrib/fiberzap/v2 v2.1.6 // indirect + github.com/gofiber/fiber/v2 v2.52.6 // indirect + github.com/gofiber/schema v1.7.0 // indirect + github.com/gofiber/utils/v2 v2.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/leodido/go-urn v1.4.0 // 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/mattn/go-runewidth v0.0.16 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // 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/multierr v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1e02837 --- /dev/null +++ b/go.sum @@ -0,0 +1,276 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +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.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +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/contrib/fiberzap/v2 v2.1.6 h1:8aMBaO7jAB4w9o2uGC1S3ieKPxg8vfJ7t1aipq2pudg= +github.com/gofiber/contrib/fiberzap/v2 v2.1.6/go.mod h1:sGrPV2XzRrI6aJQOmORr5rdk4vXLR630Oc/REtMmCYs= +github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= +github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= +github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= +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/storage/redis/v3 v3.4.3 h1:PvazbTpDAvmDHpMk4fCvCoTXm+neLXQL1rWuHTXlNz8= +github.com/gofiber/storage/redis/v3 v3.4.3/go.mod h1:n/wFsaS4cwfRQERwhkZhMmJrNFAf514MaWL7ky33sTk= +github.com/gofiber/storage/testhelpers/redis v0.1.0 h1:lDUwtanDf3f5YwlDwhbqnqCtj9Y/xc8ctxRE6HpQcws= +github.com/gofiber/storage/testhelpers/redis v0.1.0/go.mod h1:Y1UccxbGVL04+TF5RuyCsksX+76hu6nJIWjPukBBgJ4= +github.com/gofiber/utils/v2 v2.0.1 h1:+kvhvoGuAeUBzF/Qlkx5HvFK7tNd62mxSpBuI0zCRII= +github.com/gofiber/utils/v2 v2.0.1/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg= +github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= +github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +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/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +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/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/redis v0.40.0 h1:OG4qwcxp2O0re7V7M9lY9w0v6wWgWf7j7rtkpAnGMd0= +github.com/testcontainers/testcontainers-go/modules/redis v0.40.0/go.mod h1:Bc+EDhKMo5zI5V5zdBkHiMVzeAXbtI4n5isS/nzf6zw= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..cf916f7 --- /dev/null +++ b/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "fmt" + configs "goFiber/config" + database "goFiber/database/config" + migrasyon "goFiber/database/migrate" + "goFiber/middlewares" + "goFiber/routes" + "log" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" + "github.com/gofiber/fiber/v3/middleware/session" + redisStorage "github.com/gofiber/storage/redis/v3" + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.uber.org/zap" +) + +var SessionStore *session.Store + +func init() { + configs.LoadConfig() + database.ConnectDB() + database.ConnectRedis() + + migrasyon.Migrate() + + // Session store: Redis varsa Redis-backed storage, yoksa in-memory + if database.RedisOptions != nil { + // Use URL so storage package parses it correctly + cfg := redisStorage.Config{ + URL: configs.AppConfig.RedisUrl, + } + + // If TLSConfig was set during parse, copy it over + if database.RedisOptions.TLSConfig != nil { + cfg.TLSConfig = database.RedisOptions.TLSConfig + } + + storage := redisStorage.New(cfg) + SessionStore = session.NewStore(session.Config{ + Storage: storage, + }) + log.Println("Session store: using Redis-backed storage") + } else { + // Basit: her durumda in-memory session store kullan (opsiyonel: Redis kullanımı ileride eklenebilir) + SessionStore = session.NewStore() + log.Println("Session store: using in-memory storage") + } + + log.Println("Init Uygulandı !!") +} + +// @title AreS Fiber API Server +// @version 1.0 +// @description This is a sample server for AreS Fiber API. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host localhost:8080 +// @BasePath / +// @schemes http + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. +func main() { + logger, _ := zap.NewProduction() + zap.ReplaceGlobals(logger) + defer logger.Sync() + sugar := logger.Sugar() + + app := fiber.New(fiber.Config{ + AppName: "AreS Fiber API Server", + IdleTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + Concurrency: 256 * 1024, + }) + + //zap.L().Info("Fiber uygulaması başlatıldı") + // prevent 'unused variable' warning for SessionStore (it may be used elsewhere) + _ = SessionStore + app.Get("/metric", adaptor.HTTPHandler(promhttp.Handler())) + app.Get("/health", func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + }) + }) + + // Serve uploads folder dynamically so URLs like /uploads/settings/xxx.png are accessible + app.Get("/uploads/*", func(c fiber.Ctx) error { + file := c.Params("*") + return c.SendFile("./uploads/" + file) + }) + + app.Use(middlewares.DynamicCORS()) + routes.RouterUser(app) + + port := configs.AppConfig.Port + if port == "" { + port = "8080" // Fallback port + } + + sugar.Info("Server Bu Porta Başladı: " + port) + _ = app.Listen(fmt.Sprintf(":%s", port)) +} diff --git a/middlewares/auth_middleware.go b/middlewares/auth_middleware.go new file mode 100644 index 0000000..50af213 --- /dev/null +++ b/middlewares/auth_middleware.go @@ -0,0 +1,63 @@ +package middlewares + +import ( + "strings" + + "goFiber/services" + + "github.com/gofiber/fiber/v3" +) + +const authClaimsKey = "auth_claims" + +func RequireAuth(c fiber.Ctx) error { + authHeader := strings.TrimSpace(c.Get("Authorization")) + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "authorization header is required"}) + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization format, expected: Bearer "}) + } + + jwtService := services.NewJWTService() + claims, err := jwtService.ValidateToken(strings.TrimSpace(parts[1])) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"}) + } + if claims.TokenType != services.TokenTypeAccess { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "access token required"}) + } + + c.Locals(authClaimsKey, claims) + return c.Next() +} + +func RequireAdmin(c fiber.Ctx) error { + claims, ok := GetAuthClaims(c) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + if !claims.IsAdmin { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "admin role required"}) + } + return c.Next() +} + +func RequireNormalUser(c fiber.Ctx) error { + claims, ok := GetAuthClaims(c) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + if claims.IsAdmin { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "only normal users can access this endpoint"}) + } + return c.Next() +} + +func GetAuthClaims(c fiber.Ctx) (*services.JWTClaim, bool) { + raw := c.Locals(authClaimsKey) + claims, ok := raw.(*services.JWTClaim) + return claims, ok +} diff --git a/middlewares/dynamic_cors.go b/middlewares/dynamic_cors.go new file mode 100644 index 0000000..2690046 --- /dev/null +++ b/middlewares/dynamic_cors.go @@ -0,0 +1,137 @@ +package middlewares + +import ( + "encoding/json" + "errors" + configs "goFiber/config" + database "goFiber/database/config" + "goFiber/database/models" + "log" + "net/http" + "strings" + + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" +) + +const ( + corsWhitelistActiveCacheKey = "cors:active:whitelist" + corsBlacklistActiveCacheKey = "cors:active:blacklist" + corsCacheTTLSeconds = 60 +) + +var ( + allowedMethods = "GET,POST,PUT,PATCH,DELETE,OPTIONS" + allowedHeaders = "Authorization,Content-Type,Accept,Origin,X-Requested-With" +) + +// DynamicCORS validates request Origin using DB-backed whitelist/blacklist with Redis caching. +func DynamicCORS() fiber.Handler { + return func(c fiber.Ctx) error { + origin := strings.TrimSpace(c.Get("Origin")) + if origin == "" { + return c.Next() + } + if database.DB == nil { + corsLogf("[cors][skip] database unavailable origin=%s path=%s", origin, c.Path()) + return c.Next() + } + + originKey := strings.ToLower(origin) + // Keep same-origin requests working even if DB entries are missing. + if origin == requestBaseURL(c) { + corsLogf("[cors][allow] same-origin origin=%s path=%s", origin, c.Path()) + setCORSHeaders(c, origin) + if c.Method() == http.MethodOptions { + return c.SendStatus(http.StatusNoContent) + } + return c.Next() + } + + blacklist, err := loadActiveOriginSet(corsBlacklistActiveCacheKey, true) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "cors blacklist lookup failed"}) + } + if blacklist[originKey] { + log.Printf("[cors][blocked] blacklist origin=%s path=%s", origin, c.Path()) + return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "origin is blocked by CORS policy"}) + } + + whitelist, err := loadActiveOriginSet(corsWhitelistActiveCacheKey, false) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "cors whitelist lookup failed"}) + } + if !whitelist[originKey] { + log.Printf("[cors][blocked] not-whitelisted origin=%s path=%s", origin, c.Path()) + return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "origin is not allowed by CORS policy"}) + } + + corsLogf("[cors][allow] origin=%s path=%s", origin, c.Path()) + setCORSHeaders(c, origin) + if c.Method() == http.MethodOptions { + return c.SendStatus(http.StatusNoContent) + } + return c.Next() + } +} + +func setCORSHeaders(c fiber.Ctx, origin string) { + c.Set("Vary", "Origin") + c.Set("Access-Control-Allow-Origin", origin) + c.Set("Access-Control-Allow-Methods", allowedMethods) + c.Set("Access-Control-Allow-Headers", allowedHeaders) + c.Set("Access-Control-Allow-Credentials", "true") + c.Set("Access-Control-Max-Age", "600") +} + +func requestBaseURL(c fiber.Ctx) string { + return c.Protocol() + "://" + c.Get("Host") +} + +func loadActiveOriginSet(cacheKey string, isBlacklist bool) (map[string]bool, error) { + out := make(map[string]bool) + + if cached, err := database.Get(cacheKey); err == nil { + corsLogf("[cors][cache-hit] key=%s", cacheKey) + var origins []string + if jsonErr := json.Unmarshal([]byte(cached), &origins); jsonErr == nil { + for _, origin := range origins { + out[strings.ToLower(strings.TrimSpace(origin))] = true + } + return out, nil + } + } else if !errors.Is(err, redis.Nil) { + return nil, err + } + corsLogf("[cors][cache-miss] key=%s", cacheKey) + + var origins []string + var dbErr error + if isBlacklist { + dbErr = database.DB.Model(&models.CorsBlacklist{}). + Where("is_active = ?", true). + Pluck("origin", &origins).Error + } else { + dbErr = database.DB.Model(&models.CorsWhitelist{}). + Where("is_active = ?", true). + Pluck("origin", &origins).Error + } + if dbErr != nil { + return nil, dbErr + } + + for _, origin := range origins { + out[strings.ToLower(strings.TrimSpace(origin))] = true + } + + cacheBytes, _ := json.Marshal(origins) + _ = database.SetEx(cacheKey, string(cacheBytes), corsCacheTTLSeconds) + + return out, nil +} + +func corsLogf(format string, args ...interface{}) { + if configs.AppConfig != nil && configs.AppConfig.CorsDebug { + log.Printf(format, args...) + } +} diff --git a/middlewares/rate_limit.go b/middlewares/rate_limit.go new file mode 100644 index 0000000..5f25a76 --- /dev/null +++ b/middlewares/rate_limit.go @@ -0,0 +1,122 @@ +package middlewares + +import ( + "context" + "encoding/json" + "errors" + "fmt" + configs "goFiber/config" + database "goFiber/database/config" + "goFiber/database/models" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type rateLimitRuntime struct { + Name string `json:"name"` + MaxRequests int64 `json:"max_requests"` + WindowSeconds int `json:"window_seconds"` + IsActive bool `json:"is_active"` +} + +// RequireRateLimit applies Redis-backed per-IP rate limiting by setting name. +func RequireRateLimit(name string, fallbackMax int64, fallbackWindowSeconds int) fiber.Handler { + return func(c fiber.Ctx) error { + if database.DB == nil { + return c.Next() + } + + setting, err := loadRateLimitRuntime(name, fallbackMax, fallbackWindowSeconds) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "rate limit configuration error"}) + } + if !setting.IsActive { + return c.Next() + } + if database.RedisClient == nil { + rateLimitLogf("[rate-limit][warn] redis unavailable, skipping enforcement name=%s", setting.Name) + return c.Next() + } + + ip := strings.TrimSpace(c.IP()) + if ip == "" { + ip = "unknown" + } + + counterKey := fmt.Sprintf("ratelimit:%s:%s", setting.Name, ip) + count, err := database.RedisClient.Incr(context.Background(), counterKey).Result() + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "rate limit check failed"}) + } + if count == 1 { + _ = database.RedisClient.Expire(context.Background(), counterKey, time.Duration(setting.WindowSeconds)*time.Second).Err() + } + + if count > setting.MaxRequests { + ttl, _ := database.RedisClient.TTL(context.Background(), counterKey).Result() + retryAfter := int(ttl.Seconds()) + if retryAfter < 1 { + retryAfter = setting.WindowSeconds + } + c.Set("Retry-After", strconv.Itoa(retryAfter)) + log.Printf("[rate-limit][blocked] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds) + return c.Status(http.StatusTooManyRequests).JSON(fiber.Map{ + "error": "too many requests", + "retry_after": retryAfter, + }) + } + + rateLimitLogf("[rate-limit][allow] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds) + return c.Next() + } +} + +func loadRateLimitRuntime(name string, fallbackMax int64, fallbackWindowSeconds int) (*rateLimitRuntime, error) { + cacheKey := "ratelimit:setting:" + name + if cached, err := database.Get(cacheKey); err == nil { + var s rateLimitRuntime + if jsonErr := json.Unmarshal([]byte(cached), &s); jsonErr == nil { + return &s, nil + } + } else if !errors.Is(err, redis.Nil) { + return nil, err + } + + setting := &rateLimitRuntime{ + Name: name, + MaxRequests: fallbackMax, + WindowSeconds: fallbackWindowSeconds, + IsActive: true, + } + + var dbSetting models.RateLimitSetting + if err := database.DB.Where("name = ?", name).First(&dbSetting).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + rateLimitLogf("[rate-limit][config] setting=%s not found, using fallback max=%d window=%ds", name, fallbackMax, fallbackWindowSeconds) + } else { + setting.MaxRequests = dbSetting.MaxRequests + setting.WindowSeconds = dbSetting.WindowSeconds + setting.IsActive = dbSetting.IsActive + rateLimitLogf("[rate-limit][config] loaded from db name=%s active=%t max=%d window=%ds", name, setting.IsActive, setting.MaxRequests, setting.WindowSeconds) + } + + cacheJSON, _ := json.Marshal(setting) + _ = database.SetEx(cacheKey, string(cacheJSON), 60) + + return setting, nil +} + +func rateLimitLogf(format string, args ...interface{}) { + if configs.AppConfig != nil && configs.AppConfig.CorsDebug { + log.Printf(format, args...) + } +} diff --git a/pkg/utis/token.go b/pkg/utis/token.go new file mode 100644 index 0000000..724f383 --- /dev/null +++ b/pkg/utis/token.go @@ -0,0 +1,15 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" +) + +// GenerateSecureToken returns a cryptographically random hex string (e.g. for email verification). +func GenerateSecureToken(byteLength int) (string, error) { + b := make([]byte, byteLength) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/rest.client b/rest.client new file mode 100644 index 0000000..794b3d9 --- /dev/null +++ b/rest.client @@ -0,0 +1,53 @@ +### Get all heroes (no auth) +GET http://localhost:8080/api/v1/heroes +Accept: application/json + +### Get active heroes (no auth) +GET http://localhost:8080/api/v1/hero +Accept: application/json + +### Update hero (JSON) — requires admin token +PUT http://localhost:8080/api/v1/hero/1 +Content-Type: application/json +Authorization: Bearer {{ADMIN_TOKEN}} + +{ + "title": "updated-via-rest", + "is_active": false +} + +### Update hero (multipart/form-data) — send file + is_active=false +PUT http://localhost:8080/api/v1/hero/1 +Authorization: Bearer {{ADMIN_TOKEN}} +Content-Type: multipart/form-data; boundary=---011000010111000001101001 + +-----011000010111000001101001 +Content-Disposition: form-data; name="title" + +multipart-update +-----011000010111000001101001 +Content-Disposition: form-data; name="is_active" + +false +-----011000010111000001101001 +Content-Disposition: form-data; name="image"; filename="test.jpg" +Content-Type: image/jpeg + +< ./path/to/test.jpg +-----011000010111000001101001-- + +### Delete hero (admin) +DELETE http://localhost:8080/api/v1/hero/1 +Authorization: Bearer {{ADMIN_TOKEN}} + +--- +# Equivalent curl examples: +# +# curl GET all heroes +# curl -sS http://localhost:8080/api/v1/heroes | jq '.' +# +# curl update JSON +# curl -X PUT "http://localhost:8080/api/v1/hero/1" -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"title":"test","is_active":false}' +# +# curl multipart (with image) +# curl -X PUT "http://localhost:8080/api/v1/hero/1" -H "Authorization: Bearer " -F "title=multipart-test" -F "is_active=false" -F "image=@/absolute/path/to/image.jpg" diff --git a/routes/router.go b/routes/router.go new file mode 100644 index 0000000..83637a7 --- /dev/null +++ b/routes/router.go @@ -0,0 +1,146 @@ +package routes + +import ( + "goFiber/controllers" + "goFiber/middlewares" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/adaptor" + httpSwagger "github.com/swaggo/http-swagger" +) + +func RouterUser(app *fiber.App) { + + app.Get("/", func(c fiber.Ctx) error { + return c.SendFile("./views/coming_soon.html") + }) + + app.Get("/swagger/doc.json", func(c fiber.Ctx) error { + return c.SendFile("./docs/swagger.json") + }) + + app.Get("/swagger/*", adaptor.HTTPHandler(httpSwagger.Handler( + httpSwagger.URL("/swagger/doc.json"), + httpSwagger.PersistAuthorization(true), + httpSwagger.UIConfig(map[string]string{ + "requestInterceptor": `function(req) { + const auth = req.headers.Authorization || req.headers.authorization; + if (typeof auth === "string" && auth.length > 0 && !auth.toLowerCase().startsWith("bearer ")) { + req.headers.Authorization = "Bearer " + auth; + } + return req; + }`, + }), + ))) + + api := app.Group("/api/v1") + users := api.Group("/users") + auth := api.Group("/auth") + admin := api.Group("/admin") + + //users.Get("/", controllers.GetUser) + usersProtected := users.Group("", middlewares.RequireAuth) + usersProtected.Get("/me", controllers.Me) + usersProtected.Get("/admin/example", middlewares.RequireAdmin, controllers.AdminOnlyExample) + usersProtected.Get("/list", middlewares.RequireAdmin, controllers.AdminListUsers) + usersProtected.Get("/list/deleted", middlewares.RequireAdmin, controllers.AdminListDeletedUsers) + usersProtected.Get("/user/example", middlewares.RequireNormalUser, controllers.UserOnlyExample) + users.Get("/:id", controllers.GetUserOne) + users.Put("/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateUser) + users.Delete("/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteUser) + users.Delete("/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteUser) + users.Post("/:id/restore", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.RestoreUser) + + auth.Post("/register", middlewares.RequireRateLimit("register", 5, 60), controllers.Register) + auth.Post("/login", middlewares.RequireRateLimit("login", 10, 60), controllers.Login) + auth.Post("/refresh", controllers.RefreshToken, middlewares.RequireRateLimit("refresh", 10, 60), controllers.RefreshToken) + auth.Post("/resend-verification", controllers.ResendVerificationEmail) + auth.Get("/verify-email", controllers.VerifyEmail) + auth.Get("/google", controllers.GoogleAuth) + auth.Get("/google/callback", controllers.GoogleAuthCallback) + auth.Get("/github", controllers.GithubAuth) + auth.Get("/github/callback", controllers.GithubAuthCallback) + + // Hero Routes + api.Get("/hero", controllers.GetHero) + api.Get("/heroes", controllers.GetHeroAll) + api.Get("/setting", controllers.GetSetting) + + // Blog/Public Routes + api.Get("/posts", controllers.GetPosts) + api.Get("/posts/:id", controllers.GetPost) + api.Get("/categories", controllers.ListCategories) + api.Get("/tags", controllers.ListTags) + api.Get("/comments", controllers.ListComments) + + // Blog/Admin Routes + api.Post("/posts", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreatePost) + api.Put("/posts/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdatePost) + api.Delete("/posts/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeletePost) + + // Admin list posts (include trashed filter) + admin.Get("/posts", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminListPosts) + admin.Delete("/posts/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeletePost) + admin.Post("/posts/:id/restore", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminRestorePost) + + // Admin tags operations (list including trashed, hard delete, restore) + admin.Get("/tags", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminListTags) + admin.Delete("/tags/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteTag) + admin.Post("/tags/:id/restore", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminRestoreTag) + + // Admin category-views operations + admin.Get("/category-views", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminListCategoryViews) + admin.Delete("/category-views/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteCategoryView) + admin.Post("/category-views/:id/restore", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminRestoreCategoryView) + + // Admin categories operations (list including trashed, hard delete, restore) + admin.Get("/categories", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminListCategories) + admin.Delete("/categories/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteCategory) + admin.Post("/categories/:id/restore", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.AdminRestoreCategory) + + api.Post("/categories", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateCategory) + api.Put("/categories/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateCategory) + api.Delete("/categories/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteCategory) + + api.Post("/tags", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateTag) + api.Put("/tags/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateTag) + api.Delete("/tags/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteTag) + + api.Post("/comments", controllers.CreateComment) // public + api.Delete("/comments/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteComment) + + // Auth Middleware Group + authProtected := auth.Group("", middlewares.RequireAuth) + authProtected.Get("/me", controllers.Me) + //authProtected.Get("/admin/example", middlewares.RequireAdmin, controllers.AdminOnlyExample) + //authProtected.Get("/user/example", middlewares.RequireNormalUser, controllers.UserOnlyExample) + + // Admin Hero Operations + api.Post("/hero", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateHero) + api.Put("/hero/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateHero) + api.Delete("/hero/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteHero) + + // Admin Setting Operations + api.Post("/setting", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateSetting) + api.Put("/setting/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateSetting) + api.Delete("/setting/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteSetting) + + // Admin Security (CORS & Rate Limit) Operations - internal use only + admin.Get("/cors/whitelist", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.ListCorsWhitelists) + admin.Post("/cors/whitelist", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateCorsWhitelist) + admin.Put("/cors/whitelist/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateCorsWhitelist) + admin.Delete("/cors/whitelist/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteCorsWhitelist) + admin.Delete("/cors/whitelist/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteCorsWhitelist) + + admin.Get("/cors/blacklist", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.ListCorsBlacklists) + admin.Post("/cors/blacklist", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateCorsBlacklist) + admin.Put("/cors/blacklist/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateCorsBlacklist) + admin.Delete("/cors/blacklist/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteCorsBlacklist) + admin.Delete("/cors/blacklist/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteCorsBlacklist) + + admin.Get("/rate-limit", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.ListRateLimitSettings) + admin.Post("/rate-limit", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.CreateRateLimitSetting) + admin.Put("/rate-limit/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.UpdateRateLimitSetting) + admin.Delete("/rate-limit/:id", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.DeleteRateLimitSetting) + admin.Delete("/rate-limit/:id/hard", middlewares.RequireAuth, middlewares.RequireAdmin, controllers.HardDeleteRateLimitSetting) +} diff --git a/scripts/seed.go b/scripts/seed.go new file mode 100644 index 0000000..321ba4a --- /dev/null +++ b/scripts/seed.go @@ -0,0 +1,233 @@ +package main + +import ( + crand "crypto/rand" + "encoding/hex" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + configs "goFiber/config" + database "goFiber/database/config" + "goFiber/database/models" + + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var nonSlugChars = regexp.MustCompile(`[^a-z0-9\-]+`) +var multiDash = regexp.MustCompile(`-+`) + +func randHex(n int) string { + b := make([]byte, n) + _, _ = crand.Read(b) + return hex.EncodeToString(b)[:n] +} + +func ensureUploadsDir() error { + p := "./uploads/posts" + return os.MkdirAll(p, 0755) +} + +func slugify(s string) string { + repl := strings.NewReplacer( + "ç", "c", "Ç", "c", + "ğ", "g", "Ğ", "g", + "ı", "i", "İ", "i", + "ö", "o", "Ö", "o", + "ş", "s", "Ş", "s", + "ü", "u", "Ü", "u", + ) + s = repl.Replace(strings.TrimSpace(strings.ToLower(s))) + s = strings.ReplaceAll(s, " ", "-") + s = nonSlugChars.ReplaceAllString(s, "-") + s = multiDash.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if s == "" { + return "item" + } + return s +} + +func ensureUniqueCategorySlug(db *gorm.DB, base string) string { + slug := base + i := 1 + for { + var count int64 + _ = db.Model(&models.Category{}).Where("slug = ?", slug).Count(&count).Error + if count == 0 { + return slug + } + i++ + slug = fmt.Sprintf("%s-%d", base, i) + } +} + +func ensureUniquePostSlug(db *gorm.DB, base string) string { + slug := base + i := 1 + for { + var count int64 + _ = db.Model(&models.Post{}).Where("slug = ?", slug).Count(&count).Error + if count == 0 { + return slug + } + i++ + slug = fmt.Sprintf("%s-%d", base, i) + } +} + +func downloadImage(destDir string, idx int) (string, error) { + url := fmt.Sprintf("https://picsum.photos/1200/800?random=%d", idx) + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + filename := fmt.Sprintf("post_%d_%s.jpg", idx, randHex(6)) + outPath := filepath.Join(destDir, filename) + out, err := os.Create(outPath) + if err != nil { + return "", err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + if err != nil { + return "", err + } + return "uploads/posts/" + filename, nil +} + +func main() { + fmt.Println("Seeder starting...") + + // load config to get DB_URL + configs.LoadConfig() + + // ensure uploads dir + if err := ensureUploadsDir(); err != nil { + fmt.Println("failed to create uploads dir:", err) + os.Exit(1) + } + + // Require DB_URL / configured DB. Do not fallback to sqlite. + database.ConnectDB() + if database.DB == nil { + fmt.Println("Database not configured or connection failed. Please set DB_URL in .env and ensure database is reachable.") + os.Exit(1) + } + // set GORM logger to Silent for seeding to reduce noise + var db *gorm.DB = database.DB.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}) + fmt.Println("Using configured DB") + + // auto-migrate minimal models used + err := db.AutoMigrate(&models.Category{}, &models.Tag{}, &models.Post{}, &models.Comment{}) + if err != nil { + fmt.Println("AutoMigrate failed:", err) + os.Exit(1) + } + + rand.Seed(time.Now().UnixNano()) + + // create categories + cats := []models.Category{} + catNames := []string{"Teknoloji", "Yazilim", "Guncel", "Yasam", "Egitim", "Spor", "Saglik", "Finans"} + for _, n := range catNames { + baseSlug := slugify(n) + var c models.Category + if err := db.Where("title = ?", n).First(&c).Error; err == nil { + // Ensure old records have a valid unique slug. + if strings.TrimSpace(c.Slug) == "" { + c.Slug = ensureUniqueCategorySlug(db, baseSlug) + _ = db.Save(&c).Error + } + cats = append(cats, c) + continue + } + + c = models.Category{ + Title: n, + Slug: ensureUniqueCategorySlug(db, baseSlug), + Description: fmt.Sprintf("%s kategorisi seed verisi", n), + } + res := db.Create(&c) + if res.Error != nil { + fmt.Println("Failed to create category", n, ":", res.Error) + continue + } + cats = append(cats, c) + fmt.Println("Created category:", c.Title, "slug:", c.Slug) + } + + // create tags + tags := []models.Tag{} + tagNames := []string{"go", "fiber", "backend", "mysql", "redis", "jwt", "api", "docker", "cloud", "devops", "security", "testing"} + for _, n := range tagNames { + var t models.Tag + if err := db.Where("name = ?", n).First(&t).Error; err == nil { + tags = append(tags, t) + continue + } + t = models.Tag{Name: n} + res := db.Create(&t) + if res.Error != nil { + fmt.Println("Failed to create tag", n, ":", res.Error) + continue + } + tags = append(tags, t) + fmt.Println("Created tag:", t.Name) + } + + // create posts + dest := "./uploads/posts" + targetPosts := 39 + for i := 1; i <= targetPosts; i++ { + imgPath, err := downloadImage(dest, i) + if err != nil { + fmt.Println("image download failed for", i, "— using fallback path. err:", err) + imgPath = fmt.Sprintf("uploads/posts/fallback_%d.jpg", i) + } + + title := fmt.Sprintf("Seed Post %d", i) + baseSlug := slugify(title) + uniqueSlug := ensureUniquePostSlug(db, baseSlug) + p := models.Post{ + Title: title, + Slug: uniqueSlug, + Content: fmt.Sprintf("Bu bir test icerigidir. Gonderi numarasi %d.", i), + } + + // randomly attach 1-2 categories + nc := rand.Intn(2) + 1 + permCats := rand.Perm(len(cats))[:nc] + for _, idx := range permCats { + p.Categories = append(p.Categories, cats[idx]) + } + // attach 1-3 tags + nx := rand.Intn(3) + 1 + permTags := rand.Perm(len(tags))[:nx] + for _, idx := range permTags { + p.Tags = append(p.Tags, tags[idx]) + } + + // assign a single relative image path (NOT JSON array) + p.Images = imgPath + + res := db.Create(&p) + if res.Error != nil { + fmt.Println("Failed to create post", title, ":", res.Error) + } else { + fmt.Println("Created post", p.Title, "slug:", p.Slug) + } + } + + fmt.Printf("Seeding done — %d posts targeted.\n", targetPosts) + // print reminder where images are + fmt.Println("Images saved to ./uploads/posts — check files.") +} diff --git a/services/email_service.go b/services/email_service.go new file mode 100644 index 0000000..9206248 --- /dev/null +++ b/services/email_service.go @@ -0,0 +1,53 @@ +package services + +import ( + "fmt" + "net/smtp" + "strings" + + configs "goFiber/config" +) + +type EmailService struct{} + +func NewEmailService() *EmailService { + return &EmailService{} +} + +func (s *EmailService) Send(to, subject, body string) error { + host := strings.TrimSpace(configs.AppConfig.EmailHost) + port := strings.TrimSpace(configs.AppConfig.EmailPort) + from := strings.TrimSpace(configs.AppConfig.EmailFrom) + + if host == "" || port == "" || from == "" { + return fmt.Errorf("email configuration is incomplete") + } + + addr := host + ":" + port + username := strings.TrimSpace(configs.AppConfig.EmailHostUser) + password := strings.TrimSpace(configs.AppConfig.EmailHostPassword) + + var auth smtp.Auth + if username != "" && password != "" { + auth = smtp.PlainAuth("", username, password, host) + } + + message := "From: " + from + "\r\n" + + "To: " + to + "\r\n" + + "Subject: " + subject + "\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + + body + + return smtp.SendMail(addr, auth, from, []string{to}, []byte(message)) +} + +func (s *EmailService) SendVerificationEmail(to, firstName, verifyURL string) error { + subject := "Email verification" + body := fmt.Sprintf( + "Hi %s,\n\nPlease verify your email by opening this link:\n%s\n\nIf you did not create this account, you can ignore this email.", + firstName, + verifyURL, + ) + return s.Send(to, subject, body) +} diff --git a/services/jwt_service.go b/services/jwt_service.go new file mode 100644 index 0000000..494f47c --- /dev/null +++ b/services/jwt_service.go @@ -0,0 +1,121 @@ +package services + +import ( + "errors" + "strconv" + "time" + + configs "goFiber/config" + "log" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + TokenTypeAccess = "access" + TokenTypeRefresh = "refresh" +) + +type JWTClaim struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + TokenType string `json:"token_type"` + jwt.RegisteredClaims +} + +type JWTService struct{} + +func NewJWTService() *JWTService { + return &JWTService{} +} + +func (s *JWTService) GenerateToken( + userID uint, + email string, + isAdmin bool, + firstName string, + lastName string, + tokenType string, + expiration time.Duration, +) (string, error) { + now := time.Now() + claims := &JWTClaim{ + UserID: userID, + Email: email, + IsAdmin: isAdmin, + FirstName: firstName, + LastName: lastName, + TokenType: tokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatUint(uint64(userID), 10), + ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(configs.AppConfig.JWTSecret)) +} + +func (s *JWTService) GenerateTokenPair( + userID uint, + email string, + isAdmin bool, + firstName string, + lastName string, +) (string, string, error) { + access, err := s.GenerateToken( + userID, + email, + isAdmin, + firstName, + lastName, + TokenTypeAccess, + time.Duration(configs.AppConfig.AccessTokenExpireMinutes)*time.Minute, + ) + if err != nil { + return "", "", err + } + + refresh, err := s.GenerateToken( + userID, + email, + isAdmin, + firstName, + lastName, + TokenTypeRefresh, + time.Duration(configs.AppConfig.RefreshTokenExpireDays)*24*time.Hour, + ) + if err != nil { + return "", "", err + } + + // Log generated tokens (access + refresh) + log.Printf("Generated token pair for user=%d email=%s access_exp=%dm refresh_exp=%dd", userID, email, configs.AppConfig.AccessTokenExpireMinutes, configs.AppConfig.RefreshTokenExpireDays) + log.Printf("access: %s", access) + log.Printf("refresh: %s", refresh) + + return access, refresh, nil +} + +func (s *JWTService) ValidateToken(signedToken string) (*JWTClaim, error) { + token, err := jwt.ParseWithClaims(signedToken, &JWTClaim{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(configs.AppConfig.JWTSecret), nil + }) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*JWTClaim) + if !ok || !token.Valid { + return nil, errors.New("invalid token claims") + } + + return claims, nil +} diff --git a/swaginit.sh b/swaginit.sh new file mode 100644 index 0000000..67120ba --- /dev/null +++ b/swaginit.sh @@ -0,0 +1 @@ +swag init -g main.go -o docs --parseDependency --parseInternal \ No newline at end of file diff --git a/uploads/avatars/1771193117_1632286445-en-sqdgame-main-playgrou-5BVA_cover.jpg b/uploads/avatars/1771193117_1632286445-en-sqdgame-main-playgrou-5BVA_cover.jpg new file mode 100644 index 0000000..35c7888 Binary files /dev/null and b/uploads/avatars/1771193117_1632286445-en-sqdgame-main-playgrou-5BVA_cover.jpg differ diff --git a/uploads/avatars/1771193407_1657955547black-google-icon.png b/uploads/avatars/1771193407_1657955547black-google-icon.png new file mode 100644 index 0000000..7c1efb4 Binary files /dev/null and b/uploads/avatars/1771193407_1657955547black-google-icon.png differ diff --git a/uploads/avatars/1771193710_avatar-1771193710406.avif b/uploads/avatars/1771193710_avatar-1771193710406.avif new file mode 100644 index 0000000..5acbbf6 Binary files /dev/null and b/uploads/avatars/1771193710_avatar-1771193710406.avif differ diff --git a/uploads/avatars/1771193960_avatar-1771193960160.avif b/uploads/avatars/1771193960_avatar-1771193960160.avif new file mode 100644 index 0000000..05cc229 Binary files /dev/null and b/uploads/avatars/1771193960_avatar-1771193960160.avif differ diff --git a/uploads/avatars/1771193974_avatar-1771193974126.avif b/uploads/avatars/1771193974_avatar-1771193974126.avif new file mode 100644 index 0000000..1a19b50 Binary files /dev/null and b/uploads/avatars/1771193974_avatar-1771193974126.avif differ diff --git a/uploads/avatars/1771194297_avatar-1771194297610.avif b/uploads/avatars/1771194297_avatar-1771194297610.avif new file mode 100644 index 0000000..a20b6d6 Binary files /dev/null and b/uploads/avatars/1771194297_avatar-1771194297610.avif differ diff --git a/uploads/heroes/1771180434_img-1771180434213-1574.avif b/uploads/heroes/1771180434_img-1771180434213-1574.avif new file mode 100644 index 0000000..2a4998c Binary files /dev/null and b/uploads/heroes/1771180434_img-1771180434213-1574.avif differ diff --git a/uploads/heroes/1771180752_img-1771180752790-8682.avif b/uploads/heroes/1771180752_img-1771180752790-8682.avif new file mode 100644 index 0000000..30a68d6 Binary files /dev/null and b/uploads/heroes/1771180752_img-1771180752790-8682.avif differ diff --git a/uploads/heroes/1771180820_img-1771180820643-8126.avif b/uploads/heroes/1771180820_img-1771180820643-8126.avif new file mode 100644 index 0000000..ad3776a Binary files /dev/null and b/uploads/heroes/1771180820_img-1771180820643-8126.avif differ diff --git a/uploads/heroes/1771180922_img-1771180922370-1617.avif b/uploads/heroes/1771180922_img-1771180922370-1617.avif new file mode 100644 index 0000000..dc42370 Binary files /dev/null and b/uploads/heroes/1771180922_img-1771180922370-1617.avif differ diff --git a/uploads/heroes/1771181547_img-1771181547365-491.avif b/uploads/heroes/1771181547_img-1771181547365-491.avif new file mode 100644 index 0000000..b66d8a2 Binary files /dev/null and b/uploads/heroes/1771181547_img-1771181547365-491.avif differ diff --git a/uploads/heroes/1771181963_img-1771181963369-3463.avif b/uploads/heroes/1771181963_img-1771181963369-3463.avif new file mode 100644 index 0000000..3be7f17 Binary files /dev/null and b/uploads/heroes/1771181963_img-1771181963369-3463.avif differ diff --git a/uploads/heroes/1771182081_img-1771182080929-8743.avif b/uploads/heroes/1771182081_img-1771182080929-8743.avif new file mode 100644 index 0000000..42aff70 Binary files /dev/null and b/uploads/heroes/1771182081_img-1771182080929-8743.avif differ diff --git a/uploads/posts/1771318059022104000_1632286445-en-sqdgame-main-playgrou-5BVA_cover.jpg b/uploads/posts/1771318059022104000_1632286445-en-sqdgame-main-playgrou-5BVA_cover.jpg new file mode 100644 index 0000000..35c7888 Binary files /dev/null and b/uploads/posts/1771318059022104000_1632286445-en-sqdgame-main-playgrou-5BVA_cover.jpg differ diff --git a/uploads/posts/1771318192657846000_1657955547black-google-icon.png b/uploads/posts/1771318192657846000_1657955547black-google-icon.png new file mode 100644 index 0000000..7c1efb4 Binary files /dev/null and b/uploads/posts/1771318192657846000_1657955547black-google-icon.png differ diff --git a/uploads/posts/1771321021089007000_img-1771321020975.avif b/uploads/posts/1771321021089007000_img-1771321020975.avif new file mode 100644 index 0000000..8c5fac4 Binary files /dev/null and b/uploads/posts/1771321021089007000_img-1771321020975.avif differ diff --git a/uploads/posts/1771323398895282000_img-1771323398885.png b/uploads/posts/1771323398895282000_img-1771323398885.png new file mode 100644 index 0000000..aea5015 Binary files /dev/null and b/uploads/posts/1771323398895282000_img-1771323398885.png differ diff --git a/uploads/posts/1771323760968199000_img-1771323760962.png b/uploads/posts/1771323760968199000_img-1771323760962.png new file mode 100644 index 0000000..e2a0373 Binary files /dev/null and b/uploads/posts/1771323760968199000_img-1771323760962.png differ diff --git a/uploads/posts/1771324096734046000_img-1771324096723.jpg b/uploads/posts/1771324096734046000_img-1771324096723.jpg new file mode 100644 index 0000000..e24d6ed Binary files /dev/null and b/uploads/posts/1771324096734046000_img-1771324096723.jpg differ diff --git a/uploads/posts/post_10_19b004.jpg b/uploads/posts/post_10_19b004.jpg new file mode 100644 index 0000000..c2d5f7d Binary files /dev/null and b/uploads/posts/post_10_19b004.jpg differ diff --git a/uploads/posts/post_11_1447b0.jpg b/uploads/posts/post_11_1447b0.jpg new file mode 100644 index 0000000..181b8ae Binary files /dev/null and b/uploads/posts/post_11_1447b0.jpg differ diff --git a/uploads/posts/post_12_ddfaa3.jpg b/uploads/posts/post_12_ddfaa3.jpg new file mode 100644 index 0000000..73c6f58 Binary files /dev/null and b/uploads/posts/post_12_ddfaa3.jpg differ diff --git a/uploads/posts/post_13_b37f04.jpg b/uploads/posts/post_13_b37f04.jpg new file mode 100644 index 0000000..9ed9c57 Binary files /dev/null and b/uploads/posts/post_13_b37f04.jpg differ diff --git a/uploads/posts/post_14_c21ae0.jpg b/uploads/posts/post_14_c21ae0.jpg new file mode 100644 index 0000000..4e7d850 Binary files /dev/null and b/uploads/posts/post_14_c21ae0.jpg differ diff --git a/uploads/posts/post_15_0a1be0.jpg b/uploads/posts/post_15_0a1be0.jpg new file mode 100644 index 0000000..86c174a Binary files /dev/null and b/uploads/posts/post_15_0a1be0.jpg differ diff --git a/uploads/posts/post_16_6a0c58.jpg b/uploads/posts/post_16_6a0c58.jpg new file mode 100644 index 0000000..77fc589 Binary files /dev/null and b/uploads/posts/post_16_6a0c58.jpg differ diff --git a/uploads/posts/post_17_2b30e4.jpg b/uploads/posts/post_17_2b30e4.jpg new file mode 100644 index 0000000..be72bbc Binary files /dev/null and b/uploads/posts/post_17_2b30e4.jpg differ diff --git a/uploads/posts/post_18_ec88ac.jpg b/uploads/posts/post_18_ec88ac.jpg new file mode 100644 index 0000000..8393a62 Binary files /dev/null and b/uploads/posts/post_18_ec88ac.jpg differ diff --git a/uploads/posts/post_19_25715c.jpg b/uploads/posts/post_19_25715c.jpg new file mode 100644 index 0000000..820f63c Binary files /dev/null and b/uploads/posts/post_19_25715c.jpg differ diff --git a/uploads/posts/post_1_75e8c4.jpg b/uploads/posts/post_1_75e8c4.jpg new file mode 100644 index 0000000..8629c4d Binary files /dev/null and b/uploads/posts/post_1_75e8c4.jpg differ diff --git a/uploads/posts/post_20_09d967.jpg b/uploads/posts/post_20_09d967.jpg new file mode 100644 index 0000000..1da8fea Binary files /dev/null and b/uploads/posts/post_20_09d967.jpg differ diff --git a/uploads/posts/post_21_626d54.jpg b/uploads/posts/post_21_626d54.jpg new file mode 100644 index 0000000..632e964 Binary files /dev/null and b/uploads/posts/post_21_626d54.jpg differ diff --git a/uploads/posts/post_22_2aa79a.jpg b/uploads/posts/post_22_2aa79a.jpg new file mode 100644 index 0000000..558bbb7 Binary files /dev/null and b/uploads/posts/post_22_2aa79a.jpg differ diff --git a/uploads/posts/post_23_4ff46d.jpg b/uploads/posts/post_23_4ff46d.jpg new file mode 100644 index 0000000..aed3d97 Binary files /dev/null and b/uploads/posts/post_23_4ff46d.jpg differ diff --git a/uploads/posts/post_24_97be20.jpg b/uploads/posts/post_24_97be20.jpg new file mode 100644 index 0000000..bf49c36 Binary files /dev/null and b/uploads/posts/post_24_97be20.jpg differ diff --git a/uploads/posts/post_25_14eeaf.jpg b/uploads/posts/post_25_14eeaf.jpg new file mode 100644 index 0000000..bdca2de Binary files /dev/null and b/uploads/posts/post_25_14eeaf.jpg differ diff --git a/uploads/posts/post_26_ce05c4.jpg b/uploads/posts/post_26_ce05c4.jpg new file mode 100644 index 0000000..11f7447 Binary files /dev/null and b/uploads/posts/post_26_ce05c4.jpg differ diff --git a/uploads/posts/post_27_c75542.jpg b/uploads/posts/post_27_c75542.jpg new file mode 100644 index 0000000..f81ccc3 Binary files /dev/null and b/uploads/posts/post_27_c75542.jpg differ diff --git a/uploads/posts/post_28_a10a99.jpg b/uploads/posts/post_28_a10a99.jpg new file mode 100644 index 0000000..ff07fcb Binary files /dev/null and b/uploads/posts/post_28_a10a99.jpg differ diff --git a/uploads/posts/post_29_5a1d1f.jpg b/uploads/posts/post_29_5a1d1f.jpg new file mode 100644 index 0000000..9c9f4f7 Binary files /dev/null and b/uploads/posts/post_29_5a1d1f.jpg differ diff --git a/uploads/posts/post_2_a15fd6.jpg b/uploads/posts/post_2_a15fd6.jpg new file mode 100644 index 0000000..a6f5c69 Binary files /dev/null and b/uploads/posts/post_2_a15fd6.jpg differ diff --git a/uploads/posts/post_30_d116c0.jpg b/uploads/posts/post_30_d116c0.jpg new file mode 100644 index 0000000..6bc9f98 Binary files /dev/null and b/uploads/posts/post_30_d116c0.jpg differ diff --git a/uploads/posts/post_31_7a6f61.jpg b/uploads/posts/post_31_7a6f61.jpg new file mode 100644 index 0000000..e0c677e Binary files /dev/null and b/uploads/posts/post_31_7a6f61.jpg differ diff --git a/uploads/posts/post_32_214285.jpg b/uploads/posts/post_32_214285.jpg new file mode 100644 index 0000000..af89e9d Binary files /dev/null and b/uploads/posts/post_32_214285.jpg differ diff --git a/uploads/posts/post_33_640f31.jpg b/uploads/posts/post_33_640f31.jpg new file mode 100644 index 0000000..c2d5f7d Binary files /dev/null and b/uploads/posts/post_33_640f31.jpg differ diff --git a/uploads/posts/post_34_6f7963.jpg b/uploads/posts/post_34_6f7963.jpg new file mode 100644 index 0000000..82a2ac6 Binary files /dev/null and b/uploads/posts/post_34_6f7963.jpg differ diff --git a/uploads/posts/post_35_30ee16.jpg b/uploads/posts/post_35_30ee16.jpg new file mode 100644 index 0000000..dbf8374 Binary files /dev/null and b/uploads/posts/post_35_30ee16.jpg differ diff --git a/uploads/posts/post_36_36c542.jpg b/uploads/posts/post_36_36c542.jpg new file mode 100644 index 0000000..39c65f2 Binary files /dev/null and b/uploads/posts/post_36_36c542.jpg differ diff --git a/uploads/posts/post_37_573af9.jpg b/uploads/posts/post_37_573af9.jpg new file mode 100644 index 0000000..4737693 Binary files /dev/null and b/uploads/posts/post_37_573af9.jpg differ diff --git a/uploads/posts/post_38_4df36e.jpg b/uploads/posts/post_38_4df36e.jpg new file mode 100644 index 0000000..10d3938 Binary files /dev/null and b/uploads/posts/post_38_4df36e.jpg differ diff --git a/uploads/posts/post_39_0e773c.jpg b/uploads/posts/post_39_0e773c.jpg new file mode 100644 index 0000000..50c5772 Binary files /dev/null and b/uploads/posts/post_39_0e773c.jpg differ diff --git a/uploads/posts/post_3_291f5f.jpg b/uploads/posts/post_3_291f5f.jpg new file mode 100644 index 0000000..874d95a Binary files /dev/null and b/uploads/posts/post_3_291f5f.jpg differ diff --git a/uploads/posts/post_4_3d6f3f.jpg b/uploads/posts/post_4_3d6f3f.jpg new file mode 100644 index 0000000..25e5363 Binary files /dev/null and b/uploads/posts/post_4_3d6f3f.jpg differ diff --git a/uploads/posts/post_5_954662.jpg b/uploads/posts/post_5_954662.jpg new file mode 100644 index 0000000..12c0e7c Binary files /dev/null and b/uploads/posts/post_5_954662.jpg differ diff --git a/uploads/posts/post_6_ad3af8.jpg b/uploads/posts/post_6_ad3af8.jpg new file mode 100644 index 0000000..60ee3aa Binary files /dev/null and b/uploads/posts/post_6_ad3af8.jpg differ diff --git a/uploads/posts/post_7_d973fd.jpg b/uploads/posts/post_7_d973fd.jpg new file mode 100644 index 0000000..8308425 Binary files /dev/null and b/uploads/posts/post_7_d973fd.jpg differ diff --git a/uploads/posts/post_8_80cbb5.jpg b/uploads/posts/post_8_80cbb5.jpg new file mode 100644 index 0000000..19ebb06 Binary files /dev/null and b/uploads/posts/post_8_80cbb5.jpg differ diff --git a/uploads/posts/post_9_03c3b8.jpg b/uploads/posts/post_9_03c3b8.jpg new file mode 100644 index 0000000..b8e3cd5 Binary files /dev/null and b/uploads/posts/post_9_03c3b8.jpg differ diff --git a/uploads/settings/b_1771327728_img-1771327728548-3442.avif b/uploads/settings/b_1771327728_img-1771327728548-3442.avif new file mode 100644 index 0000000..32b9251 Binary files /dev/null and b/uploads/settings/b_1771327728_img-1771327728548-3442.avif differ diff --git a/uploads/settings/w_1771327728_img-1771327728388-5953.avif b/uploads/settings/w_1771327728_img-1771327728388-5953.avif new file mode 100644 index 0000000..32b9251 Binary files /dev/null and b/uploads/settings/w_1771327728_img-1771327728388-5953.avif differ diff --git a/views/coming_soon.html b/views/coming_soon.html new file mode 100644 index 0000000..6a18240 --- /dev/null +++ b/views/coming_soon.html @@ -0,0 +1,170 @@ + + + + + + + Çok Yakında - AreS Fiber API + + + + + +
+
+

Çok Yakında

+
+

Harika bir şey hazırlıyoruz. Burası yakında harika özelliklerle dolu yeni anasayfamız olacak.

+

Bizi izlemeye devam edin!

+ + +
+ + + \ No newline at end of file