first commit
This commit is contained in:
58
.air.toml
Normal file
58
.air.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
#:schema https://json.schemastore.org/any.json
|
||||
|
||||
env_files = []
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
entrypoint = ["./tmp/main"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata","public"]
|
||||
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
|
||||
40
.env
Normal file
40
.env
Normal file
@@ -0,0 +1,40 @@
|
||||
### Db Configuration
|
||||
DB_URL="goares:gg7678290@tcp(10.80.80.70:3306)/goares?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=30s&writeTimeout=30s&multiStatements=true"
|
||||
DB_URL_PG=host=10.80.80.70 user=cloud password=gg7678290 dbname=goares port=5432 sslmode=disable TimeZone=Europe/Istanbul
|
||||
##########################
|
||||
# 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=ares-fiber-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
|
||||
################################
|
||||
CORS_DEBUG=true
|
||||
################################
|
||||
# Harici Resim İşleme API
|
||||
#IMAGE_API_URL=https://v2.beyhano.com.tr
|
||||
#IMAGE_API_URL=https://v3.beyhan.gen.tr
|
||||
IMAGE_API_URL=http://localhost:3000
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
SESSION_SECRET=go-fiber-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9
|
||||
CLOUD_FLARE_SITE_KEY='0x4AAAAAACHzHKvlEwMamxCM'
|
||||
CLOUD_FLARE_SECRET='0x4AAAAAACHzHHisTSFzGw15HvwXF3yXRIg'
|
||||
IMAGE_API=https://v2.beyhano.com.tr/api/v1/image/upload
|
||||
IMAGE_USERNAME=goares
|
||||
IMAGE_API_KEY=img_44HbL9V1B9RSADJze8MmY6fgc9Z_-T1P
|
||||
36
.env.copy
Normal file
36
.env.copy
Normal file
@@ -0,0 +1,36 @@
|
||||
### Db Configuration
|
||||
DB_URL="goares:gg7678290@tcp(10.80.80.70:3306)/goares?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=ares-fiber-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
|
||||
################################
|
||||
CORS_DEBUG=true
|
||||
################################
|
||||
# Harici Resim İşleme API
|
||||
IMAGE_API_URL=https://v2.beyhano.com.tr
|
||||
IMAGE_API_EMAIL=beyhan@beyhan.dev
|
||||
IMAGE_API_PASSWORD=1923btO**
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
SESSION_SECRET=go-fiber-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9
|
||||
CLOUD_FLARE_SITE_KEY='0x4AAAAAACHzHKvlEwMamxCM'
|
||||
CLOUD_FLARE_SECRET='0x4AAAAAACHzHHisTSFzGw15HvwXF3yXRIg'
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
### 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
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
tmp
|
||||
ares
|
||||
main
|
||||
.DS_Store
|
||||
.idea
|
||||
9
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXV
Normal file
9
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXV
Normal file
@@ -0,0 +1,9 @@
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjo2LCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwiaXNfYWRtaW4iOnRydWUsImZpcnN0X25hbWUiOiJCZXloYW4iLCJsYXN0X25hbWUiOiJPxJ91ciIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJzdWIiOiI2IiwiZXhwIjoxNzcxNzAyMTU2LCJpYXQiOjE3NzE2OTQ5NTZ9.QHid2xqKsdwe1E-vkrZLA7nB_qL3DEcEWztbkFoOaZU
|
||||
|
||||
|
||||
|
||||
|
||||
a1a4e309bb7b1ea86c5c046a22a5d5e4f7ec727a
|
||||
|
||||
|
||||
81f4318fe76b595c582aa9f2baf26818894303d0
|
||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Builder: pure Go, no CGO needed (external image API)
|
||||
FROM golang:1.26.0-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy the source code
|
||||
COPY . .
|
||||
|
||||
# Build the application (CGO not required)
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
|
||||
|
||||
# --- Final Stage ---
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary and assets from the builder stage
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/views ./views
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads && chown -R 1000:1000 /app/uploads
|
||||
|
||||
# Add entrypoint and make executable
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create a non-root user for running the app
|
||||
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -s /bin/sh -m appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
USER appuser
|
||||
CMD ["./main"]
|
||||
96
belgeler/admin_panel.md
Normal file
96
belgeler/admin_panel.md
Normal file
@@ -0,0 +1,96 @@
|
||||
You are an AI developer assistant. Your task is to generate a complete admin panel scaffold (HTML templates, static assets and server-side route stubs) for a Go Fiber project. Use only the following frontend libraries already available in the project dependencies: Alpine.js, Bootstrap 5, HTMX, jQuery. Do not add other frameworks. Produce code artifacts described in the "Outputs" section below.
|
||||
|
||||
Constraints & requirements (önemli)
|
||||
- Kullanılan frontend kütüphaneleri: Alpine.js (v3), HTMX, Bootstrap 5, jQuery — bunları proje /public üzerinden kullan.
|
||||
- Tema: Koyu ve aydın (dark/light) destekli olacak. Kullanıcı temayı değiştirdiğinde tercih localStorage'da saklansın ve sayfa yenilendiğinde korunmalı.
|
||||
- Layout: Sol sabit (desktop) sidebar; mobilde off-canvas veya slide-in davranışı. Üstte sticky header (navbar) — hep görünür. Ana içerik HTMX ile parçalar halinde yüklensin (partials/fragments).
|
||||
- Login sayfası: Cloudflare Turnstile ile korumalı. Turnstile için sitekey ve secret key placeholder kullanılacak (örneğin: YOUR_TURNSTILE_SITEKEY, YOUR_TURNSTILE_SECRET). Login formu HTMX ile POST gönderecek, sunucu Turnstile doğrulaması yapacak.
|
||||
- Accessibility: ARIA nitelikleri eklensin, klavye navigasyonu çalışsın, renk kontrastı erişilebilir seviyede olsun.
|
||||
- Responsive: Desktop / tablet / mobile uyumlu olmalı.
|
||||
- Minimal JS: Alpine.js ile UI state yönetimi (tema toggle, mobil sidebar), HTMX ile partial yükleme, küçük helper JS dosyası için jQuery veya vanilla kullanılabilir. Büyük bundler veya build pipeline gerektirmeyecek.
|
||||
- Security: CSRF, XSS ve session güvenliği için sunucu tarafı notları verilecek (CSRF token uygulaması önerisi, Turnstile server-side doğrulama, secure cookie flags, rate limiting).
|
||||
- Dosya/dizin yapısı: templates/ (HTML şablonları), public/css, public/js, public/assets. Server tarafı Go Fiber rotaları ve basit handler açıklamaları verilecek (kod değil; hangi endpoint ne döner).
|
||||
|
||||
Görsel / UX istekleri
|
||||
- Tema değişimi için hem simge hem metin olacak (ör. güneş/ay). Geçiş animasyonu küçük (0.2s - 0.35s).
|
||||
- Sidebar: ikon + etiket, aktif öğe vurgulu, mümkünse collapse/expand (yalnızca genişlik daraltıldığında).
|
||||
- Header: sol marka, ortada opsiyonel breadcrumb (isteğe bağlı), sağda tema toggle ve kullanıcı dropdown.
|
||||
- İçerik alanı: kart bazlı, boşluklar (padding/margin) dengeli.
|
||||
- Animasyonlar: HTMX swap sırasında fade veya slide geçişi (kısa).
|
||||
- Renk paleti: net iki palet tanımı (aydın için açık nötr arka plan + mavi aksan; koyu için koyu mavi/şeftali aksan). Renkler CSS custom properties (CSS değişkenleri) ile tanımlansın.
|
||||
|
||||
Girdi (input)
|
||||
- ./public Alpine.js, Bootstrap, HTMX, jQuery (mevcut).
|
||||
- Kullanıcı sağlayacak: Cloudflare Turnstile sitekey/secret (placeholder bırakılacak).
|
||||
- Sunucu: Go Fiber (kullanıcı Fiber projesine kolayca entegre edebilsin).
|
||||
|
||||
Outputs — AI'nin üretmesi gerekenler (tam liste)
|
||||
1. Temel layout şablonu (templates/layout.html veya .tmpl):
|
||||
- ./public/assets (Bootstrap CSS/JS, HTMX, Alpine, jQuery, Turnstile script).
|
||||
- Body: sticky header, sol sidebar, main content container (#main-content) — HTMX hedefi.
|
||||
- Tema yönetimi için Alpine veri nesnesi referansları (tema toggle, mobile sidebar kontrol).
|
||||
- Ana layout'ta HTMX ile fragment yüklemeye uygun linkler (hx-get, hx-target="#main-content", hx-swap).
|
||||
|
||||
2. Login şablonu (templates/login.html):
|
||||
- Cloudflare Turnstile widget (data-sitekey placeholder).
|
||||
- Form HTMX ile POST atacak (hx-post="/login", hx-target="#login-feedback" vb.).
|
||||
- Başarı/hata fragment'leri için sunucu tarafı cevap formatı belirtilecek.
|
||||
|
||||
3. Örnek HTMX fragment'leri (templates/fragments/*):
|
||||
- dashboard fragment
|
||||
- users list fragment
|
||||
- settings fragment
|
||||
(her fragment minimal içerik, HTMX swap ile çalışacak biçimde)
|
||||
|
||||
4. public/js/main.js (küçük açıklama, içerik oluşturulacak):
|
||||
- Alpine tema manager yapısı (isDark reactive, save/load localStorage, toggle fonksiyonları).
|
||||
- Mobile sidebar toggle fonksiyonları.
|
||||
- HTMX üzerinden fragment yüklenirken küçük loading indicator veya class ekleme.
|
||||
- Turnstile ile integration notu (widget render ve token gönderimi; token'ın login formu ile nasıl dahil olacağı).
|
||||
|
||||
5. public/css/theme.css:
|
||||
- CSS custom properties hem light hem dark için.
|
||||
- Sidebar, header, card stilleri, responsive medya sorguları.
|
||||
- Küçük animasyonlar (fade, transition).
|
||||
|
||||
6. Sunucu tarafı: Go Fiber rotaları listesi ve davranış açıklamaları (kod değil, endpoint + beklenen request/response):
|
||||
- GET /login -> login şablonunu döndür
|
||||
- POST /login -> Turnstile token doğrulaması + credentials doğrulama; başarılıysa session cookie oluştur ve /admin yönlendir (HTMX için fragment veya redirect)
|
||||
- GET /admin -> layout render (veya layout + default fragment)
|
||||
- GET /admin/content/dashboard -> dashboard fragment (HTMX hedefi)
|
||||
- GET /admin/content/users -> users fragment
|
||||
- GET /admin/content/settings -> settings fragment
|
||||
- GET /logout -> oturumu sonlandır
|
||||
- admin middleware ye agit tum rotalar
|
||||
- (Opsiyonel) POST /api/users/* gibi API uç noktaları (JSON) — HTMX yerine AJAX gerekeceğinde kullanılacak
|
||||
|
||||
7. Güvenlik & operasyonel notlar (dokümantasyon/metin):
|
||||
- Turnstile: server-side doğrulama nasıl yapılır (istek: secret ve token ile Cloudflare API endpoint çağrısı; doğrulama şartı).
|
||||
- CSRF: HTMX formlarında CSRF token nasıl geçilir (hidden input veya header); Fiber için örnek header adı ve cookie ilişkisi (kod değil açıklama).
|
||||
- Session yönetimi: secure, httpOnly, SameSite=strict/ Lax önerisi.
|
||||
- Rate limiting önerisi (ör. login için).
|
||||
- Input validation and output escaping reminder.
|
||||
|
||||
8. Acceptance criteria / test senaryoları:
|
||||
- Tema toggle yapıldığında localStorage güncellenmeli ve sayfa yenilendiğinde tercih korunmalı.
|
||||
- Sidebar linkine tıklandığında HTMX ile fragment yüklensin, header sabit kalsın.
|
||||
- Mobilde sidebar toggle düzgün çalışsın ve overlay kapanışı sağlansın.
|
||||
- Login formu Turnstile widget üretiyor; form gönderildiğinde Sunucu Turnstile token'ı doğruluyor — geçerse oturum açma gerçekleşiyor, geçmezse hata mesajı.
|
||||
- Erişilemeyen bir fragment istenirse HTMX hedefinde uygun hata gösterimi (alert/flash) gelsin.
|
||||
- Temel a11y kontrolü: tüm interaktif öğeler klavye ile erişilebilir olsun, img/ikonlar için alt metin/aria-label.
|
||||
|
||||
Ek istekler / tercih edilen çıktı biçimi
|
||||
- Üretilen dosyaları dosya isimleriyle ve kısa açıklamalarıyla listelerken KOD VERMEYİN — sadece hangi dosyayı ve ne içermesi gerektiğini net maddelemeyle verin (ama AI kod da üretirse ayrı bir adımda bunu isteyebilirim).
|
||||
- Turnstile sitekey/secret için placeholder kullanın ve kullanıcıya nerede değiştireceğini açıkça belirtin.
|
||||
- HTMX fragment response biçimi: tam HTML fragment (sadece main-content içeriği) veya JSON+HTML toggles; tercihen saf HTML fragment kullanılsın.
|
||||
- Her dosya için kısa "örnek içerik açıklaması" verin (ör. dashboard fragment: 3 adet statistic card ve bir tablo).
|
||||
|
||||
Çıktı teslim formatı
|
||||
- Lütfen üretim çıktısını şu şekilde organize et:
|
||||
- "Files to create" listesi (path + kısa açıklama)
|
||||
- "Server endpoints" listesi (method + path + beklenen davranış)
|
||||
- "Güvenlik & deployment notları"
|
||||
- "Acceptance tests"
|
||||
- Tekrar ediyorum: BU PROMPT sadece kılavuzdur — şu an yalnızca prompt dosyasını istiyorum, KOD YOK. Eğer kod oluşturmaya hazırsan, ayrı bir istekte bulunacağım.
|
||||
|
||||
Not: Kullanıcı adı ve mevcut package.json bağımlılıklarını unutma: Alpine.js, Bootstrap 5, htmx, jquery. Cloudflare Turnstile sitekey/secret işaretlerini placeholder bırak.
|
||||
50
build_windows.sh
Executable file
50
build_windows.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Renk tanımlamaları
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # Renk Yok
|
||||
|
||||
echo -e "${BLUE}=== Go Windows Build Script ===${NC}"
|
||||
|
||||
# Çıktı klasörünü oluştur
|
||||
OUTPUT_DIR="builds"
|
||||
mkdir -p $OUTPUT_DIR
|
||||
|
||||
# Ana dosya adı (main.go varsayılır, yoksa ilk argümanı al)
|
||||
ENTRY_FILE=${1:-"main.go"}
|
||||
APP_NAME=$(basename $(pwd))
|
||||
|
||||
if [ ! -f "$ENTRY_FILE" ]; then
|
||||
echo -e "${RED}Hata: $ENTRY_FILE bulunamadı!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "Derleniyor: ${GREEN}$ENTRY_FILE${NC}"
|
||||
|
||||
# Mimari seçimi (Varsayılan amd64)
|
||||
read -p "Mimari seçin (1: amd64 [Varsayılan], 2: 386): " ARCH_CHOICE
|
||||
|
||||
if [[ "$ARCH_CHOICE" == "2" ]]; then
|
||||
export GOARCH=386
|
||||
FINAL_NAME="${APP_NAME}_x86.exe"
|
||||
else
|
||||
export GOARCH=amd64
|
||||
FINAL_NAME="${APP_NAME}_x64.exe"
|
||||
fi
|
||||
|
||||
# Derleme işlemi
|
||||
# -s -w bayrakları dosya boyutunu ciddi oranda küçültür
|
||||
echo -e "${BLUE}Windows için derleniyor...${NC}"
|
||||
|
||||
env GOOS=windows CGO_ENABLED=0 \
|
||||
go build -ldflags="-s -w" \
|
||||
-o "$OUTPUT_DIR/$FINAL_NAME" "$ENTRY_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}Başarılı!${NC}"
|
||||
echo -e "Çıktı: ${BLUE}$OUTPUT_DIR/$FINAL_NAME${NC}"
|
||||
else
|
||||
echo -e "${RED}Derleme sırasında bir hata oluştu.${NC}"
|
||||
fi
|
||||
127
config/config.go
Normal file
127
config/config.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package configs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Env string // örn. development, production
|
||||
Port string
|
||||
DBUrl string
|
||||
DBPGUrl string
|
||||
JWTSecret string
|
||||
AppURL string // örn. https://api.example.com - e-posta doğrulama linkleri için kullanılır
|
||||
ImageAPIURL string // Harici resim işleme servisi
|
||||
ImageAPIKey string
|
||||
ImageAPIEmail string
|
||||
ImageAPIPassword string
|
||||
GoogleClientID string
|
||||
GoogleClientSecret string
|
||||
GithubClientID string
|
||||
GithubClientSecret string
|
||||
GoogleRedirectURL string
|
||||
GithubRedirectURL string
|
||||
ClientCallbackURL string
|
||||
OAuthRedirectURL string
|
||||
RedisUrl string
|
||||
AccessTokenExpireMinutes int
|
||||
RefreshTokenExpireDays int
|
||||
// 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 {
|
||||
// Logger henüz hazır olmayabilir; varsa kullan
|
||||
if Logger != nil {
|
||||
Logger.Warn("Uyarı: .env dosyası yüklenirken hata oluştu, sistem ortam değişkenleriyle devam ediliyor", zapFieldsForEnvError(err)...)
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig = &Config{
|
||||
Env: getEnv("APP_ENV", "development"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DBUrl: getEnv("DB_URL", ""),
|
||||
DBPGUrl: getEnv("DB_URL_PG", ""),
|
||||
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
|
||||
|
||||
// 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),
|
||||
ImageAPIURL: getEnv("IMAGE_API_URL", "https://v2.beyhano.com.tr"),
|
||||
ImageAPIKey: getEnv("IMAGE_API_KEY", ""),
|
||||
ImageAPIEmail: getEnv("IMAGE_API_EMAIL", ""),
|
||||
ImageAPIPassword: getEnv("IMAGE_API_PASSWORD", ""),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func zapFieldsForEnvError(err error) []zap.Field {
|
||||
return []zap.Field{
|
||||
zap.String("error", err.Error()),
|
||||
}
|
||||
}
|
||||
39
config/loger.go
Normal file
39
config/loger.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package configs
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var Logger *zap.Logger
|
||||
|
||||
func init() {
|
||||
// initialize default logger early so other packages can use it in their init functions
|
||||
LogerAres()
|
||||
}
|
||||
|
||||
func LogerAres() {
|
||||
logFile, err := os.Create("./info.log")
|
||||
if err != nil {
|
||||
// Fallback: sadece konsola yaz
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
|
||||
zapcore.AddSync(os.Stdout),
|
||||
zapcore.DebugLevel,
|
||||
)
|
||||
Logger = zap.New(core, zap.AddCaller())
|
||||
Logger.Warn("info.log açılamadı, sadece konsola yazılıyor", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Hem dosyaya hem konsola yaz (logları görebilirsin)
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
encoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
multiOut := zapcore.NewMultiWriteSyncer(zapcore.AddSync(os.Stdout), zapcore.AddSync(logFile))
|
||||
core := zapcore.NewCore(encoder, multiOut, zapcore.DebugLevel)
|
||||
Logger = zap.New(core, zap.AddCaller())
|
||||
Logger.Info("Logger başlatıldı (konsol + info.log)")
|
||||
}
|
||||
52
controllers/admin_cart_controller.go
Normal file
52
controllers/admin_cart_controller.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
dbConfig "ares/database/config"
|
||||
"ares/database/models"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AdminContentCarts handles rendering the Carts list in the admin panel
|
||||
func AdminContentCarts(c fiber.Ctx) error {
|
||||
var carts []models.Cart
|
||||
|
||||
// Preload the User to display who owns the cart
|
||||
// Preload Items to show the item count
|
||||
query := dbConfig.DB.Model(&models.Cart{}).Preload("Items")
|
||||
|
||||
// Filter by User ID if a search is provided (basic example, finding user by ID)
|
||||
search := c.Query("search")
|
||||
if search != "" {
|
||||
if userID, err := strconv.Atoi(search); err == nil {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
}
|
||||
|
||||
query.Order("updated_at desc").Find(&carts)
|
||||
|
||||
data := fiber.Map{
|
||||
"Carts": carts,
|
||||
"Search": search,
|
||||
}
|
||||
|
||||
// Render partial if requested via HTMX
|
||||
if c.Get("HX-Request") == "true" {
|
||||
return c.Render("admin/partials/carts", data)
|
||||
}
|
||||
|
||||
// Otherwise render full layout
|
||||
return c.Render("admin/partials/carts", data, "admin/layout")
|
||||
}
|
||||
|
||||
// AdminCartDelete handles deleting a cart (useful for clearing abandoned carts)
|
||||
func AdminCartDelete(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if err := dbConfig.DB.Unscoped().Where("id = ?", id).Delete(&models.Cart{}).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/carts?error=Silme+başarısız")
|
||||
}
|
||||
|
||||
return c.Redirect().To("/admin/content/carts?deleted=true&success=Sepet+silindi")
|
||||
}
|
||||
1735
controllers/admin_controller.go
Normal file
1735
controllers/admin_controller.go
Normal file
File diff suppressed because it is too large
Load Diff
619
controllers/admin_product_controller.go
Normal file
619
controllers/admin_product_controller.go
Normal file
@@ -0,0 +1,619 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
dbConfig "ares/database/config"
|
||||
"ares/database/models"
|
||||
utils "ares/pkg/utis"
|
||||
"ares/services"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AdminContentProducts renders the products partial
|
||||
func AdminContentProducts(c fiber.Ctx) error {
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
search := c.Query("search", "")
|
||||
showDeleted := c.Query("deleted") == "true"
|
||||
|
||||
var products []models.Product
|
||||
var total int64
|
||||
|
||||
query := dbConfig.DB.Model(&models.Product{})
|
||||
if showDeleted {
|
||||
query = query.Unscoped().Where("deleted_at IS NOT NULL")
|
||||
}
|
||||
if search != "" {
|
||||
query = query.Where("title LIKE ? OR slug LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
query.Count(&total)
|
||||
query.Preload("Categories").Preload("Tags").Order("created_at desc").Limit(limit).Offset(offset).Find(&products)
|
||||
|
||||
imageMap := make(map[uint]string)
|
||||
for _, p := range products {
|
||||
if p.Images != "" {
|
||||
imgs := parseImagesField(p.Images)
|
||||
if len(imgs) > 0 {
|
||||
imageMap[p.ID] = imgs[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalPages := int(math.Ceil(float64(total) / float64(limit)))
|
||||
|
||||
data := fiber.Map{
|
||||
"Products": products,
|
||||
"ImageMap": imageMap,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"NextPage": page + 1,
|
||||
"PrevPage": page - 1,
|
||||
"Search": search,
|
||||
"ShowDeleted": showDeleted,
|
||||
}
|
||||
|
||||
if c.Get("HX-Request") == "true" {
|
||||
return c.Render("admin/partials/products", data)
|
||||
}
|
||||
return c.Render("admin/partials/products", data, "admin/layout")
|
||||
}
|
||||
|
||||
// AdminProductNew renders create form
|
||||
func AdminProductNew(c fiber.Ctx) error {
|
||||
var cats []models.ProductCategory
|
||||
var tags []models.ProductTag
|
||||
dbConfig.DB.Order("title asc").Find(&cats)
|
||||
dbConfig.DB.Order("name asc").Find(&tags)
|
||||
return c.Render("admin/pages/product_form", fiber.Map{"IsEdit": false, "Categories": cats, "Tags": tags, "FirstImage": ""}, "admin/layout")
|
||||
}
|
||||
|
||||
// AdminProductCreate handles creation
|
||||
func AdminProductCreate(c fiber.Ctx) error {
|
||||
title := c.FormValue("title")
|
||||
if title == "" {
|
||||
return c.Redirect().To("/admin/content/products?error=Başlık+gerekli")
|
||||
}
|
||||
product := models.Product{Title: title}
|
||||
product.Content = c.FormValue("content")
|
||||
|
||||
// Slug handling
|
||||
rawSlug := c.FormValue("slug")
|
||||
if rawSlug == "" {
|
||||
rawSlug = utils.Slugify(product.Title)
|
||||
} else {
|
||||
rawSlug = utils.Slugify(rawSlug)
|
||||
}
|
||||
attempt := rawSlug
|
||||
i := 1
|
||||
for {
|
||||
var existing models.Product
|
||||
err := dbConfig.DB.Unscoped().Where("slug = ?", attempt).First(&existing).Error
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
attempt = rawSlug + "-" + strconv.Itoa(i)
|
||||
i++
|
||||
}
|
||||
product.Slug = attempt
|
||||
|
||||
// Image Upload
|
||||
priceStr := c.FormValue("price")
|
||||
price, _ := strconv.ParseFloat(priceStr, 64)
|
||||
width, _ := strconv.Atoi(c.FormValue("width"))
|
||||
height, _ := strconv.Atoi(c.FormValue("height"))
|
||||
quality, _ := strconv.Atoi(c.FormValue("quality"))
|
||||
format := c.FormValue("format")
|
||||
if format == "" {
|
||||
format = "avif"
|
||||
}
|
||||
// DB fields
|
||||
product.Price = price
|
||||
product.Format = format
|
||||
product.Width = width
|
||||
product.Height = height
|
||||
product.Quality = quality
|
||||
|
||||
// Use processAndSaveImage for main product image (for simplicity we store 1 image mapped as JSON array or just single string per parseImagesField)
|
||||
imagePath, err := services.ProcessAndSaveImage(c, "image", services.ImageOptions{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Quality: quality,
|
||||
Format: format,
|
||||
Folder: "products",
|
||||
})
|
||||
if err == nil && imagePath != "" {
|
||||
// Tek resim olarak string kaydet
|
||||
product.Images = imagePath
|
||||
}
|
||||
|
||||
if err := dbConfig.DB.Create(&product).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/products?error=Oluşturma+başarısız")
|
||||
}
|
||||
|
||||
// Handline Relations (Categories & Tags)
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err == nil && form != nil {
|
||||
catIDs := form.Value["categories"]
|
||||
for _, catIDStr := range catIDs {
|
||||
catID, _ := strconv.Atoi(catIDStr)
|
||||
if catID > 0 {
|
||||
var cat models.ProductCategory
|
||||
if err := dbConfig.DB.First(&cat, catID).Error; err == nil {
|
||||
dbConfig.DB.Model(&product).Association("Categories").Append(&cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tagIDs := form.Value["tags"]
|
||||
for _, tagIDStr := range tagIDs {
|
||||
tagID, _ := strconv.Atoi(tagIDStr)
|
||||
if tagID > 0 {
|
||||
var tag models.ProductTag
|
||||
if err := dbConfig.DB.First(&tag, tagID).Error; err == nil {
|
||||
dbConfig.DB.Model(&product).Association("Tags").Append(&tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if form data is not multipart, try basic FormValue array
|
||||
// Though Fiber usually parses multiple values when we get them explicitly or we can just parse the body
|
||||
}
|
||||
|
||||
return c.Redirect().To("/admin/content/products?success=Ürün+oluşturuldu")
|
||||
}
|
||||
|
||||
// AdminProductEdit renders the edit hero form
|
||||
func AdminProductEdit(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var product models.Product
|
||||
if err := dbConfig.DB.Preload("Categories").Preload("Tags").First(&product, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).SendString("Ürün bulunamadı")
|
||||
}
|
||||
|
||||
var cats []models.ProductCategory
|
||||
var tags []models.ProductTag
|
||||
dbConfig.DB.Order("title asc").Find(&cats)
|
||||
dbConfig.DB.Order("name asc").Find(&tags)
|
||||
|
||||
firstImage := ""
|
||||
if product.Images != "" {
|
||||
imgs := parseImagesField(product.Images)
|
||||
if len(imgs) > 0 {
|
||||
firstImage = imgs[0]
|
||||
}
|
||||
}
|
||||
|
||||
return c.Render("admin/pages/product_form", fiber.Map{
|
||||
"IsEdit": true,
|
||||
"Product": product,
|
||||
"Categories": cats,
|
||||
"Tags": tags,
|
||||
"FirstImage": firstImage,
|
||||
}, "admin/layout")
|
||||
}
|
||||
|
||||
// AdminProductUpdate handles product update
|
||||
func AdminProductUpdate(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var product models.Product
|
||||
if err := dbConfig.DB.First(&product, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/products?error=Ürün+bulunamadı")
|
||||
}
|
||||
|
||||
product.Title = c.FormValue("title")
|
||||
product.Content = c.FormValue("content")
|
||||
|
||||
// Slug update
|
||||
rawSlug := c.FormValue("slug")
|
||||
if rawSlug != "" && rawSlug != product.Slug {
|
||||
rawSlug = utils.Slugify(rawSlug)
|
||||
attempt := rawSlug
|
||||
i := 1
|
||||
for {
|
||||
var existing models.Product
|
||||
err := dbConfig.DB.Unscoped().Where("slug = ? AND id != ?", attempt, product.ID).First(&existing).Error
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
attempt = rawSlug + "-" + strconv.Itoa(i)
|
||||
i++
|
||||
}
|
||||
product.Slug = attempt
|
||||
}
|
||||
|
||||
// Image Upload
|
||||
priceStr := c.FormValue("price")
|
||||
price, _ := strconv.ParseFloat(priceStr, 64)
|
||||
width, _ := strconv.Atoi(c.FormValue("width"))
|
||||
height, _ := strconv.Atoi(c.FormValue("height"))
|
||||
quality, _ := strconv.Atoi(c.FormValue("quality"))
|
||||
format := c.FormValue("format")
|
||||
if format == "" {
|
||||
format = "avif"
|
||||
}
|
||||
// DB fields
|
||||
product.Price = price
|
||||
product.Format = format
|
||||
product.Width = width
|
||||
product.Height = height
|
||||
product.Quality = quality
|
||||
|
||||
imagePath, err := services.ProcessAndSaveImage(c, "image", services.ImageOptions{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Quality: quality,
|
||||
Format: format,
|
||||
Folder: "products",
|
||||
})
|
||||
if err == nil && imagePath != "" {
|
||||
product.Images = imagePath
|
||||
}
|
||||
|
||||
if err := dbConfig.DB.Save(&product).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/products?error=Güncelleme+başarısız")
|
||||
}
|
||||
|
||||
// Handline Relations (Categories & Tags)
|
||||
dbConfig.DB.Model(&product).Association("Categories").Clear()
|
||||
dbConfig.DB.Model(&product).Association("Tags").Clear()
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err == nil && form != nil {
|
||||
catIDs := form.Value["categories"]
|
||||
for _, catIDStr := range catIDs {
|
||||
catID, _ := strconv.Atoi(catIDStr)
|
||||
if catID > 0 {
|
||||
var cat models.ProductCategory
|
||||
if err := dbConfig.DB.First(&cat, catID).Error; err == nil {
|
||||
dbConfig.DB.Model(&product).Association("Categories").Append(&cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tagIDs := form.Value["tags"]
|
||||
for _, tagIDStr := range tagIDs {
|
||||
tagID, _ := strconv.Atoi(tagIDStr)
|
||||
if tagID > 0 {
|
||||
var tag models.ProductTag
|
||||
if err := dbConfig.DB.First(&tag, tagID).Error; err == nil {
|
||||
dbConfig.DB.Model(&product).Association("Tags").Append(&tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Redirect().To("/admin/content/products?success=Ürün+güncellendi")
|
||||
}
|
||||
|
||||
// AdminProductDelete handles soft delete
|
||||
func AdminProductDelete(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Delete(&models.Product{}, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/products?error=Silme+hatası")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/products?success=Ürün+silindi")
|
||||
}
|
||||
|
||||
// AdminProductRestore handles restore
|
||||
func AdminProductRestore(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Unscoped().Model(&models.Product{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/products?error=Geri+yükleme+hatası")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/products?deleted=true&success=Ürün+geri+yüklendi")
|
||||
}
|
||||
|
||||
// --- ProductCategory Management ---
|
||||
|
||||
func AdminContentProductCategories(c fiber.Ctx) error {
|
||||
var categories []models.ProductCategory
|
||||
showDeleted := c.Query("deleted") == "true"
|
||||
query := dbConfig.DB.Model(&models.ProductCategory{})
|
||||
if showDeleted {
|
||||
query = query.Unscoped().Where("deleted_at IS NOT NULL")
|
||||
}
|
||||
query.Preload("Parent").Order("title asc").Find(&categories)
|
||||
|
||||
data := fiber.Map{
|
||||
"Categories": categories,
|
||||
"ShowDeleted": showDeleted,
|
||||
}
|
||||
|
||||
if c.Get("HX-Request") == "true" {
|
||||
return c.Render("admin/partials/product_categories", data)
|
||||
}
|
||||
return c.Render("admin/partials/product_categories", data, "admin/layout")
|
||||
}
|
||||
|
||||
func AdminProductCategoryNew(c fiber.Ctx) error {
|
||||
var parents []models.ProductCategory
|
||||
dbConfig.DB.Order("title asc").Find(&parents)
|
||||
return c.Render("admin/pages/product_category_form", fiber.Map{
|
||||
"IsEdit": false,
|
||||
"Parents": parents,
|
||||
}, "admin/layout")
|
||||
}
|
||||
|
||||
func AdminProductCategoryCreate(c fiber.Ctx) error {
|
||||
cat := new(models.ProductCategory)
|
||||
if err := c.Bind().Body(cat); err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Geçersiz+istek")
|
||||
}
|
||||
|
||||
if cat.Title == "" {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Başlık+zorunlu")
|
||||
}
|
||||
|
||||
rawSlug := c.FormValue("slug")
|
||||
if rawSlug == "" {
|
||||
rawSlug = utils.Slugify(cat.Title)
|
||||
} else {
|
||||
rawSlug = utils.Slugify(rawSlug)
|
||||
}
|
||||
|
||||
attempt := rawSlug
|
||||
i := 1
|
||||
for {
|
||||
var existing models.ProductCategory
|
||||
err := dbConfig.DB.Unscoped().Where("slug = ?", attempt).First(&existing).Error
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
attempt = rawSlug + "-" + strconv.Itoa(i)
|
||||
i++
|
||||
}
|
||||
cat.Slug = attempt
|
||||
|
||||
// Handle ParentID
|
||||
parentIDStr := c.FormValue("parent_id")
|
||||
if parentIDStr != "" {
|
||||
if pid, err := strconv.ParseUint(parentIDStr, 10, 32); err == nil && pid > 0 {
|
||||
pidUint := uint(pid)
|
||||
cat.ParentID = &pidUint
|
||||
}
|
||||
} else {
|
||||
cat.ParentID = nil
|
||||
}
|
||||
|
||||
if err := dbConfig.DB.Create(cat).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Oluşturma+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-categories?success=Kategori+oluşturuldu")
|
||||
}
|
||||
|
||||
func AdminProductCategoryEdit(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var cat models.ProductCategory
|
||||
if err := dbConfig.DB.First(&cat, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Kategori+bulunamadı")
|
||||
}
|
||||
|
||||
var parents []models.ProductCategory
|
||||
dbConfig.DB.Where("id != ?", id).Order("title asc").Find(&parents)
|
||||
|
||||
return c.Render("admin/pages/product_category_form", fiber.Map{
|
||||
"IsEdit": true,
|
||||
"Category": cat,
|
||||
"Parents": parents,
|
||||
}, "admin/layout")
|
||||
}
|
||||
|
||||
func AdminProductCategoryUpdate(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var cat models.ProductCategory
|
||||
if err := dbConfig.DB.First(&cat, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Kategori+bulunamadı")
|
||||
}
|
||||
|
||||
cat.Title = c.FormValue("title")
|
||||
cat.Description = c.FormValue("description")
|
||||
cat.Keywords = c.FormValue("keywords")
|
||||
|
||||
rawSlug := c.FormValue("slug")
|
||||
if rawSlug != "" && rawSlug != cat.Slug {
|
||||
rawSlug = utils.Slugify(rawSlug)
|
||||
attempt := rawSlug
|
||||
i := 1
|
||||
for {
|
||||
var existing models.ProductCategory
|
||||
err := dbConfig.DB.Unscoped().Where("slug = ? AND id != ?", attempt, cat.ID).First(&existing).Error
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
attempt = rawSlug + "-" + strconv.Itoa(i)
|
||||
i++
|
||||
}
|
||||
cat.Slug = attempt
|
||||
}
|
||||
|
||||
parentIDStr := c.FormValue("parent_id")
|
||||
if parentIDStr != "" {
|
||||
if pid, err := strconv.ParseUint(parentIDStr, 10, 32); err == nil && pid > 0 {
|
||||
pidUint := uint(pid)
|
||||
if pidUint != cat.ID {
|
||||
cat.ParentID = &pidUint
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cat.ParentID = nil
|
||||
}
|
||||
|
||||
if err := dbConfig.DB.Save(&cat).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Güncelleme+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-categories?success=Kategori+güncellendi")
|
||||
}
|
||||
|
||||
func AdminProductCategoryDelete(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Delete(&models.ProductCategory{}, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Silme+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-categories?success=Kategori+silindi")
|
||||
}
|
||||
|
||||
func AdminProductCategoryRestore(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Unscoped().Model(&models.ProductCategory{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-categories?error=Geri+yükleme+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-categories?deleted=true&success=Kategori+geri+yüklendi")
|
||||
}
|
||||
|
||||
// --- ProductTag Management ---
|
||||
|
||||
func AdminContentProductTags(c fiber.Ctx) error {
|
||||
var tags []models.ProductTag
|
||||
showDeleted := c.Query("deleted") == "true"
|
||||
query := dbConfig.DB.Model(&models.ProductTag{})
|
||||
if showDeleted {
|
||||
query = query.Unscoped().Where("deleted_at IS NOT NULL")
|
||||
}
|
||||
query.Order("name asc").Find(&tags)
|
||||
|
||||
data := fiber.Map{
|
||||
"Tags": tags,
|
||||
"ShowDeleted": showDeleted,
|
||||
}
|
||||
|
||||
if c.Get("HX-Request") == "true" {
|
||||
return c.Render("admin/partials/product_tags", data)
|
||||
}
|
||||
return c.Render("admin/partials/product_tags", data, "admin/layout")
|
||||
}
|
||||
|
||||
func AdminProductTagNew(c fiber.Ctx) error {
|
||||
return c.Render("admin/pages/product_tag_form", fiber.Map{
|
||||
"IsEdit": false,
|
||||
}, "admin/layout")
|
||||
}
|
||||
|
||||
func AdminProductTagCreate(c fiber.Ctx) error {
|
||||
tag := new(models.ProductTag)
|
||||
if err := c.Bind().Body(tag); err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Geçersiz+istek")
|
||||
}
|
||||
if tag.Name == "" {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=İsim+zorunlu")
|
||||
}
|
||||
if err := dbConfig.DB.Create(tag).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Oluşturma+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-tags?success=Etiket+oluşturuldu")
|
||||
}
|
||||
|
||||
func AdminProductTagEdit(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var tag models.ProductTag
|
||||
if err := dbConfig.DB.First(&tag, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Etiket+bulunamadı")
|
||||
}
|
||||
return c.Render("admin/pages/product_tag_form", fiber.Map{
|
||||
"IsEdit": true,
|
||||
"Tag": tag,
|
||||
}, "admin/layout")
|
||||
}
|
||||
|
||||
func AdminProductTagUpdate(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var tag models.ProductTag
|
||||
if err := dbConfig.DB.First(&tag, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Etiket+bulunamadı")
|
||||
}
|
||||
|
||||
tag.Name = c.FormValue("name")
|
||||
|
||||
if err := dbConfig.DB.Save(&tag).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Güncelleme+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-tags?success=Etiket+güncellendi")
|
||||
}
|
||||
|
||||
func AdminProductTagDelete(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Delete(&models.ProductTag{}, id).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Silme+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-tags?success=Etiket+silindi")
|
||||
}
|
||||
|
||||
func AdminProductTagRestore(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Unscoped().Model(&models.ProductTag{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-tags?error=Geri+yükleme+başarısız")
|
||||
}
|
||||
return c.Redirect().To("/admin/content/product-tags?deleted=true&success=Etiket+geri+yüklendi")
|
||||
}
|
||||
|
||||
// AdminContentProductComments handles rendering the Product Comments list in the admin panel
|
||||
func AdminContentProductComments(c fiber.Ctx) error {
|
||||
var comments []models.ProductComment
|
||||
|
||||
// Preload the User and Product for display (Wait, user and product relationships are missing in model temporarily)
|
||||
// We'll just list them out manually
|
||||
query := dbConfig.DB.Model(&models.ProductComment{})
|
||||
|
||||
// Optional filtering by specific product via query string
|
||||
productID := c.Query("product_id")
|
||||
if productID != "" {
|
||||
if pid, err := strconv.Atoi(productID); err == nil {
|
||||
query = query.Where("product_id = ?", pid)
|
||||
}
|
||||
}
|
||||
|
||||
query.Order("created_at desc").Find(&comments)
|
||||
|
||||
data := fiber.Map{
|
||||
"ProductComments": comments,
|
||||
"ProductID": productID,
|
||||
}
|
||||
|
||||
if c.Get("HX-Request") == "true" {
|
||||
return c.Render("admin/partials/product_comments", data)
|
||||
}
|
||||
|
||||
return c.Render("admin/partials/product_comments", data, "admin/layout")
|
||||
}
|
||||
|
||||
// AdminProductCommentDelete handles hard deleting a product comment
|
||||
func AdminProductCommentDelete(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if err := dbConfig.DB.Unscoped().Where("id = ?", id).Delete(&models.ProductComment{}).Error; err != nil {
|
||||
return c.Redirect().To("/admin/content/product-comments?error=Yorum+silinemedi")
|
||||
}
|
||||
|
||||
return c.Redirect().To("/admin/content/product-comments?deleted=true&success=Yorum+silindi")
|
||||
}
|
||||
|
||||
// AdminContentProductCategoryViews handles rendering the Product Category Views list
|
||||
func AdminContentProductCategoryViews(c fiber.Ctx) error {
|
||||
var views []models.ProductCategoryView
|
||||
|
||||
query := dbConfig.DB.Model(&models.ProductCategoryView{})
|
||||
|
||||
// Filter by Category ID
|
||||
categoryID := c.Query("category_id")
|
||||
if categoryID != "" {
|
||||
if cid, err := strconv.Atoi(categoryID); err == nil {
|
||||
query = query.Where("category_id = ?", cid)
|
||||
}
|
||||
}
|
||||
|
||||
query.Order("created_at desc").Find(&views)
|
||||
|
||||
data := fiber.Map{
|
||||
"Views": views,
|
||||
"CategoryID": categoryID,
|
||||
}
|
||||
|
||||
if c.Get("HX-Request") == "true" {
|
||||
return c.Render("admin/partials/product_category_views", data)
|
||||
}
|
||||
|
||||
return c.Render("admin/partials/product_category_views", data, "admin/layout")
|
||||
}
|
||||
237
controllers/api_cart_controller.go
Normal file
237
controllers/api_cart_controller.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// getOrCreateCart is a helper to fetch the cart of the current user.
|
||||
func getOrCreateCart(userID uint) (models.Cart, error) {
|
||||
var cart models.Cart
|
||||
if err := database.DB.Preload("Items").Preload("Items.Product").Where("user_id = ?", userID).First(&cart).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
cart = models.Cart{UserID: userID}
|
||||
if createErr := database.DB.Create(&cart).Error; createErr != nil {
|
||||
return cart, createErr
|
||||
}
|
||||
return cart, nil
|
||||
}
|
||||
return cart, err
|
||||
}
|
||||
return cart, nil
|
||||
}
|
||||
|
||||
// GetMyCart godoc
|
||||
// @Summary Get the current user's cart
|
||||
// @Tags Cart
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.CartDoc
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/cart [get]
|
||||
func GetMyCart(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("user_id").(uint)
|
||||
if !ok || userID == 0 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not load cart"})
|
||||
}
|
||||
|
||||
return c.JSON(cart)
|
||||
}
|
||||
|
||||
// AddToCartRequest represents the body for adding to a cart
|
||||
type AddToCartRequest struct {
|
||||
ProductID uint `json:"product_id" validate:"required"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// AddToCart godoc
|
||||
// @Summary Add item to cart
|
||||
// @Tags Cart
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param data body AddToCartRequest true "Cart Item Details"
|
||||
// @Success 200 {object} models.CartDoc
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/cart/items [post]
|
||||
func AddToCart(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("user_id").(uint)
|
||||
if !ok || userID == 0 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
var input AddToCartRequest
|
||||
if err := c.Bind().Body(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
|
||||
}
|
||||
|
||||
if err := validate.Struct(input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check product exists
|
||||
var product models.Product
|
||||
if err := database.DB.First(&product, input.ProductID).Error; err != nil {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "product not found"})
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not load cart"})
|
||||
}
|
||||
|
||||
// Check if item already exists in cart
|
||||
var existingItem models.CartItem
|
||||
if err := database.DB.Where("cart_id = ? AND product_id = ?", cart.ID, input.ProductID).First(&existingItem).Error; err == nil {
|
||||
// Update quantity
|
||||
existingItem.Quantity += input.Quantity
|
||||
database.DB.Save(&existingItem)
|
||||
} else {
|
||||
// Create new item
|
||||
newItem := models.CartItem{
|
||||
CartID: cart.ID,
|
||||
ProductID: input.ProductID,
|
||||
Quantity: input.Quantity,
|
||||
}
|
||||
database.DB.Create(&newItem)
|
||||
}
|
||||
|
||||
// Return updated cart
|
||||
cart, _ = getOrCreateCart(userID)
|
||||
return c.JSON(cart)
|
||||
}
|
||||
|
||||
// UpdateCartItemRequest represents the body for updating a cart item quantity
|
||||
type UpdateCartItemRequest struct {
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
// UpdateCartItem godoc
|
||||
// @Summary Update cart item quantity
|
||||
// @Tags Cart
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param item_id path int true "Cart Item ID"
|
||||
// @Param data body UpdateCartItemRequest true "Update Quantity"
|
||||
// @Success 200 {object} models.CartDoc
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/cart/items/{item_id} [put]
|
||||
func UpdateCartItem(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("user_id").(uint)
|
||||
if !ok || userID == 0 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
itemID, err := strconv.ParseUint(c.Params("item_id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid item id"})
|
||||
}
|
||||
|
||||
var input UpdateCartItemRequest
|
||||
if err := c.Bind().Body(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
|
||||
}
|
||||
|
||||
if err := validate.Struct(input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not load cart"})
|
||||
}
|
||||
|
||||
var cartItem models.CartItem
|
||||
if err := database.DB.Where("id = ? AND cart_id = ?", itemID, cart.ID).First(&cartItem).Error; err != nil {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "item not found in your cart"})
|
||||
}
|
||||
|
||||
cartItem.Quantity = input.Quantity
|
||||
database.DB.Save(&cartItem)
|
||||
|
||||
// Return updated cart
|
||||
cart, _ = getOrCreateCart(userID)
|
||||
return c.JSON(cart)
|
||||
}
|
||||
|
||||
// RemoveFromCart godoc
|
||||
// @Summary Remove item from cart
|
||||
// @Tags Cart
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param item_id path int true "Cart Item ID"
|
||||
// @Success 200 {object} models.CartDoc
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/cart/items/{item_id} [delete]
|
||||
func RemoveFromCart(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("user_id").(uint)
|
||||
if !ok || userID == 0 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
itemID, err := strconv.ParseUint(c.Params("item_id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid item id"})
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not load cart"})
|
||||
}
|
||||
|
||||
var cartItem models.CartItem
|
||||
if err := database.DB.Where("id = ? AND cart_id = ?", itemID, cart.ID).First(&cartItem).Error; err != nil {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "item not found in your cart"})
|
||||
}
|
||||
|
||||
database.DB.Delete(&cartItem)
|
||||
|
||||
// Return updated cart
|
||||
cart, _ = getOrCreateCart(userID)
|
||||
return c.JSON(cart)
|
||||
}
|
||||
|
||||
// ClearCart godoc
|
||||
// @Summary Clear the entire cart
|
||||
// @Tags Cart
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.CartDoc
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/cart [delete]
|
||||
func ClearCart(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("user_id").(uint)
|
||||
if !ok || userID == 0 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not load cart"})
|
||||
}
|
||||
|
||||
// Delete all items for this cart
|
||||
database.DB.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
|
||||
|
||||
// Return updated empty cart
|
||||
cart, _ = getOrCreateCart(userID)
|
||||
return c.JSON(cart)
|
||||
}
|
||||
1123
controllers/blog_controller.go
Normal file
1123
controllers/blog_controller.go
Normal file
File diff suppressed because it is too large
Load Diff
164
controllers/hero_controller.go
Normal file
164
controllers/hero_controller.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"fmt"
|
||||
"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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
configs.Logger.Sugar().Warnf("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)
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
161
controllers/product_controller.go
Normal file
161
controllers/product_controller.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetProducts godoc
|
||||
// @Summary List products (public) with pagination
|
||||
// @Tags Products
|
||||
// @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/products [get]
|
||||
func GetProducts(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.Product{}).Count(&total)
|
||||
|
||||
var products []models.Product
|
||||
database.DB.Preload("Categories").Preload("Tags").Limit(perPage).Offset(offset).Order("created_at desc").Find(&products)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"data": products,
|
||||
"meta": fiber.Map{"page": page, "per_page": perPage, "total": total},
|
||||
})
|
||||
}
|
||||
|
||||
// GetProduct godoc
|
||||
// @Summary Get single product (public) by slug
|
||||
// @Tags Products
|
||||
// @Produce json
|
||||
// @Param slug path string true "Product slug"
|
||||
// @Success 200 {object} models.ProductDoc
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/products/{slug} [get]
|
||||
func GetProduct(c fiber.Ctx) error {
|
||||
slug := c.Params("slug")
|
||||
if slug == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid slug"})
|
||||
}
|
||||
|
||||
var product models.Product
|
||||
if err := database.DB.Preload("Categories").Preload("Tags").Where("slug = ? AND deleted_at IS NULL", slug).First(&product).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "product not found"})
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||
}
|
||||
|
||||
return c.JSON(product)
|
||||
}
|
||||
|
||||
// AddProductCommentRequest represents payload
|
||||
type AddProductCommentRequest struct {
|
||||
ProductID uint `json:"product_id" validate:"required"`
|
||||
Body string `json:"body" validate:"required,min=3"`
|
||||
}
|
||||
|
||||
|
||||
func AddProductComment(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("user_id").(uint)
|
||||
if !ok || userID == 0 {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
var input AddProductCommentRequest
|
||||
if err := c.Bind().Body(&input); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
|
||||
}
|
||||
|
||||
// Add validation if needed
|
||||
if input.Body == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "body is required"})
|
||||
}
|
||||
|
||||
var product models.Product
|
||||
if err := database.DB.First(&product, input.ProductID).Error; err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "product not found"})
|
||||
}
|
||||
|
||||
comment := models.ProductComment{
|
||||
UserID: userID,
|
||||
ProductID: input.ProductID,
|
||||
Body: input.Body,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&comment).Error; err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not save comment"})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusCreated).JSON(comment)
|
||||
}
|
||||
|
||||
// GetProductComments godoc
|
||||
// @Summary Get comments for a product
|
||||
// @Tags Products
|
||||
// @Produce json
|
||||
// @Param id path int true "Product ID"
|
||||
// @Success 200 {array} models.ProductCommentDoc
|
||||
// @Router /api/v1/products/{id}/comments [get]
|
||||
func GetProductComments(c fiber.Ctx) error {
|
||||
productID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid product id"})
|
||||
}
|
||||
|
||||
var comments []models.ProductComment
|
||||
database.DB.Where("product_id = ?", productID).Order("created_at desc").Find(&comments)
|
||||
|
||||
return c.JSON(comments)
|
||||
}
|
||||
|
||||
// RecordProductCategoryView godoc
|
||||
// @Summary Record a view for a product category
|
||||
// @Tags Products
|
||||
// @Produce json
|
||||
// @Param id path int true "Category ID"
|
||||
// @Success 201 {object} models.ProductCategoryViewDoc
|
||||
// @Router /api/v1/product-categories/{id}/view [post]
|
||||
func RecordProductCategoryView(c fiber.Ctx) error {
|
||||
categoryID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid category id"})
|
||||
}
|
||||
|
||||
var category models.ProductCategory
|
||||
if err := database.DB.First(&category, categoryID).Error; err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "category not found"})
|
||||
}
|
||||
|
||||
view := models.ProductCategoryView{
|
||||
CategoryID: uint(categoryID),
|
||||
IPAddress: c.IP(),
|
||||
}
|
||||
|
||||
database.DB.Create(&view)
|
||||
|
||||
return c.Status(http.StatusCreated).JSON(view)
|
||||
}
|
||||
419
controllers/security_controller.go
Normal file
419
controllers/security_controller.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"ares/middlewares"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"`
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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 {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Infof(format, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
166
controllers/setting_controller.go
Normal file
166
controllers/setting_controller.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"go.uber.org/zap"
|
||||
"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)
|
||||
}
|
||||
|
||||
func CreateSetting(c fiber.Ctx) error {
|
||||
configs.Logger.Info(
|
||||
"CreateSetting called",
|
||||
zap.String("method", c.Method()),
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("content_type", c.Get("Content-Type")),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func UpdateSetting(c fiber.Ctx) error {
|
||||
configs.Logger.Info(
|
||||
"UpdateSetting called",
|
||||
zap.String("method", c.Method()),
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("content_type", c.Get("Content-Type")),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
974
controllers/user.go
Normal file
974
controllers/user.go
Normal file
@@ -0,0 +1,974 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"ares/middlewares"
|
||||
utils "ares/pkg/utis"
|
||||
"crypto/sha256"
|
||||
"ares/services"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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")
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to save avatar: %v", err)
|
||||
}
|
||||
} else {
|
||||
payload["avatar_url"] = "/uploads/avatars/" + filename
|
||||
}
|
||||
}
|
||||
} 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})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
// Persist refresh token server-side for rotation & revoke
|
||||
refreshClaims, err := jwtService.ValidateToken(refreshToken)
|
||||
if err != nil || refreshClaims.TokenType != services.TokenTypeRefresh {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to validate issued refresh token for user=%d: %v", user.ID, err)
|
||||
}
|
||||
} else {
|
||||
// Revoke any existing refresh tokens for this user (single-device semantics)
|
||||
if err := database.DB.Model(&models.RefreshToken{}).
|
||||
Where("user_id = ?", user.ID).
|
||||
Update("revoked", true).Error; err != nil {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to revoke existing refresh tokens for user=%d: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(configs.AppConfig.RefreshTokenExpireDays) * 24 * time.Hour)
|
||||
if refreshClaims.ExpiresAt != nil {
|
||||
expiresAt = refreshClaims.ExpiresAt.Time
|
||||
}
|
||||
|
||||
rt := models.RefreshToken{
|
||||
UserID: user.ID,
|
||||
TokenID: refreshClaims.ID,
|
||||
TokenHash: sha256Hex(refreshToken),
|
||||
TokenFingerprint: tokenFingerprint(refreshToken),
|
||||
ExpiresAt: expiresAt.UTC(),
|
||||
Revoked: false,
|
||||
UserAgent: c.Get("User-Agent"),
|
||||
IP: c.IP(),
|
||||
}
|
||||
if err := database.DB.Create(&rt).Error; err != nil {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to persist refresh token for user=%d: %v", user.ID, err)
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token could not be persisted"})
|
||||
}
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Infof("refresh token persisted user=%d token_id=%s fingerprint=%s", user.ID, rt.TokenID, rt.TokenFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
// Look up refresh token server-side to enforce rotation and revoke state
|
||||
var stored models.RefreshToken
|
||||
if err := database.DB.Where("token_id = ? AND user_id = ?", claims.ID, claims.UserID).First(&stored).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired refresh token"})
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token lookup failed"})
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if stored.Revoked || stored.ExpiresAt.Before(now) {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired refresh token"})
|
||||
}
|
||||
// Extra safety: if we have a stored hash, require it to match.
|
||||
if stored.TokenHash != "" && stored.TokenHash != sha256Hex(req.RefreshToken) {
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid refresh token"})
|
||||
}
|
||||
|
||||
// Reuse detection: if this token was already rotated to a new one, treat as suspicious
|
||||
if stored.ReplacedByTokenID != "" {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Warnf("refresh token reuse detected for user=%d token_id=%s", claims.UserID, claims.ID)
|
||||
}
|
||||
// Revoke all refresh tokens for this user to force re-login
|
||||
if err := database.DB.Model(&models.RefreshToken{}).
|
||||
Where("user_id = ?", claims.UserID).
|
||||
Update("revoked", true).Error; err != nil {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to revoke refresh tokens after reuse for user=%d: %v", claims.UserID, err)
|
||||
}
|
||||
}
|
||||
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "refresh token has been reused; please login again"})
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
// Persist new refresh token and rotate old one
|
||||
newClaims, err := jwtService.ValidateToken(refreshToken)
|
||||
if err != nil || newClaims.TokenType != services.TokenTypeRefresh {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to validate rotated refresh token for user=%d: %v", user.ID, err)
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token rotation failed"})
|
||||
}
|
||||
|
||||
expiresAt := now.Add(time.Duration(configs.AppConfig.RefreshTokenExpireDays) * 24 * time.Hour)
|
||||
if newClaims.ExpiresAt != nil {
|
||||
expiresAt = newClaims.ExpiresAt.Time
|
||||
}
|
||||
|
||||
if err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&stored).Updates(map[string]interface{}{
|
||||
"revoked": true,
|
||||
"replaced_by_token_id": newClaims.ID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rt := models.RefreshToken{
|
||||
UserID: user.ID,
|
||||
TokenID: newClaims.ID,
|
||||
TokenHash: sha256Hex(refreshToken),
|
||||
TokenFingerprint: tokenFingerprint(refreshToken),
|
||||
ExpiresAt: expiresAt.UTC(),
|
||||
Revoked: false,
|
||||
UserAgent: c.Get("User-Agent"),
|
||||
IP: c.IP(),
|
||||
}
|
||||
if err := tx.Create(&rt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to rotate refresh token for user=%d: %v", user.ID, err)
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token rotation failed"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func tokenFingerprint(token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if len(token) <= 10 {
|
||||
return "****"
|
||||
}
|
||||
return token[:6] + "..." + token[len(token)-4:]
|
||||
}
|
||||
|
||||
// Logout godoc
|
||||
// @Summary Logout user and revoke refresh tokens
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefreshRequest true "Logout payload (refresh token)"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/logout [post]
|
||||
func Logout(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"})
|
||||
}
|
||||
|
||||
// Revoke all refresh tokens for this user to enforce full logout
|
||||
if err := database.DB.Model(&models.RefreshToken{}).
|
||||
Where("user_id = ?", claims.UserID).
|
||||
Update("revoked", true).Error; err != nil {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("failed to revoke refresh tokens on logout for user=%d: %v", claims.UserID, err)
|
||||
}
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "logout failed"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "logged out successfully"})
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
46
database/config/mysql_db.go
Normal file
46
database/config/mysql_db.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func ConnectDB() {
|
||||
dsn := configs.AppConfig.DBUrl
|
||||
if dsn == "" {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Warn(".env dosyasında DB_URL ayarlı değil — veritabanı bağlantısı atlanıyor (geliştirme modu)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Info("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.Warn), // 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 {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("MySQL veritabanı bağlantısı kurulamadı: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Info("MySQL veritabanı bağlantısı kuruldu.")
|
||||
}
|
||||
DB = db
|
||||
}
|
||||
46
database/config/postgres_db.go
Normal file
46
database/config/postgres_db.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DBPg *gorm.DB
|
||||
|
||||
func ConnectDBPg() {
|
||||
dsn := configs.AppConfig.DBPGUrl
|
||||
if dsn == "" {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Warn(".env dosyasında DB_URL ayarlı değil — veritabanı bağlantısı atlanıyor (geliştirme modu)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Info("Yapılandırmada DB_URL_PG bulundu, veritabanına bağlanılmaya çalışılıyor...")
|
||||
}
|
||||
|
||||
// GORM için MySQL konfigürasyonu
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn), // 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 {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Errorf("Postgres veritabanı bağlantısı kurulamadı: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Info("Postgres veritabanı bağlantısı kuruldu.")
|
||||
}
|
||||
DBPg = db
|
||||
}
|
||||
117
database/config/redis_db.go
Normal file
117
database/config/redis_db.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
"context"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"time"
|
||||
)
|
||||
|
||||
var RedisClient *redis.Client
|
||||
var RedisOptions *redis.Options
|
||||
var ctx = context.Background()
|
||||
|
||||
func ConnectRedis() {
|
||||
redisURL := configs.AppConfig.RedisUrl
|
||||
if redisURL == "" {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Warn("Warning: REDIS_URL is not set, continuing without Redis cache")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Warnf("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 {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Warnf("Warning: Failed to connect to Redis: %v, continuing without Redis cache", err)
|
||||
}
|
||||
RedisClient = nil
|
||||
RedisOptions = nil
|
||||
return
|
||||
}
|
||||
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Info("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
|
||||
}
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Info("🧹 Clearing Redis Cache...")
|
||||
}
|
||||
return RedisClient.FlushDB(ctx).Err()
|
||||
}
|
||||
165
database/migrate/migrate.go
Normal file
165
database/migrate/migrate.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"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.RefreshToken{},
|
||||
&models.Category{},
|
||||
&models.Tag{},
|
||||
&models.Post{},
|
||||
&models.CategoryView{},
|
||||
&models.Comment{},
|
||||
&models.ProductCategory{},
|
||||
&models.ProductTag{},
|
||||
&models.Product{},
|
||||
&models.ProductCategoryView{},
|
||||
&models.ProductComment{},
|
||||
&models.Cart{},
|
||||
&models.CartItem{},
|
||||
); err != nil {
|
||||
configs.Logger.Sugar().Errorf("AutoMigrate Yapılamadı !!: %v", err)
|
||||
}
|
||||
seedSecurityDefaults()
|
||||
configs.Logger.Info("AutoMigrate Yapıldı.")
|
||||
} else {
|
||||
configs.Logger.Info("DB not initialized: skipping AutoMigrate")
|
||||
}
|
||||
}
|
||||
|
||||
func MigratePg() {
|
||||
if database.DBPg != nil {
|
||||
if err := database.DBPg.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.SocialAccount{},
|
||||
&models.Profile{},
|
||||
&models.Hero{},
|
||||
&models.Setting{},
|
||||
&models.CorsWhitelist{},
|
||||
&models.CorsBlacklist{},
|
||||
&models.RateLimitSetting{},
|
||||
&models.RefreshToken{},
|
||||
&models.Category{},
|
||||
&models.Tag{},
|
||||
&models.Post{},
|
||||
&models.CategoryView{},
|
||||
&models.Comment{},
|
||||
&models.ProductCategory{},
|
||||
&models.ProductTag{},
|
||||
&models.Product{},
|
||||
&models.ProductCategoryView{},
|
||||
&models.ProductComment{},
|
||||
&models.Cart{},
|
||||
&models.CartItem{},
|
||||
); err != nil {
|
||||
configs.Logger.Sugar().Errorf("PG AutoMigrate Yapılamadı !!: %v", err)
|
||||
}
|
||||
seedSecurityDefaults()
|
||||
configs.Logger.Info("PG AutoMigrate Yapıldı.")
|
||||
} else {
|
||||
configs.Logger.Info("PG 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)
|
||||
seedRateLimit("global", "Global endpoint default rate limit", 1000, 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 {
|
||||
configs.Logger.Sugar().Errorf("RateLimit seed failed (%s): %v", name, err)
|
||||
return
|
||||
}
|
||||
configs.Logger.Sugar().Infof("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 {
|
||||
configs.Logger.Sugar().Errorf("CorsWhitelist seed failed (%s): %v", origin, err)
|
||||
return
|
||||
}
|
||||
configs.Logger.Sugar().Infof("CorsWhitelist seed created: origin=%s", origin)
|
||||
}
|
||||
|
||||
func defaultWhitelistOrigins() []string {
|
||||
origins := []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8000",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
53
database/models/blog.go
Normal file
53
database/models/blog.go
Normal file
@@ -0,0 +1,53 @@
|
||||
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" form:"title"`
|
||||
Images string `gorm:"type:text;not null" json:"images" form:"images"`
|
||||
ImagesMid string `gorm:"type:text;not null" json:"images_mid" form:"images_mid"`
|
||||
ImagesMin string `gorm:"type:text;not null" json:"images_min" form:"images_min"`
|
||||
Width int `gorm:"default:0" json:"width" form:"width"`
|
||||
Height int `gorm:"default:0" json:"height" form:"height"`
|
||||
Quality int `gorm:"default:0" json:"quality" form:"quality"`
|
||||
Format string `gorm:"type:varchar(10)" json:"format" form:"format" default:"avif"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty" form:"content"`
|
||||
Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug" form:"slug"`
|
||||
Categories []Category `gorm:"many2many:post_categories;" json:"categories,omitempty" form:"categories"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty" form:"tags"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
20
database/models/cart.go
Normal file
20
database/models/cart.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Cart struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Items []CartItem `gorm:"foreignKey:CartID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type CartItem struct {
|
||||
gorm.Model
|
||||
CartID uint `gorm:"not null;index" json:"cart_id"`
|
||||
Cart *Cart `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:CartID" json:"cart,omitempty"`
|
||||
ProductID uint `gorm:"not null;index" json:"product_id"`
|
||||
Product *Product `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:ProductID" json:"product,omitempty"`
|
||||
Quantity int `gorm:"default:1" json:"quantity"`
|
||||
}
|
||||
34
database/models/cors.go
Normal file
34
database/models/cors.go
Normal file
@@ -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"`
|
||||
}
|
||||
96
database/models/docs_models.go
Normal file
96
database/models/docs_models.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type ProductCategoryDoc struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Children []ProductCategoryDoc `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type ProductTagDoc struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProductDoc struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Images string `json:"images"`
|
||||
Price float64 `json:"price"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Categories []ProductCategoryDoc `json:"categories,omitempty"`
|
||||
Tags []ProductTagDoc `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type CartItemDoc struct {
|
||||
ID uint `json:"id"`
|
||||
CartID uint `json:"cart_id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
Product ProductDoc `json:"product,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type CartDoc struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Items []CartItemDoc `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type ProductCommentDoc struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type ProductCategoryViewDoc struct {
|
||||
ID uint `json:"id"`
|
||||
CategoryID uint `json:"category_id"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
}
|
||||
23
database/models/hero.go
Normal file
23
database/models/hero.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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"`
|
||||
Width int `gorm:"default:0" json:"width" form:"width"`
|
||||
Height int `gorm:"default:0" json:"height" form:"height"`
|
||||
Quality int `gorm:"default:0" json:"quality" form:"quality"`
|
||||
Format string `gorm:"type:varchar(10)" json:"format" form:"format"`
|
||||
}
|
||||
53
database/models/product.go
Normal file
53
database/models/product.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Minimal, temiz GORM modelleri
|
||||
|
||||
type ProductCategory 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"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *ProductCategory `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []ProductCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
Products []Product `gorm:"many2many:product_product_categories;" json:"products,omitempty"`
|
||||
}
|
||||
|
||||
type ProductTag struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"type:varchar(254);not null" json:"name"`
|
||||
Products []Product `gorm:"many2many:product_product_tags;" json:"products,omitempty"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"`
|
||||
Images string `gorm:"type:text;not null" json:"images" form:"images"`
|
||||
Price float64 `gorm:"type:decimal(10,2);default:0.0" json:"price" form:"price"`
|
||||
Width int `gorm:"default:0" json:"width" form:"width"`
|
||||
Height int `gorm:"default:0" json:"height" form:"height"`
|
||||
Quality int `gorm:"default:0" json:"quality" form:"quality"`
|
||||
Format string `gorm:"type:varchar(10);default:avif" json:"format" form:"format"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty" form:"content"`
|
||||
Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug" form:"slug"`
|
||||
Categories []ProductCategory `gorm:"many2many:product_product_categories;" json:"categories,omitempty" form:"product_category"`
|
||||
Tags []ProductTag `gorm:"many2many:product_product_tags;" json:"tags,omitempty" form:"tags"`
|
||||
}
|
||||
|
||||
type ProductCategoryView struct {
|
||||
gorm.Model
|
||||
CategoryID uint `json:"category_id"`
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"`
|
||||
}
|
||||
|
||||
type ProductComment struct {
|
||||
gorm.Model
|
||||
UserID uint `json:"user_id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
Body string `gorm:"type:text" json:"body,omitempty"`
|
||||
}
|
||||
43
database/models/setting.go
Normal file
43
database/models/setting.go
Normal file
@@ -0,0 +1,43 @@
|
||||
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"`
|
||||
WWidth int `gorm:"default:0" json:"w_width" form:"w_width"`
|
||||
WHeight int `gorm:"default:0" json:"w_height" form:"w_height"`
|
||||
WQuality int `gorm:"default:0" json:"w_quality" form:"w_quality"`
|
||||
WFormat string `gorm:"type:varchar(10)" json:"w_format" form:"w_format"`
|
||||
BWidth int `gorm:"default:0" json:"b_width" form:"b_width"`
|
||||
BHeight int `gorm:"default:0" json:"b_height" form:"b_height"`
|
||||
BQuality int `gorm:"default:0" json:"b_quality" form:"b_quality"`
|
||||
BFormat string `gorm:"type:varchar(10)" json:"b_format" form:"b_format"`
|
||||
}
|
||||
|
||||
// TableName overrides the table name used by Setting to `settings`
|
||||
func (Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
27
database/models/token.go
Normal file
27
database/models/token.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RefreshToken represents a server-side record of issued refresh tokens
|
||||
// to support rotation, revocation and reuse detection.
|
||||
type RefreshToken struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
TokenID string `gorm:"type:varchar(128);not null;uniqueIndex" json:"token_id"`
|
||||
// TokenHash is SHA-256 hex of the refresh token string (64 chars).
|
||||
// Stored instead of the raw token for security, while still allowing debug/lookup.
|
||||
TokenHash string `gorm:"type:char(64);index" json:"token_hash"`
|
||||
// TokenFingerprint is a masked representation (e.g. first6...last4) to help operators
|
||||
// visually correlate DB rows with logs without storing full token.
|
||||
TokenFingerprint string `gorm:"type:varchar(32);index" json:"token_fingerprint"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
Revoked bool `gorm:"index" json:"revoked"`
|
||||
ReplacedByTokenID string `gorm:"type:varchar(128)" json:"replaced_by_token_id"`
|
||||
UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
|
||||
IP string `gorm:"type:varchar(64)" json:"ip"`
|
||||
}
|
||||
|
||||
48
database/models/user.go
Normal file
48
database/models/user.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
UserName string `json:"username" gorm:"type:varchar(255)"`
|
||||
Email string `gorm:"uniqueIndex;not null;type:varchar(255)" json:"email"`
|
||||
Password string `json:"-" gorm:"type:varchar(255)"` // Password shouldn't be returned in JSON
|
||||
EmailVerified *bool `gorm:"default:false" json:"email_verified"` // default false for email/password registration
|
||||
EmailVerifyToken string `gorm:"index;type:varchar(255)" json:"-"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"`
|
||||
IsAdmin *bool `gorm:"default:false" json:"is_admin"`
|
||||
SocialAccounts []SocialAccount `gorm:"foreignKey:UserID" json:"social_accounts,omitempty"`
|
||||
Profile []Profile `gorm:"foreignKey:UserID" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
}
|
||||
75
database/seeder/seeder.go
Normal file
75
database/seeder/seeder.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package seeder
|
||||
|
||||
import (
|
||||
dbConfig "ares/database/config"
|
||||
"ares/database/models"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Seed checks for essential data and creates it if missing
|
||||
func Seed() {
|
||||
seedAdmin()
|
||||
}
|
||||
|
||||
func seedAdmin() {
|
||||
// Include soft-deleted records in lookup
|
||||
var existing models.User
|
||||
err := dbConfig.DB.Unscoped().Where("email = ?", "admin@example.com").First(&existing).Error
|
||||
if err == nil {
|
||||
// Found a user (could be soft-deleted)
|
||||
// If soft-deleted, restore it
|
||||
if existing.DeletedAt.Valid {
|
||||
// Restore (set deleted_at to NULL) and ensure admin/verified flags
|
||||
updateErr := dbConfig.DB.Unscoped().Model(&existing).Updates(map[string]interface{}{
|
||||
"deleted_at": nil,
|
||||
"is_admin": true,
|
||||
"email_verified": true,
|
||||
}).Error
|
||||
if updateErr != nil {
|
||||
fmt.Println("Admin restore hatası:", updateErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
// user exists or restored, nothing more to do
|
||||
return
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fmt.Println("Admin seed lookup error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If not found at all, create
|
||||
password := "password123"
|
||||
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
isTrue := true
|
||||
|
||||
admin := models.User{
|
||||
UserName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Password: string(hashedPassword),
|
||||
IsAdmin: &isTrue,
|
||||
EmailVerified: &isTrue,
|
||||
}
|
||||
|
||||
res := dbConfig.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&admin)
|
||||
if res.Error != nil {
|
||||
fmt.Println("Admin seed hatası:", res.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
// Another process likely created it concurrently
|
||||
fmt.Println("Admin kullanıcı zaten mevcut; seed atlandı.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("------------------------------------------------")
|
||||
fmt.Println("Admin kullanıcısı oluşturuldu:")
|
||||
fmt.Println("Email: admin@example.com")
|
||||
fmt.Println("Şifre: password123")
|
||||
fmt.Println("------------------------------------------------")
|
||||
}
|
||||
20
docker-compose.c.yml
Normal file
20
docker-compose.c.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: ares_app_v2
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- uploads_data:/app/uploads
|
||||
env_file:
|
||||
- .env
|
||||
# Assumes MySQL and Redis are provided externally. Configure hosts in .env (DB_HOST, REDIS_HOST)
|
||||
restart: always
|
||||
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: ares_app_v2
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
volumes:
|
||||
# For local development, bind mount the host uploads folder so files appear on host
|
||||
#- ./uploads:/app/uploads
|
||||
# For production platforms that support named volumes, you can use:
|
||||
- uploads_data:/app/uploads
|
||||
env_file:
|
||||
- .env
|
||||
# Assumes MySQL and Redis are provided externally. Configure hosts in .env (DB_HOST, REDIS_HOST)
|
||||
restart: always
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
13
docker-entrypoint.sh
Normal file
13
docker-entrypoint.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure uploads dir exists; try to chown but ignore failures (some platforms forbid chown)
|
||||
if [ -d /app/uploads ]; then
|
||||
chown -R 1000:1000 /app/uploads >/dev/null 2>&1 || true
|
||||
else
|
||||
mkdir -p /app/uploads >/dev/null 2>&1 || true
|
||||
chown -R 1000:1000 /app/uploads >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Execute the passed command (do not attempt to change user/groups here)
|
||||
exec "$@"
|
||||
1377
docs/docs.go
Normal file
1377
docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
1348
docs/swagger.json
Normal file
1348
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
872
docs/swagger.yaml
Normal file
872
docs/swagger.yaml
Normal file
@@ -0,0 +1,872 @@
|
||||
definitions:
|
||||
controllers.AddToCartRequest:
|
||||
properties:
|
||||
product_id:
|
||||
type: integer
|
||||
quantity:
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- product_id
|
||||
- quantity
|
||||
type: object
|
||||
controllers.LoginRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
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.UpdateCartItemRequest:
|
||||
properties:
|
||||
quantity:
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- quantity
|
||||
type: object
|
||||
models.CartDoc:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/models.CartItemDoc'
|
||||
type: array
|
||||
user_id:
|
||||
type: integer
|
||||
type: object
|
||||
models.CartItemDoc:
|
||||
properties:
|
||||
cart_id:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
product:
|
||||
$ref: '#/definitions/models.ProductDoc'
|
||||
product_id:
|
||||
type: integer
|
||||
quantity:
|
||||
type: integer
|
||||
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.ProductCategoryDoc:
|
||||
properties:
|
||||
children:
|
||||
items:
|
||||
$ref: '#/definitions/models.ProductCategoryDoc'
|
||||
type: array
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
keywords:
|
||||
type: string
|
||||
parent_id:
|
||||
type: integer
|
||||
slug:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
models.ProductCategoryViewDoc:
|
||||
properties:
|
||||
category_id:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
ip_address:
|
||||
type: string
|
||||
type: object
|
||||
models.ProductCommentDoc:
|
||||
properties:
|
||||
body:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
product_id:
|
||||
type: integer
|
||||
user_id:
|
||||
type: integer
|
||||
type: object
|
||||
models.ProductDoc:
|
||||
properties:
|
||||
categories:
|
||||
items:
|
||||
$ref: '#/definitions/models.ProductCategoryDoc'
|
||||
type: array
|
||||
content:
|
||||
type: string
|
||||
format:
|
||||
type: string
|
||||
height:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
images:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
quality:
|
||||
type: integer
|
||||
slug:
|
||||
type: string
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/definitions/models.ProductTagDoc'
|
||||
type: array
|
||||
title:
|
||||
type: string
|
||||
width:
|
||||
type: integer
|
||||
type: object
|
||||
models.ProductTagDoc:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
models.TagDoc:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
/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/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/cart:
|
||||
delete:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.CartDoc'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Clear the entire cart
|
||||
tags:
|
||||
- Cart
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.CartDoc'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get the current user's cart
|
||||
tags:
|
||||
- Cart
|
||||
/api/v1/cart/items:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Cart Item Details
|
||||
in: body
|
||||
name: data
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/controllers.AddToCartRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.CartDoc'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Add item to cart
|
||||
tags:
|
||||
- Cart
|
||||
/api/v1/cart/items/{item_id}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: Cart Item ID
|
||||
in: path
|
||||
name: item_id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.CartDoc'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Remove item from cart
|
||||
tags:
|
||||
- Cart
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Cart Item ID
|
||||
in: path
|
||||
name: item_id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Update Quantity
|
||||
in: body
|
||||
name: data
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/controllers.UpdateCartItemRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.CartDoc'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update cart item quantity
|
||||
tags:
|
||||
- Cart
|
||||
/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
|
||||
/api/v1/categories/{slug}:
|
||||
get:
|
||||
parameters:
|
||||
- description: Category slug
|
||||
in: path
|
||||
name: slug
|
||||
required: true
|
||||
type: string
|
||||
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
|
||||
/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/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
|
||||
/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
|
||||
/api/v1/posts/{slug}:
|
||||
get:
|
||||
parameters:
|
||||
- description: Post slug
|
||||
in: path
|
||||
name: slug
|
||||
required: true
|
||||
type: string
|
||||
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
|
||||
/api/v1/product-categories/{id}/view:
|
||||
post:
|
||||
parameters:
|
||||
- description: Category ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/models.ProductCategoryViewDoc'
|
||||
summary: Record a view for a product category
|
||||
tags:
|
||||
- Products
|
||||
/api/v1/products:
|
||||
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 products (public) with pagination
|
||||
tags:
|
||||
- Products
|
||||
/api/v1/products/{id}/comments:
|
||||
get:
|
||||
parameters:
|
||||
- description: Product ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.ProductCommentDoc'
|
||||
type: array
|
||||
summary: Get comments for a product
|
||||
tags:
|
||||
- Products
|
||||
/api/v1/products/{slug}:
|
||||
get:
|
||||
parameters:
|
||||
- description: Product slug
|
||||
in: path
|
||||
name: slug
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.ProductDoc'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get single product (public) by slug
|
||||
tags:
|
||||
- Products
|
||||
/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
|
||||
/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
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Enter your bearer token in the format 'Bearer {token}'
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
2
git config --global user.log
Normal file
2
git config --global user.log
Normal file
@@ -0,0 +1,2 @@
|
||||
git config --global user.email "beyhano@gmail.com"
|
||||
git config --global user.name "Beyhan Oğur"
|
||||
69
go.mod
Normal file
69
go.mod
Normal file
@@ -0,0 +1,69 @@
|
||||
module ares
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/gofiber/template/html/v2 v2.1.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.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.22.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 // 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/schema v1.7.0 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/utils v1.2.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
)
|
||||
170
go.sum
Normal file
170
go.sum
Normal file
@@ -0,0 +1,170 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/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-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
|
||||
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
|
||||
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
|
||||
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
||||
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
|
||||
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
|
||||
github.com/gofiber/utils v1.2.0 h1:NCaqd+Efg3khhN++eeUUTyBz+byIxAsmIjpl8kKOMIc=
|
||||
github.com/gofiber/utils v1.2.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
|
||||
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
|
||||
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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/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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
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.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.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/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.v3 v3.0.0-20200313102051-9f266ea9e77c/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/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
125
guvenlik-raporu.md
Normal file
125
guvenlik-raporu.md
Normal file
@@ -0,0 +1,125 @@
|
||||
## Go Backend API Güvenlik Raporu
|
||||
|
||||
### 1. Genel Değerlendirme
|
||||
|
||||
- **Kapsam**: Kod tabanı üzerinden statik güvenlik analizi ve `go vet ./...` ile temel statik araç kontrolü.
|
||||
- **Genel Sonuç**: Mimari olarak sağlam bir temel var (JWT, rol tabanlı yetki, rate limit, CORS). En önemli eksik, **refresh token güvenliğinin state-less olması (rotation/revoke yok)** ve **API tarafında token invalidation / logout akışının olmaması**.
|
||||
|
||||
### 2. Güçlü Yönler
|
||||
|
||||
- **JWT ve Kimlik Doğrulama**
|
||||
- HS256 ile imzalama yapılıyor ve `SigningMethodHMAC` kontrolü var → `alg: none` benzeri saldırılara karşı temel koruma mevcut.
|
||||
- Access / refresh token ayrımı `TokenType` alanı ile net; `RequireAuth` yalnızca access token kabul ediyor.
|
||||
- Email doğrulaması yapılmadan login’e izin verilmiyor.
|
||||
|
||||
- **Rol ve Yetkilendirme**
|
||||
- Public API tarafında admin işlemleri `RequireAuth` + `RequireAdmin` middleware’leri ile korunuyor.
|
||||
- Admin panel altındaki `"/admin"` rotaları global olarak `RequireAuth` + `RequireAdmin` ile kapalı.
|
||||
|
||||
- **CORS**
|
||||
- DB + Redis destekli whitelist/blacklist ile **default deny** yaklaşımı kullanılıyor.
|
||||
- Same-origin istekler her zaman izinli, wildcard `*` yok → klasik açık CORS yanlış konfigürasyonları görülmedi.
|
||||
|
||||
- **Rate Limiting**
|
||||
- `/api/v1` için global; `/auth/login` ve `/auth/refresh` için isimlendirilmiş rate limit profilleri tanımlı.
|
||||
- Redis tabanlı sayaçlar ile limit aşıldığında `429` ve `Retry-After` header’ı dönüyor.
|
||||
|
||||
- **Admin Oturumu (Browser)**
|
||||
- `admin_session` cookie: `HttpOnly`, `Secure`, `SameSite=Strict` → XSS sonrası cookie çalınması ve CSRF riskleri azaltılmış.
|
||||
- Admin login’de parolalar `bcrypt` ile doğrulanıyor.
|
||||
|
||||
### 3. Tespit Edilen Riskler ve Öneriler
|
||||
|
||||
#### 3.1 Refresh Token Rotation & Revoke Eksikliği (Kritik / Yüksek Öncelik)
|
||||
|
||||
- **Durum**:
|
||||
- `/api/v1/auth/refresh` endpoint’i yalnızca:
|
||||
- JWT imzasını,
|
||||
- `TokenType == refresh` olmasını
|
||||
kontrol ediyor.
|
||||
- Refresh token’lar için DB/Redis tabanlı bir “token store”, revoke listesi veya rotation takibi yok.
|
||||
- **Risk**:
|
||||
- Bir refresh token ele geçirilirse, süresi dolana kadar sınırsız sayıda yeni access token üretmek için yeniden kullanılabilir.
|
||||
- Token reuse (aynı refresh token’ın birden fazla kez kullanılması) tespit edilemiyor.
|
||||
- **Öneri**:
|
||||
- Refresh token’lar için tablo veya Redis store tasarla:
|
||||
- Her refresh isteğinde:
|
||||
- Eski refresh token’ı **geçersiz** kıl (rotation),
|
||||
- Yeni bir refresh token üret ve store’a kaydet.
|
||||
- Aynı refresh token ikinci kez kullanılırsa:
|
||||
- İlgili hesabı veya oturumu geçici olarak kilitle,
|
||||
- Gerekirse tüm tokenlarını revoke et (global logout).
|
||||
- Mümkünse refresh token’ları **HTTP-only cookie** ile taşı (XSS’e karşı daha dirençli).
|
||||
|
||||
#### 3.2 API Logout / Token İptali Eksikliği (Orta–Yüksek)
|
||||
|
||||
- **Durum**:
|
||||
- Public API’de `/api/v1/auth/logout` benzeri bir endpoint yok.
|
||||
- Client tarafında yalnızca local storage / memory’den token silinerek logout yapılıyor; backend tarafında “session state” yok.
|
||||
- **Risk**:
|
||||
- Bir access veya refresh token sızdığında, expire olana kadar backend tarafında bunu geçersiz kılma imkânı yok.
|
||||
- Özellikle refresh token için kritik: saldırgan elinde refresh token olduğu sürece yeni access token üretebilir.
|
||||
- **Öneri**:
|
||||
- `/api/v1/auth/logout` endpoint’i ekle:
|
||||
- İlgili kullanıcının aktif refresh token kaydını (veya kayıtlardan birini) revoke listesine ekle ya da store’dan sil.
|
||||
- İsteğe bağlı olarak access token için kısa süreli bir blacklist kullan (jti/subject bazlı).
|
||||
- Admin panel logout şu an cookie temizliyor; bunu backend tarafında da bir “session invalidation” akışı ile desteklemek düşünülebilir.
|
||||
|
||||
#### 3.3 Token İçeriğinin Loglanması (Düşük–Orta)
|
||||
|
||||
- **Durum**:
|
||||
- `GenerateTokenPair` içinde development ortamında hem access hem refresh token string’leri loglanıyor.
|
||||
- Refresh akışında `fmt.Println(accessToken, "Access Token Yenilendi !!!")` ile access token stdout’a yazılıyor.
|
||||
- **Risk**:
|
||||
- Production konfigürasyonu yanlış yapılırsa, log dosyalarında tam token değerleri yer alabilir.
|
||||
- **Öneri**:
|
||||
- Production ortamında:
|
||||
- Token gövdesini **asla** loglama; yalnızca `userID`, `exp`, `tokenType` gibi meta verileri logla.
|
||||
- Development ortamında bile mümkünse:
|
||||
- Token’ı maskeleyerek veya kısmi göstererek logla (örneğin sadece ilk 6 + son 4 karakter).
|
||||
|
||||
#### 3.4 Admin Login – Captcha / Turnstile Doğrulaması Tamamlanmamış (Orta)
|
||||
|
||||
- **Durum**:
|
||||
- Admin login formu `cf-turnstile-response` alanını okuyor ancak gerçek Cloudflare Turnstile doğrulaması yapılmıyor.
|
||||
- Rate limiting mevcut olsa da insan/makine ayrımı yok.
|
||||
- **Risk**:
|
||||
- Admin hesabı için brute force ve credential stuffing saldırılarına karşı savunma zayıf kalıyor.
|
||||
- **Öneri**:
|
||||
- Cloudflare Turnstile veya benzeri servis için gerçek HTTP doğrulamasını ekle:
|
||||
- Turnstile token’ı backend’de doğrulanmadan login’e izin verme.
|
||||
- Başarısız giriş denemelerine göre:
|
||||
- IP ve hesap bazlı ek limitler veya geçici hesap kilitleme mekanizması eklemeyi değerlendir.
|
||||
|
||||
#### 3.5 Redis Yoksa Rate Limit & CORS Enforcement’ın Devre Dışı Kalması (Düşük–Orta)
|
||||
|
||||
- **Durum**:
|
||||
- Redis bağlantısı yoksa, rate limit ve CORS cache tarafı graceful fail yapıyor ve bazı kontroller uygulanmayabiliyor.
|
||||
- **Risk**:
|
||||
- Production’da Redis yanlış konfigüre edilirse, rate limit fiilen devre dışı kalabilir; CORS kontrolleri de zayıflayabilir.
|
||||
- **Öneri**:
|
||||
- Production ortamında Redis’i **zorunlu bağımlılık** haline getir:
|
||||
- Redis’e bağlanılamıyorsa servisi başlatma (fail-fast).
|
||||
- Redis bağlantı hatalarını loglarda daha yüksek seviye (error) olarak işaretle.
|
||||
|
||||
### 4. `go vet` Çıktısı Özeti
|
||||
|
||||
- `go vet ./...` komutu çalıştırıldığında:
|
||||
- `scripts/seed.go` içinde aynı pakette birden fazla `main` fonksiyonu olduğu için “main redeclared” uyarısı veriliyor.
|
||||
- **Not**:
|
||||
- Bu, güvenlikten ziyade script yapısına dair yapısal bir uyarı; istenirse ilgili script ayrı bir pakete veya dosya yapısına taşınarak temizlenebilir.
|
||||
|
||||
### 5. Önerilen İyileştirme Planı (Önceliklendirilmiş)
|
||||
|
||||
1. **Kritik (kısa vadede)**:
|
||||
- Refresh token için rotation + revoke mekanizması tasarlayıp uygulamak.
|
||||
- Public API için `/api/v1/auth/logout` endpoint’i ekleyip refresh (ve gerekiyorsa access) token’larını server-side olarak da geçersiz kılmak.
|
||||
- Production’da token içeriğini loglamayı tamamen kapatmak; development’ta da maskelemek.
|
||||
|
||||
2. **Orta vadede**:
|
||||
- Admin login için gerçek Turnstile (veya eşdeğer captcha) doğrulamasını devreye almak.
|
||||
- Redis’i production ortamında zorunlu hale getirip rate limit/CORS’un Redis olmadan çalışmamasını sağlamak (fail-fast yaklaşımı).
|
||||
|
||||
3. **Uzun vadede**:
|
||||
- Bu rapora göre hazırlanmış, dış pentest’e verilebilecek detaylı bir test senaryoları dokümanı oluşturmak (mevcut `guvenlik.md` şablonunu projeye özgü endpoint/roller ile doldurmak).
|
||||
|
||||
77
guvenlik.md
Normal file
77
guvenlik.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Go Backend API Güvenlik Tarama / Pentest Prompt (Türkçe)
|
||||
|
||||
Aşağıdaki prompt'u otomatik bir API güvenlik aracına, dışarıdan test yapacak bir pentester'a veya güvenlik-odaklı bir LLM'ye ver. Testlerin üretim ortamında yapılmaması, veya yapılacaksa kesin izin ve rollback planı gerektirdiğini unutma — yasal izinleri sağla.
|
||||
|
||||
--------------------------------------------------------------------
|
||||
Amaç
|
||||
- Go tabanlı API uygulamasının güvenliğini değerlendirmek: yetkisiz erişim, yetki yükseltme, token yönetimi hataları, kimlik doğrulama/oturum güvenliği, API mantık hataları, veri sızıntıları ve yaygın web/API zafiyetleri.
|
||||
- Özellikle admin endpoint’lerinin korunması, JWT (access) ve refresh-token mekanizmasının güvenli biçimde uygulanıp uygulanmadığına odaklan.
|
||||
|
||||
Kapsam
|
||||
- Base URL: <API_BASE_URL> (ör. https://api.example.com)
|
||||
- Dahil olan yollar (örnek, tam listeyi test öncesi sağla):
|
||||
- /api/v1/auth/login
|
||||
- /api/v1/auth/refresh
|
||||
- /api/v1/users/*
|
||||
- /api/v1/admin/*
|
||||
- /api/v1/public/*
|
||||
- Test ortamı mı yoksa üretim mi: <TEST_ENVIRONMENT_INFO> (SAĞLAYIN)
|
||||
- Teste dahil edilmemesi gereken kaynaklar / IP’ler: <EXCLUDE_IF_ANY>
|
||||
|
||||
Yetkilendirme & Test Hesapları (TEST VERİSİ sağlanmalı)
|
||||
- Test kullanıcısı (standard user): kullanıcı adı/email ve parola
|
||||
- Test admin hesabı: kullanıcı adı/email ve parola (manuel veya token)
|
||||
- Örnek erişim tokenı (opsiyonel): Bearer <JWT_ACCESS_TOKEN_EXAMPLE>
|
||||
- Refresh token davranışı: cookie mi, bearer mi, HTTP-only mi, rotating mı? (AÇIKLA)
|
||||
- JWT imzalama algoritması: (HS256/RS256/ES256/…) ve varsa public key / JWKS endpointi
|
||||
- Rate limit bilgisi ve beklenen limitler
|
||||
|
||||
İzinler ve Güvenlik Kuralları
|
||||
- Yasal izin: Test için gerekli izinler verildi mi? (EVET/HAYIR)
|
||||
- Üretime doğrudan zarar verici testler yalnızca açık izin varsa yapılır. (Destruktif işlemler = veri silme, maskeleme, yeniden başlatma gibi)
|
||||
- Testler sırasında tetiklenen kritik durumlarda iletişim kişisi: <AD - TEL/EMAIL>
|
||||
|
||||
Test Senaryoları / Yapılacaklar (ayrıntılı)
|
||||
1) Kimlik Doğrulama & Token Yönetimi
|
||||
- Doğrulama akışını tam tekrar et: login -> access token al -> protected endpoint çağır -> refresh token ile yeni access al.
|
||||
- Access tokenın süresi dolduğunda refresh akışını test et.
|
||||
- Refresh token tekrar kullanılabilir mi? (refresh token reuse / replay attack testi)
|
||||
- Refresh token rotation: her refresh işleminde eski refresh token invalid ediliyor mu?
|
||||
- Logout davranışı: logout sonrasında access/refresh tokenların geçersizleştirildiğini doğrula.
|
||||
- Token saklama: refresh token cookie ise Secure, HttpOnly, SameSite ayarlarını kontrol et.
|
||||
|
||||
2) JWT Güvenlik Testleri
|
||||
- Alg none veya algoritma manipülasyon testleri (alg değiştirme, key confusion).
|
||||
- HS256/RS256 anahtar/alg uyumsuzluk zafiyetleri kontrolü.
|
||||
- JWT imzalanmamış veya hatalı imzalı token ile erişim denemeleri.
|
||||
- Exp (expiration), nbf, iat gibi claim kontrolleri; ileri/geri tarihli token denemeleri.
|
||||
- Uzun süreli tokenlarda (refresh) reuse/replay tespitleri.
|
||||
- JWT içindeki role/claims manipülasyonu ile RBAC atlanabilir mi? (ör. role: user -> role: admin)
|
||||
|
||||
3) Yetki Kontrolleri & IDOR / Horizontal/Vertical Privilege Escalation
|
||||
- Admin endpointlerine kullanıcı tokenı ile erişim denemeleri.
|
||||
- IDOR kontrolleri: kullanıcının başka bir kullanıcının verisini görmesi/işlemesi (ör. /users/{id} parametre manipulasyonu).
|
||||
- Resource-level access kontrolü (özellikle PUT/DELETE işlemleri).
|
||||
|
||||
4) Endpoint Mantık / İş Akışı Testleri
|
||||
- İş mantığı kaynaklı hatalar: ödeme, bakiye, onay vb. süreçlerde yetki veya mantık atlatılabiliyor mu?
|
||||
- Sequence / race condition testleri (ör. aynı anda iki refresh isteği).
|
||||
|
||||
5) Girdi Doğrulama & Enjeksiyonlar
|
||||
- SQL Injection (parametrized query kontrolü), NoSQL injection (eğer NoSQL kullanılıyorsa), LDAP injection.
|
||||
- Command injection, OS command dahil kod enjeksiyonu (varsa endpointlerle).
|
||||
- XSS (reflected/stored) – API cevaplarında HTML içerik döndürülüyorsa.
|
||||
- XML External Entity (XXE) (eğer XML işleniyorsa).
|
||||
- JSON payload fuzzing ve boundary testleri (çok büyük/değişken değerler).
|
||||
|
||||
6) Rate Limiting & Brute Force
|
||||
- Login endpointine brute-force testleri (kaba kuvvet). Var ise account lockout, rate limiting ve CAPTCHA mekanizmalarını kontrol et.
|
||||
- Token brute-force / token guessing zafiyetleri (zayıf token üretimi).
|
||||
|
||||
7) Transport Güvenliği & CORS
|
||||
- TLS konfigürasyonu: güncel protokoller (TLS1.2/1.3), zayıf şifreler, HSTS header kontrolü.
|
||||
- CORS konfigürasyonu: permissive wildcard origin veya credentials ile açık origin olup olmadığı.
|
||||
- HSTS, CSP, X-Frame-Options, X-Content-Type-Options gibi güvenlik header’ları.
|
||||
|
||||
8) Bilgi Sızıntısı & Logging
|
||||
- H
|
||||
6
info.log
Normal file
6
info.log
Normal file
@@ -0,0 +1,6 @@
|
||||
2026-04-08T15:00:03.318+0300 INFO config/loger.go:38 Logger başlatıldı (konsol + info.log)
|
||||
2026-04-08T15:00:03.319+0300 INFO config/mysql_db.go:24 Yapılandırmada DB_URL bulundu, veritabanına bağlanılmaya çalışılıyor...
|
||||
2026-04-08T15:00:03.323+0300 INFO config/mysql_db.go:43 MySQL veritabanı bağlantısı kuruldu.
|
||||
2026-04-08T15:00:03.326+0300 INFO config/redis_db.go:47 Connected to Redis successfully
|
||||
2026-04-08T15:00:03.660+0300 INFO migrate/migrate.go:41 AutoMigrate Yapıldı.
|
||||
2026-04-08T15:00:03.662+0300 INFO aresv2/main.go:34 Init Uygulandı !!
|
||||
131
main.go
Normal file
131
main.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
// Swagger security definition for Bearer token (swaggo)
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Enter your bearer token in the format 'Bearer {token}'
|
||||
|
||||
import (
|
||||
config "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/migrate"
|
||||
"ares/database/seeder"
|
||||
_ "ares/docs"
|
||||
"ares/routes"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
"github.com/gofiber/template/html/v2"
|
||||
swag "github.com/swaggo/swag"
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.LoadConfig()
|
||||
database.ConnectDB()
|
||||
// database.ConnectDBPg()
|
||||
database.ConnectRedis()
|
||||
migrate.Migrate()
|
||||
// migrate.MigratePg()
|
||||
seeder.Seed()
|
||||
if config.Logger != nil {
|
||||
config.Logger.Info("Init Uygulandı !!")
|
||||
}
|
||||
|
||||
}
|
||||
func main() {
|
||||
// Initialize standard Go html template engine
|
||||
// Use absolute path for safety
|
||||
cwd, _ := os.Getwd()
|
||||
engine := html.New(cwd+"/views", ".html")
|
||||
|
||||
// Add helper function to handle *bool and bool in templates
|
||||
engine.AddFunc("isTrue", func(val interface{}) bool {
|
||||
if val == nil {
|
||||
return false
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case *bool:
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return *v
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
engine.Reload(true) // Enable reload for development
|
||||
|
||||
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,
|
||||
Views: engine, // Register the views engine
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// Serve static files from public directory (CSS, JS, Assets)
|
||||
app.Use("/", static.New("./public"))
|
||||
|
||||
// Register Routes
|
||||
// Serve generated swagger.json from registered docs (swag)
|
||||
app.Get("/swagger/swagger.json", func(c fiber.Ctx) error {
|
||||
doc, err := swag.ReadDoc("swagger")
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "swagger doc not found"})
|
||||
}
|
||||
return c.Type("json").SendString(doc)
|
||||
})
|
||||
// Simple Swagger UI page using CDN (points to /swagger/swagger.json)
|
||||
app.Get("/swagger/*", func(c fiber.Ctx) error {
|
||||
html := `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: '/swagger/swagger.json',
|
||||
dom_id: '#swagger-ui'
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
return c.Type("html").SendString(html)
|
||||
})
|
||||
routes.RouterUser(app)
|
||||
routes.RouterAdmin(app)
|
||||
// Start the server using port from config
|
||||
if config.AppConfig != nil && config.AppConfig.Port != "" {
|
||||
if err := app.Listen(":" + config.AppConfig.Port); err != nil {
|
||||
config.Logger.Sugar().Fatalf("Server başlatılamadı: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := app.Listen(":8080"); err != nil {
|
||||
config.Logger.Sugar().Fatalf("Server başlatılamadı: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
108
middlewares/auth_middleware.go
Normal file
108
middlewares/auth_middleware.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ares/services"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
const authClaimsKey = "auth_claims"
|
||||
|
||||
func RequireAuth(c fiber.Ctx) error {
|
||||
// First try Authorization header (Bearer JWT)
|
||||
authHeader := strings.TrimSpace(c.Get("Authorization"))
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" {
|
||||
// If request originates from browser/HTMX, redirect to login instead of returning JSON
|
||||
if c.Get("HX-Request") == "true" {
|
||||
c.Set("HX-Redirect", "/login")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
accept := strings.ToLower(c.Get("Accept"))
|
||||
if strings.Contains(accept, "text/html") || strings.HasPrefix(c.Path(), "/admin") {
|
||||
return c.Redirect().To("/login")
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization format, expected: Bearer <token>"})
|
||||
}
|
||||
jwtService := services.NewJWTService()
|
||||
claims, err := jwtService.ValidateToken(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
if c.Get("HX-Request") == "true" {
|
||||
c.Set("HX-Redirect", "/login")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
accept := strings.ToLower(c.Get("Accept"))
|
||||
if strings.Contains(accept, "text/html") || strings.HasPrefix(c.Path(), "/admin") {
|
||||
return c.Redirect().To("/login")
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"})
|
||||
}
|
||||
if claims.TokenType != services.TokenTypeAccess {
|
||||
if c.Get("HX-Request") == "true" {
|
||||
c.Set("HX-Redirect", "/login")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
accept := strings.ToLower(c.Get("Accept"))
|
||||
if strings.Contains(accept, "text/html") || strings.HasPrefix(c.Path(), "/admin") {
|
||||
return c.Redirect().To("/login")
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "access token required"})
|
||||
}
|
||||
c.Locals(authClaimsKey, claims)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Fallback: check cookie-based admin session (browser login) — expect signed JWT
|
||||
cookie := c.Cookies("admin_session")
|
||||
if cookie != "" {
|
||||
jwtService := services.NewJWTService()
|
||||
if claims, err := jwtService.ValidateToken(cookie); err == nil {
|
||||
if claims.TokenType == services.TokenTypeAccess {
|
||||
c.Locals(authClaimsKey, claims)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default unauthorized response: redirect to login for browser requests, JSON for API clients
|
||||
if c.Get("HX-Request") == "true" {
|
||||
c.Set("HX-Redirect", "/login")
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
accept := strings.ToLower(c.Get("Accept"))
|
||||
if strings.Contains(accept, "text/html") || strings.HasPrefix(c.Path(), "/admin") {
|
||||
return c.Redirect().To("/login")
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "authorization header is required"})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
151
middlewares/dynamic_cors.go
Normal file
151
middlewares/dynamic_cors.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
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] {
|
||||
// use project logger if available
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Warn("cors blocked - blacklist", zapFieldsForCORS(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] {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Warn("cors blocked - not whitelisted", zapFieldsForCORS(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 zapFieldsForCORS(origin, path string) []zap.Field {
|
||||
return []zap.Field{
|
||||
zap.String("origin", origin),
|
||||
zap.String("path", path),
|
||||
}
|
||||
}
|
||||
|
||||
func corsLogf(format string, args ...interface{}) {
|
||||
if configs.AppConfig != nil && configs.AppConfig.CorsDebug {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Infof(format, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
136
middlewares/rate_limit.go
Normal file
136
middlewares/rate_limit.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
configs "ares/config"
|
||||
database "ares/database/config"
|
||||
"ares/database/models"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"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))
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Warn("rate-limit blocked", zapFieldsForRateLimit(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 zapFieldsForRateLimit(name, ip string, count, max int64, window int) []zap.Field {
|
||||
return []zap.Field{
|
||||
zap.String("name", name),
|
||||
zap.String("ip", ip),
|
||||
zap.Int64("count", count),
|
||||
zap.Int64("max", max),
|
||||
zap.Int("window_seconds", window),
|
||||
}
|
||||
}
|
||||
|
||||
func rateLimitLogf(format string, args ...interface{}) {
|
||||
if configs.AppConfig != nil && configs.AppConfig.CorsDebug {
|
||||
if configs.Logger != nil {
|
||||
configs.Logger.Sugar().Infof(format, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
middlewares/reject_all_middleware.go
Normal file
12
middlewares/reject_all_middleware.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// RejectAll middleware tüm gelen istekleri reddeder ve HTTP 500 döner.
|
||||
func RejectAll() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "internal server error"})
|
||||
}
|
||||
}
|
||||
48
pkg/utis/slug.go
Normal file
48
pkg/utis/slug.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Slugify converts a string to a URL-friendly slug, replacing Turkish characters
|
||||
// with ASCII equivalents and ensuring lowercase, hyphen-separated result.
|
||||
func Slugify(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Map Turkish characters to ASCII
|
||||
replacer := strings.NewReplacer(
|
||||
"ç", "c", "Ç", "c",
|
||||
"ğ", "g", "Ğ", "g",
|
||||
"ı", "i", "İ", "i",
|
||||
"ö", "o", "Ö", "o",
|
||||
"ş", "s", "Ş", "s",
|
||||
"ü", "u", "Ü", "u",
|
||||
"’", "", "'", "",
|
||||
)
|
||||
s = replacer.Replace(s)
|
||||
|
||||
// Normalize: keep letters, numbers, and spaces
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
// convert other punctuation to space
|
||||
b.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
out := strings.ToLower(b.String())
|
||||
|
||||
// replace spaces with hyphens and collapse multiple hyphens
|
||||
out = strings.TrimSpace(out)
|
||||
// replace any sequence of non-alnum with hyphen
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
out = re.ReplaceAllString(out, "-")
|
||||
out = strings.Trim(out, "-")
|
||||
return out
|
||||
}
|
||||
15
pkg/utis/token.go
Normal file
15
pkg/utis/token.go
Normal file
@@ -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
|
||||
}
|
||||
146
public/admin/css/theme.css
Normal file
146
public/admin/css/theme.css
Normal file
@@ -0,0 +1,146 @@
|
||||
/* Admin Panel Theme CSS */
|
||||
|
||||
:root {
|
||||
--sidebar-width: 250px;
|
||||
--header-height: 60px;
|
||||
--transition-speed: 0.3s;
|
||||
}
|
||||
|
||||
[data-bs-theme="light"] {
|
||||
--bg-body: #f8f9fa;
|
||||
--bg-sidebar: #ffffff;
|
||||
--text-sidebar: #333333;
|
||||
--border-color: #e9ecef;
|
||||
--accent-color: #0d6efd;
|
||||
--accent-color-rgb: 13,110,253;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--bg-body: #212529;
|
||||
--bg-sidebar: #1a1d20;
|
||||
--text-sidebar: #e9ecef;
|
||||
--border-color: #343a40;
|
||||
--accent-color: #ffc107; /* Şeftali tonu veya sarımsı */
|
||||
--accent-color-rgb: 255,193,7;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-body);
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
#sidebar {
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: var(--bg-sidebar);
|
||||
color: var(--text-sidebar);
|
||||
border-right: 1px solid var(--border-color);
|
||||
transition: transform var(--transition-speed);
|
||||
z-index: 1040;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
color: var(--text-sidebar);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover,
|
||||
.sidebar-nav .nav-link.active {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
border-left-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
#main-header {
|
||||
height: var(--header-height);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: var(--sidebar-width);
|
||||
right: 0;
|
||||
background-color: var(--bg-sidebar);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: 1030;
|
||||
transition: left var(--transition-speed);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
#main-content-wrapper {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding-top: var(--header-height);
|
||||
transition: margin-left var(--transition-speed);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Mobile Sidebar Behavior */
|
||||
@media (max-width: 991.98px) {
|
||||
#sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
#sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#main-header {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#main-content-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Turnstile Widget Placeholder Style */
|
||||
.cf-turnstile {
|
||||
margin: 1rem 0;
|
||||
min-height: 65px;
|
||||
}
|
||||
|
||||
/* Logo preview helpers: force preview backgrounds for white/black logos */
|
||||
.logo-preview {
|
||||
display: inline-block;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.logo-preview-dark {
|
||||
/* stronger, always-visible dark preview (for white logos) */
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.logo-preview-light {
|
||||
/* white preview for dark/black logos */
|
||||
background-color: #ffffff !important;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||||
}
|
||||
63
public/admin/js/main.js
Normal file
63
public/admin/js/main.js
Normal file
@@ -0,0 +1,63 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.store('theme', {
|
||||
mode: localStorage.getItem('theme') || 'light',
|
||||
|
||||
toggle() {
|
||||
this.mode = this.mode === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem('theme', this.mode);
|
||||
this.apply();
|
||||
},
|
||||
|
||||
apply() {
|
||||
document.documentElement.setAttribute('data-bs-theme', this.mode);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply theme on init
|
||||
Alpine.store('theme').apply();
|
||||
|
||||
Alpine.store('sidebar', {
|
||||
open: false,
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (this.open) {
|
||||
sidebar.classList.add('show');
|
||||
} else {
|
||||
sidebar.classList.remove('show');
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
document.getElementById('sidebar').classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// HTMX Configuration
|
||||
document.addEventListener('htmx:configRequest', (event) => {
|
||||
// Add CSRF token if available (implementation dependent)
|
||||
// event.detail.headers['X-CSRF-Token'] = getCsrfToken();
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', (event) => {
|
||||
// Handle 401 Unauthorized by redirecting to login
|
||||
if (event.detail.xhr.status === 401) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile helper: Close sidebar on outside click (simple implementation)
|
||||
document.addEventListener('click', (e) => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleBtn = document.querySelector('[data-bs-target="#sidebar"]'); // Adjust selector as needed
|
||||
|
||||
// Only on mobile
|
||||
if (window.innerWidth < 992 && Alpine.store('sidebar').open) {
|
||||
if (!sidebar.contains(e.target) && !toggleBtn.contains(e.target)) {
|
||||
Alpine.store('sidebar').close();
|
||||
}
|
||||
}
|
||||
});
|
||||
77
public/assets/.package-lock.json
generated
Normal file
77
public/assets/.package-lock.json
generated
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.8",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz",
|
||||
"integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/htmx": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmx/-/htmx-0.0.2.tgz",
|
||||
"integrity": "sha512-FfUo3ynRYr6Ra4vqmS4Nq9g47607FSmvHYCOuU8bvbW8s4kPMhAmCbMBjuW2cEZI6DauaFNZKinfgV91cc9Feg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"htmx": "htmx.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jquery": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
public/assets/@popperjs/core/LICENSE.md
Normal file
20
public/assets/@popperjs/core/LICENSE.md
Normal file
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019 Federico Zivolo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
376
public/assets/@popperjs/core/README.md
Normal file
376
public/assets/@popperjs/core/README.md
Normal file
@@ -0,0 +1,376 @@
|
||||
<!-- <HEADER> // IGNORE IT -->
|
||||
<p align="center">
|
||||
<img src="https://rawcdn.githack.com/popperjs/popper-core/8805a5d7599e14619c9e7ac19a3713285d8e5d7f/docs/src/images/popper-logo-outlined.svg" alt="Popper" height="300px"/>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<h1>Tooltip & Popover Positioning Engine</h1>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@popperjs/core">
|
||||
<img src="https://img.shields.io/npm/v/@popperjs/core?style=for-the-badge" alt="npm version" />
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@popperjs/core">
|
||||
<img src="https://img.shields.io/endpoint?style=for-the-badge&url=https://runkit.io/fezvrasta/combined-npm-downloads/1.0.0?packages=popper.js,@popperjs/core" alt="npm downloads per month (popper.js + @popperjs/core)" />
|
||||
</a>
|
||||
<a href="https://rollingversions.com/popperjs/popper-core">
|
||||
<img src="https://img.shields.io/badge/Rolling%20Versions-Enabled-brightgreen?style=for-the-badge" alt="Rolling Versions" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<!-- </HEADER> // NOW BEGINS THE README -->
|
||||
|
||||
**Positioning tooltips and popovers is difficult. Popper is here to help!**
|
||||
|
||||
Given an element, such as a button, and a tooltip element describing it, Popper
|
||||
will automatically put the tooltip in the right place near the button.
|
||||
|
||||
It will position _any_ UI element that "pops out" from the flow of your document
|
||||
and floats near a target element. The most common example is a tooltip, but it
|
||||
also includes popovers, drop-downs, and more. All of these can be generically
|
||||
described as a "popper" element.
|
||||
|
||||
## Demo
|
||||
|
||||
[](https://popper.js.org)
|
||||
|
||||
## Docs
|
||||
|
||||
- [v2.x (latest)](https://popper.js.org/docs/v2/)
|
||||
- [v1.x](https://popper.js.org/docs/v1/)
|
||||
|
||||
We've created a
|
||||
[Migration Guide](https://popper.js.org/docs/v2/migration-guide/) to help you
|
||||
migrate from Popper 1 to Popper 2.
|
||||
|
||||
To contribute to the Popper website and documentation, please visit the
|
||||
[dedicated repository](https://github.com/popperjs/website).
|
||||
|
||||
## Why not use pure CSS?
|
||||
|
||||
- **Clipping and overflow issues**: Pure CSS poppers will not be prevented from
|
||||
overflowing clipping boundaries, such as the viewport. It will get partially
|
||||
cut off or overflows if it's near the edge since there is no dynamic
|
||||
positioning logic. When using Popper, your popper will always be positioned in
|
||||
the right place without needing manual adjustments.
|
||||
- **No flipping**: CSS poppers will not flip to a different placement to fit
|
||||
better in view if necessary. While you can manually adjust for the main axis
|
||||
overflow, this feature cannot be achieved via CSS alone. Popper automatically
|
||||
flips the tooltip to make it fit in view as best as possible for the user.
|
||||
- **No virtual positioning**: CSS poppers cannot follow the mouse cursor or be
|
||||
used as a context menu. Popper allows you to position your tooltip relative to
|
||||
any coordinates you desire.
|
||||
- **Slower development cycle**: When pure CSS is used to position popper
|
||||
elements, the lack of dynamic positioning means they must be carefully placed
|
||||
to consider overflow on all screen sizes. In reusable component libraries,
|
||||
this means a developer can't just add the component anywhere on the page,
|
||||
because these issues need to be considered and adjusted for every time. With
|
||||
Popper, you can place your elements anywhere and they will be positioned
|
||||
correctly, without needing to consider different screen sizes, layouts, etc.
|
||||
This massively speeds up development time because this work is automatically
|
||||
offloaded to Popper.
|
||||
- **Lack of extensibility**: CSS poppers cannot be easily extended to fit any
|
||||
arbitrary use case you may need to adjust for. Popper is built with
|
||||
extensibility in mind.
|
||||
|
||||
## Why Popper?
|
||||
|
||||
With the CSS drawbacks out of the way, we now move on to Popper in the
|
||||
JavaScript space itself.
|
||||
|
||||
Naive JavaScript tooltip implementations usually have the following problems:
|
||||
|
||||
- **Scrolling containers**: They don't ensure the tooltip stays with the
|
||||
reference element while scrolling when inside any number of scrolling
|
||||
containers.
|
||||
- **DOM context**: They often require the tooltip move outside of its original
|
||||
DOM context because they don't handle `offsetParent` contexts.
|
||||
- **Compatibility**: Popper handles an incredible number of edge cases regarding
|
||||
different browsers and environments (mobile viewports, RTL, scrollbars enabled
|
||||
or disabled, etc.). Popper is a popular and well-maintained library, so you
|
||||
can be confident positioning will work for your users on any device.
|
||||
- **Configurability**: They often lack advanced configurability to suit any
|
||||
possible use case.
|
||||
- **Size**: They are usually relatively large in size, or require an ancient
|
||||
jQuery dependency.
|
||||
- **Performance**: They often have runtime performance issues and update the
|
||||
tooltip position too slowly.
|
||||
|
||||
**Popper solves all of these key problems in an elegant, performant manner.** It
|
||||
is a lightweight ~3 kB library that aims to provide a reliable and extensible
|
||||
positioning engine you can use to ensure all your popper elements are positioned
|
||||
in the right place.
|
||||
|
||||
When you start writing your own popper implementation, you'll quickly run into
|
||||
all of the problems mentioned above. These widgets are incredibly common in our
|
||||
UIs; we've done the hard work figuring this out so you don't need to spend hours
|
||||
fixing and handling numerous edge cases that we already ran into while building
|
||||
the library!
|
||||
|
||||
Popper is used in popular libraries like Bootstrap, Foundation, Material UI, and
|
||||
more. It's likely you've already used popper elements on the web positioned by
|
||||
Popper at some point in the past few years.
|
||||
|
||||
Since we write UIs using powerful abstraction libraries such as React or Angular
|
||||
nowadays, you'll also be glad to know Popper can fully integrate with them and
|
||||
be a good citizen together with your other components. Check out `react-popper`
|
||||
for the official Popper wrapper for React.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Package Manager
|
||||
|
||||
```bash
|
||||
# With npm
|
||||
npm i @popperjs/core
|
||||
|
||||
# With Yarn
|
||||
yarn add @popperjs/core
|
||||
```
|
||||
|
||||
### 2. CDN
|
||||
|
||||
```html
|
||||
<!-- Development version -->
|
||||
<script src="https://unpkg.com/@popperjs/core@2/dist/umd/popper.js"></script>
|
||||
|
||||
<!-- Production version -->
|
||||
<script src="https://unpkg.com/@popperjs/core@2"></script>
|
||||
```
|
||||
|
||||
### 3. Direct Download?
|
||||
|
||||
Managing dependencies by "directly downloading" them and placing them into your
|
||||
source code is not recommended for a variety of reasons, including missing out
|
||||
on feat/fix updates easily. Please use a versioning management system like a CDN
|
||||
or npm/Yarn.
|
||||
|
||||
## Usage
|
||||
|
||||
The most straightforward way to get started is to import Popper from the `unpkg`
|
||||
CDN, which includes all of its features. You can call the `Popper.createPopper`
|
||||
constructor to create new popper instances.
|
||||
|
||||
Here is a complete example:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<title>Popper example</title>
|
||||
|
||||
<style>
|
||||
#tooltip {
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button id="button" aria-describedby="tooltip">I'm a button</button>
|
||||
<div id="tooltip" role="tooltip">I'm a tooltip</div>
|
||||
|
||||
<script src="https://unpkg.com/@popperjs/core@^2.0.0"></script>
|
||||
<script>
|
||||
const button = document.querySelector('#button');
|
||||
const tooltip = document.querySelector('#tooltip');
|
||||
|
||||
// Pass the button, the tooltip, and some options, and Popper will do the
|
||||
// magic positioning for you:
|
||||
Popper.createPopper(button, tooltip, {
|
||||
placement: 'right',
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Visit the [tutorial](https://popper.js.org/docs/v2/tutorial/) for an example of
|
||||
how to build your own tooltip from scratch using Popper.
|
||||
|
||||
### Module bundlers
|
||||
|
||||
You can import the `createPopper` constructor from the fully-featured file:
|
||||
|
||||
```js
|
||||
import { createPopper } from '@popperjs/core';
|
||||
|
||||
const button = document.querySelector('#button');
|
||||
const tooltip = document.querySelector('#tooltip');
|
||||
|
||||
// Pass the button, the tooltip, and some options, and Popper will do the
|
||||
// magic positioning for you:
|
||||
createPopper(button, tooltip, {
|
||||
placement: 'right',
|
||||
});
|
||||
```
|
||||
|
||||
All the modifiers listed in the docs menu will be enabled and "just work", so
|
||||
you don't need to think about setting Popper up. The size of Popper including
|
||||
all of its features is about 5 kB minzipped, but it may grow a bit in the
|
||||
future.
|
||||
|
||||
#### Popper Lite (tree-shaking)
|
||||
|
||||
If bundle size is important, you'll want to take advantage of tree-shaking. The
|
||||
library is built in a modular way to allow to import only the parts you really
|
||||
need.
|
||||
|
||||
```js
|
||||
import { createPopperLite as createPopper } from '@popperjs/core';
|
||||
```
|
||||
|
||||
The Lite version includes the most necessary modifiers that will compute the
|
||||
offsets of the popper, compute and add the positioning styles, and add event
|
||||
listeners. This is close in bundle size to pure CSS tooltip libraries, and
|
||||
behaves somewhat similarly.
|
||||
|
||||
However, this does not include the features that makes Popper truly useful.
|
||||
|
||||
The two most useful modifiers not included in Lite are `preventOverflow` and
|
||||
`flip`:
|
||||
|
||||
```js
|
||||
import {
|
||||
createPopperLite as createPopper,
|
||||
preventOverflow,
|
||||
flip,
|
||||
} from '@popperjs/core';
|
||||
|
||||
const button = document.querySelector('#button');
|
||||
const tooltip = document.querySelector('#tooltip');
|
||||
|
||||
createPopper(button, tooltip, {
|
||||
modifiers: [preventOverflow, flip],
|
||||
});
|
||||
```
|
||||
|
||||
As you make more poppers, you may be finding yourself needing other modifiers
|
||||
provided by the library.
|
||||
|
||||
See [tree-shaking](https://popper.js.org/docs/v2/performance/#tree-shaking) for more
|
||||
information.
|
||||
|
||||
## Distribution targets
|
||||
|
||||
Popper is distributed in 3 different versions, in 3 different file formats.
|
||||
|
||||
The 3 file formats are:
|
||||
|
||||
- `esm` (works with `import` syntax — **recommended**)
|
||||
- `umd` (works with `<script>` tags or RequireJS)
|
||||
- `cjs` (works with `require()` syntax)
|
||||
|
||||
There are two different `esm` builds, one for bundler consumers (e.g. webpack,
|
||||
Rollup, etc..), which is located under `/lib`, and one for browsers with native
|
||||
support for ES Modules, under `/dist/esm`. The only difference within the two,
|
||||
is that the browser-compatible version doesn't make use of
|
||||
`process.env.NODE_ENV` to run development checks.
|
||||
|
||||
The 3 versions are:
|
||||
|
||||
- `popper`: includes all the modifiers (features) in one file (**default**);
|
||||
- `popper-lite`: includes only the minimum amount of modifiers to provide the
|
||||
basic functionality;
|
||||
- `popper-base`: doesn't include any modifier, you must import them separately;
|
||||
|
||||
Below you can find the size of each version, minified and compressed with the
|
||||
[Brotli compression algorithm](https://medium.com/groww-engineering/enable-brotli-compression-in-webpack-with-fallback-to-gzip-397a57cf9fc6):
|
||||
|
||||
<!-- Don't change the labels to use hyphens, it breaks, even when encoded -->
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Hacking the library
|
||||
|
||||
If you want to play with the library, implement new features, fix a bug you
|
||||
found, or simply experiment with it, this section is for you!
|
||||
|
||||
First of all, make sure to have
|
||||
[Yarn installed](https://yarnpkg.com/lang/en/docs/install).
|
||||
|
||||
Install the development dependencies:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
And run the development environment:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Then, simply open one the development server web page:
|
||||
|
||||
```bash
|
||||
# macOS and Linux
|
||||
open localhost:5000
|
||||
|
||||
# Windows
|
||||
start localhost:5000
|
||||
```
|
||||
|
||||
From there, you can open any of the examples (`.html` files) to fiddle with
|
||||
them.
|
||||
|
||||
Now any change you will made to the source code, will be automatically compiled,
|
||||
you just need to refresh the page.
|
||||
|
||||
If the page is not working properly, try to go in _"Developer Tools >
|
||||
Application > Clear storage"_ and click on "_Clear site data_".
|
||||
To run the examples you need a browser with
|
||||
[JavaScript modules via script tag support](https://caniuse.com/#feat=es6-module).
|
||||
|
||||
## Test Suite
|
||||
|
||||
Popper is currently tested with unit tests, and functional tests. Both of them
|
||||
are run by Jest.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
The unit tests use JSDOM to provide a primitive document object API, they are
|
||||
used to ensure the utility functions behave as expected in isolation.
|
||||
|
||||
### Functional Tests
|
||||
|
||||
The functional tests run with Puppeteer, to take advantage of a complete browser
|
||||
environment. They are currently running on Chromium, and Firefox.
|
||||
|
||||
You can run them with `yarn test:functional`. Set the `PUPPETEER_BROWSER`
|
||||
environment variable to `firefox` to run them on the Mozilla browser.
|
||||
|
||||
The assertions are written in form of image snapshots, so that it's easy to
|
||||
assert for the correct Popper behavior without having to write a lot of offsets
|
||||
comparisons manually.
|
||||
|
||||
You can mark a `*.test.js` file to run in the Puppeteer environment by
|
||||
prepending a `@jest-environment puppeteer` JSDoc comment to the interested file.
|
||||
|
||||
Here's an example of a basic functional test:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @jest-environment puppeteer
|
||||
* @flow
|
||||
*/
|
||||
import { screenshot } from '../utils/puppeteer.js';
|
||||
|
||||
it('should position the popper on the right', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${TEST_URL}/basic.html`);
|
||||
|
||||
expect(await screenshot(page)).toMatchImageSnapshot();
|
||||
});
|
||||
```
|
||||
|
||||
You can find the complete
|
||||
[`jest-puppeteer` documentation here](https://github.com/smooth-code/jest-puppeteer#api),
|
||||
and the
|
||||
[`jest-image-snapshot` documentation here](https://github.com/americanexpress/jest-image-snapshot#%EF%B8%8F-api).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
65
public/assets/@popperjs/core/dist/cjs/enums.js
vendored
Normal file
65
public/assets/@popperjs/core/dist/cjs/enums.js
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @popperjs/core v2.11.8 - MIT License
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
var top = 'top';
|
||||
var bottom = 'bottom';
|
||||
var right = 'right';
|
||||
var left = 'left';
|
||||
var auto = 'auto';
|
||||
var basePlacements = [top, bottom, right, left];
|
||||
var start = 'start';
|
||||
var end = 'end';
|
||||
var clippingParents = 'clippingParents';
|
||||
var viewport = 'viewport';
|
||||
var popper = 'popper';
|
||||
var reference = 'reference';
|
||||
var variationPlacements = /*#__PURE__*/basePlacements.reduce(function (acc, placement) {
|
||||
return acc.concat([placement + "-" + start, placement + "-" + end]);
|
||||
}, []);
|
||||
var placements = /*#__PURE__*/[].concat(basePlacements, [auto]).reduce(function (acc, placement) {
|
||||
return acc.concat([placement, placement + "-" + start, placement + "-" + end]);
|
||||
}, []); // modifiers that need to read the DOM
|
||||
|
||||
var beforeRead = 'beforeRead';
|
||||
var read = 'read';
|
||||
var afterRead = 'afterRead'; // pure-logic modifiers
|
||||
|
||||
var beforeMain = 'beforeMain';
|
||||
var main = 'main';
|
||||
var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state)
|
||||
|
||||
var beforeWrite = 'beforeWrite';
|
||||
var write = 'write';
|
||||
var afterWrite = 'afterWrite';
|
||||
var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite];
|
||||
|
||||
exports.afterMain = afterMain;
|
||||
exports.afterRead = afterRead;
|
||||
exports.afterWrite = afterWrite;
|
||||
exports.auto = auto;
|
||||
exports.basePlacements = basePlacements;
|
||||
exports.beforeMain = beforeMain;
|
||||
exports.beforeRead = beforeRead;
|
||||
exports.beforeWrite = beforeWrite;
|
||||
exports.bottom = bottom;
|
||||
exports.clippingParents = clippingParents;
|
||||
exports.end = end;
|
||||
exports.left = left;
|
||||
exports.main = main;
|
||||
exports.modifierPhases = modifierPhases;
|
||||
exports.placements = placements;
|
||||
exports.popper = popper;
|
||||
exports.read = read;
|
||||
exports.reference = reference;
|
||||
exports.right = right;
|
||||
exports.start = start;
|
||||
exports.top = top;
|
||||
exports.variationPlacements = variationPlacements;
|
||||
exports.viewport = viewport;
|
||||
exports.write = write;
|
||||
//# sourceMappingURL=enums.js.map
|
||||
3
public/assets/@popperjs/core/dist/cjs/enums.js.flow
vendored
Normal file
3
public/assets/@popperjs/core/dist/cjs/enums.js.flow
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
|
||||
export * from '../../lib/enums.js'
|
||||
1
public/assets/@popperjs/core/dist/cjs/enums.js.map
vendored
Normal file
1
public/assets/@popperjs/core/dist/cjs/enums.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"enums.js","sources":["../../src/enums.js"],"sourcesContent":["// @flow\nexport const top: 'top' = 'top';\nexport const bottom: 'bottom' = 'bottom';\nexport const right: 'right' = 'right';\nexport const left: 'left' = 'left';\nexport const auto: 'auto' = 'auto';\nexport type BasePlacement =\n | typeof top\n | typeof bottom\n | typeof right\n | typeof left;\nexport const basePlacements: Array<BasePlacement> = [top, bottom, right, left];\n\nexport const start: 'start' = 'start';\nexport const end: 'end' = 'end';\nexport type Variation = typeof start | typeof end;\n\nexport const clippingParents: 'clippingParents' = 'clippingParents';\nexport const viewport: 'viewport' = 'viewport';\nexport type Boundary = Element | Array<Element> | typeof clippingParents;\nexport type RootBoundary = typeof viewport | 'document';\n\nexport const popper: 'popper' = 'popper';\nexport const reference: 'reference' = 'reference';\nexport type Context = typeof popper | typeof reference;\n\nexport type VariationPlacement =\n | 'top-start'\n | 'top-end'\n | 'bottom-start'\n | 'bottom-end'\n | 'right-start'\n | 'right-end'\n | 'left-start'\n | 'left-end';\nexport type AutoPlacement = 'auto' | 'auto-start' | 'auto-end';\nexport type ComputedPlacement = VariationPlacement | BasePlacement;\nexport type Placement = AutoPlacement | BasePlacement | VariationPlacement;\n\nexport const variationPlacements: Array<VariationPlacement> = basePlacements.reduce(\n (acc: Array<VariationPlacement>, placement: BasePlacement) =>\n acc.concat([(`${placement}-${start}`: any), (`${placement}-${end}`: any)]),\n []\n);\nexport const placements: Array<Placement> = [...basePlacements, auto].reduce(\n (\n acc: Array<Placement>,\n placement: BasePlacement | typeof auto\n ): Array<Placement> =>\n acc.concat([\n placement,\n (`${placement}-${start}`: any),\n (`${placement}-${end}`: any),\n ]),\n []\n);\n\n// modifiers that need to read the DOM\nexport const beforeRead: 'beforeRead' = 'beforeRead';\nexport const read: 'read' = 'read';\nexport const afterRead: 'afterRead' = 'afterRead';\n// pure-logic modifiers\nexport const beforeMain: 'beforeMain' = 'beforeMain';\nexport const main: 'main' = 'main';\nexport const afterMain: 'afterMain' = 'afterMain';\n// modifier with the purpose to write to the DOM (or write into a framework state)\nexport const beforeWrite: 'beforeWrite' = 'beforeWrite';\nexport const write: 'write' = 'write';\nexport const afterWrite: 'afterWrite' = 'afterWrite';\nexport const modifierPhases: Array<ModifierPhases> = [\n beforeRead,\n read,\n afterRead,\n beforeMain,\n main,\n afterMain,\n beforeWrite,\n write,\n afterWrite,\n];\n\nexport type ModifierPhases =\n | typeof beforeRead\n | typeof read\n | typeof afterRead\n | typeof beforeMain\n | typeof main\n | typeof afterMain\n | typeof beforeWrite\n | typeof write\n | typeof afterWrite;\n"],"names":["top","bottom","right","left","auto","basePlacements","start","end","clippingParents","viewport","popper","reference","variationPlacements","reduce","acc","placement","concat","placements","beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite","modifierPhases"],"mappings":";;;;;;;;IACaA,GAAU,GAAG;IACbC,MAAgB,GAAG;IACnBC,KAAc,GAAG;IACjBC,IAAY,GAAG;IACfC,IAAY,GAAG;IAMfC,cAAoC,GAAG,CAACL,GAAD,EAAMC,MAAN,EAAcC,KAAd,EAAqBC,IAArB;IAEvCG,KAAc,GAAG;IACjBC,GAAU,GAAG;IAGbC,eAAkC,GAAG;IACrCC,QAAoB,GAAG;IAIvBC,MAAgB,GAAG;IACnBC,SAAsB,GAAG;IAgBzBC,mBAA8C,gBAAGP,cAAc,CAACQ,MAAf,CAC5D,UAACC,GAAD,EAAiCC,SAAjC;AAAA,SACED,GAAG,CAACE,MAAJ,CAAW,CAAKD,SAAL,SAAkBT,KAAlB,EAAqCS,SAArC,SAAkDR,GAAlD,CAAX,CADF;AAAA,CAD4D,EAG5D,EAH4D;IAKjDU,UAA4B,gBAAG,UAAIZ,cAAJ,GAAoBD,IAApB,GAA0BS,MAA1B,CAC1C,UACEC,GADF,EAEEC,SAFF;AAAA,SAIED,GAAG,CAACE,MAAJ,CAAW,CACTD,SADS,EAELA,SAFK,SAEQT,KAFR,EAGLS,SAHK,SAGQR,GAHR,CAAX,CAJF;AAAA,CAD0C,EAU1C,EAV0C;;IAc/BW,UAAwB,GAAG;IAC3BC,IAAY,GAAG;IACfC,SAAsB,GAAG;;IAEzBC,UAAwB,GAAG;IAC3BC,IAAY,GAAG;IACfC,SAAsB,GAAG;;IAEzBC,WAA0B,GAAG;IAC7BC,KAAc,GAAG;IACjBC,UAAwB,GAAG;IAC3BC,cAAqC,GAAG,CACnDT,UADmD,EAEnDC,IAFmD,EAGnDC,SAHmD,EAInDC,UAJmD,EAKnDC,IALmD,EAMnDC,SANmD,EAOnDC,WAPmD,EAQnDC,KARmD,EASnDC,UATmD;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
||||
939
public/assets/@popperjs/core/dist/cjs/popper-base.js
vendored
Normal file
939
public/assets/@popperjs/core/dist/cjs/popper-base.js
vendored
Normal file
@@ -0,0 +1,939 @@
|
||||
/**
|
||||
* @popperjs/core v2.11.8 - MIT License
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
function getWindow(node) {
|
||||
if (node == null) {
|
||||
return window;
|
||||
}
|
||||
|
||||
if (node.toString() !== '[object Window]') {
|
||||
var ownerDocument = node.ownerDocument;
|
||||
return ownerDocument ? ownerDocument.defaultView || window : window;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function isElement(node) {
|
||||
var OwnElement = getWindow(node).Element;
|
||||
return node instanceof OwnElement || node instanceof Element;
|
||||
}
|
||||
|
||||
function isHTMLElement(node) {
|
||||
var OwnElement = getWindow(node).HTMLElement;
|
||||
return node instanceof OwnElement || node instanceof HTMLElement;
|
||||
}
|
||||
|
||||
function isShadowRoot(node) {
|
||||
// IE 11 has no ShadowRoot
|
||||
if (typeof ShadowRoot === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var OwnElement = getWindow(node).ShadowRoot;
|
||||
return node instanceof OwnElement || node instanceof ShadowRoot;
|
||||
}
|
||||
|
||||
var max = Math.max;
|
||||
var min = Math.min;
|
||||
var round = Math.round;
|
||||
|
||||
function getUAString() {
|
||||
var uaData = navigator.userAgentData;
|
||||
|
||||
if (uaData != null && uaData.brands && Array.isArray(uaData.brands)) {
|
||||
return uaData.brands.map(function (item) {
|
||||
return item.brand + "/" + item.version;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
return navigator.userAgent;
|
||||
}
|
||||
|
||||
function isLayoutViewport() {
|
||||
return !/^((?!chrome|android).)*safari/i.test(getUAString());
|
||||
}
|
||||
|
||||
function getBoundingClientRect(element, includeScale, isFixedStrategy) {
|
||||
if (includeScale === void 0) {
|
||||
includeScale = false;
|
||||
}
|
||||
|
||||
if (isFixedStrategy === void 0) {
|
||||
isFixedStrategy = false;
|
||||
}
|
||||
|
||||
var clientRect = element.getBoundingClientRect();
|
||||
var scaleX = 1;
|
||||
var scaleY = 1;
|
||||
|
||||
if (includeScale && isHTMLElement(element)) {
|
||||
scaleX = element.offsetWidth > 0 ? round(clientRect.width) / element.offsetWidth || 1 : 1;
|
||||
scaleY = element.offsetHeight > 0 ? round(clientRect.height) / element.offsetHeight || 1 : 1;
|
||||
}
|
||||
|
||||
var _ref = isElement(element) ? getWindow(element) : window,
|
||||
visualViewport = _ref.visualViewport;
|
||||
|
||||
var addVisualOffsets = !isLayoutViewport() && isFixedStrategy;
|
||||
var x = (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / scaleX;
|
||||
var y = (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / scaleY;
|
||||
var width = clientRect.width / scaleX;
|
||||
var height = clientRect.height / scaleY;
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
top: y,
|
||||
right: x + width,
|
||||
bottom: y + height,
|
||||
left: x,
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
}
|
||||
|
||||
function getWindowScroll(node) {
|
||||
var win = getWindow(node);
|
||||
var scrollLeft = win.pageXOffset;
|
||||
var scrollTop = win.pageYOffset;
|
||||
return {
|
||||
scrollLeft: scrollLeft,
|
||||
scrollTop: scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
function getHTMLElementScroll(element) {
|
||||
return {
|
||||
scrollLeft: element.scrollLeft,
|
||||
scrollTop: element.scrollTop
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeScroll(node) {
|
||||
if (node === getWindow(node) || !isHTMLElement(node)) {
|
||||
return getWindowScroll(node);
|
||||
} else {
|
||||
return getHTMLElementScroll(node);
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeName(element) {
|
||||
return element ? (element.nodeName || '').toLowerCase() : null;
|
||||
}
|
||||
|
||||
function getDocumentElement(element) {
|
||||
// $FlowFixMe[incompatible-return]: assume body is always available
|
||||
return ((isElement(element) ? element.ownerDocument : // $FlowFixMe[prop-missing]
|
||||
element.document) || window.document).documentElement;
|
||||
}
|
||||
|
||||
function getWindowScrollBarX(element) {
|
||||
// If <html> has a CSS width greater than the viewport, then this will be
|
||||
// incorrect for RTL.
|
||||
// Popper 1 is broken in this case and never had a bug report so let's assume
|
||||
// it's not an issue. I don't think anyone ever specifies width on <html>
|
||||
// anyway.
|
||||
// Browsers where the left scrollbar doesn't cause an issue report `0` for
|
||||
// this (e.g. Edge 2019, IE11, Safari)
|
||||
return getBoundingClientRect(getDocumentElement(element)).left + getWindowScroll(element).scrollLeft;
|
||||
}
|
||||
|
||||
function getComputedStyle(element) {
|
||||
return getWindow(element).getComputedStyle(element);
|
||||
}
|
||||
|
||||
function isScrollParent(element) {
|
||||
// Firefox wants us to check `-x` and `-y` variations as well
|
||||
var _getComputedStyle = getComputedStyle(element),
|
||||
overflow = _getComputedStyle.overflow,
|
||||
overflowX = _getComputedStyle.overflowX,
|
||||
overflowY = _getComputedStyle.overflowY;
|
||||
|
||||
return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX);
|
||||
}
|
||||
|
||||
function isElementScaled(element) {
|
||||
var rect = element.getBoundingClientRect();
|
||||
var scaleX = round(rect.width) / element.offsetWidth || 1;
|
||||
var scaleY = round(rect.height) / element.offsetHeight || 1;
|
||||
return scaleX !== 1 || scaleY !== 1;
|
||||
} // Returns the composite rect of an element relative to its offsetParent.
|
||||
// Composite means it takes into account transforms as well as layout.
|
||||
|
||||
|
||||
function getCompositeRect(elementOrVirtualElement, offsetParent, isFixed) {
|
||||
if (isFixed === void 0) {
|
||||
isFixed = false;
|
||||
}
|
||||
|
||||
var isOffsetParentAnElement = isHTMLElement(offsetParent);
|
||||
var offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent);
|
||||
var documentElement = getDocumentElement(offsetParent);
|
||||
var rect = getBoundingClientRect(elementOrVirtualElement, offsetParentIsScaled, isFixed);
|
||||
var scroll = {
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0
|
||||
};
|
||||
var offsets = {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
|
||||
if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) {
|
||||
if (getNodeName(offsetParent) !== 'body' || // https://github.com/popperjs/popper-core/issues/1078
|
||||
isScrollParent(documentElement)) {
|
||||
scroll = getNodeScroll(offsetParent);
|
||||
}
|
||||
|
||||
if (isHTMLElement(offsetParent)) {
|
||||
offsets = getBoundingClientRect(offsetParent, true);
|
||||
offsets.x += offsetParent.clientLeft;
|
||||
offsets.y += offsetParent.clientTop;
|
||||
} else if (documentElement) {
|
||||
offsets.x = getWindowScrollBarX(documentElement);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: rect.left + scroll.scrollLeft - offsets.x,
|
||||
y: rect.top + scroll.scrollTop - offsets.y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
}
|
||||
|
||||
// means it doesn't take into account transforms.
|
||||
|
||||
function getLayoutRect(element) {
|
||||
var clientRect = getBoundingClientRect(element); // Use the clientRect sizes if it's not been transformed.
|
||||
// Fixes https://github.com/popperjs/popper-core/issues/1223
|
||||
|
||||
var width = element.offsetWidth;
|
||||
var height = element.offsetHeight;
|
||||
|
||||
if (Math.abs(clientRect.width - width) <= 1) {
|
||||
width = clientRect.width;
|
||||
}
|
||||
|
||||
if (Math.abs(clientRect.height - height) <= 1) {
|
||||
height = clientRect.height;
|
||||
}
|
||||
|
||||
return {
|
||||
x: element.offsetLeft,
|
||||
y: element.offsetTop,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
function getParentNode(element) {
|
||||
if (getNodeName(element) === 'html') {
|
||||
return element;
|
||||
}
|
||||
|
||||
return (// this is a quicker (but less type safe) way to save quite some bytes from the bundle
|
||||
// $FlowFixMe[incompatible-return]
|
||||
// $FlowFixMe[prop-missing]
|
||||
element.assignedSlot || // step into the shadow DOM of the parent of a slotted node
|
||||
element.parentNode || ( // DOM Element detected
|
||||
isShadowRoot(element) ? element.host : null) || // ShadowRoot detected
|
||||
// $FlowFixMe[incompatible-call]: HTMLElement is a Node
|
||||
getDocumentElement(element) // fallback
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
function getScrollParent(node) {
|
||||
if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) {
|
||||
// $FlowFixMe[incompatible-return]: assume body is always available
|
||||
return node.ownerDocument.body;
|
||||
}
|
||||
|
||||
if (isHTMLElement(node) && isScrollParent(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return getScrollParent(getParentNode(node));
|
||||
}
|
||||
|
||||
/*
|
||||
given a DOM element, return the list of all scroll parents, up the list of ancesors
|
||||
until we get to the top window object. This list is what we attach scroll listeners
|
||||
to, because if any of these parent elements scroll, we'll need to re-calculate the
|
||||
reference element's position.
|
||||
*/
|
||||
|
||||
function listScrollParents(element, list) {
|
||||
var _element$ownerDocumen;
|
||||
|
||||
if (list === void 0) {
|
||||
list = [];
|
||||
}
|
||||
|
||||
var scrollParent = getScrollParent(element);
|
||||
var isBody = scrollParent === ((_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body);
|
||||
var win = getWindow(scrollParent);
|
||||
var target = isBody ? [win].concat(win.visualViewport || [], isScrollParent(scrollParent) ? scrollParent : []) : scrollParent;
|
||||
var updatedList = list.concat(target);
|
||||
return isBody ? updatedList : // $FlowFixMe[incompatible-call]: isBody tells us target will be an HTMLElement here
|
||||
updatedList.concat(listScrollParents(getParentNode(target)));
|
||||
}
|
||||
|
||||
function isTableElement(element) {
|
||||
return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0;
|
||||
}
|
||||
|
||||
function getTrueOffsetParent(element) {
|
||||
if (!isHTMLElement(element) || // https://github.com/popperjs/popper-core/issues/837
|
||||
getComputedStyle(element).position === 'fixed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.offsetParent;
|
||||
} // `.offsetParent` reports `null` for fixed elements, while absolute elements
|
||||
// return the containing block
|
||||
|
||||
|
||||
function getContainingBlock(element) {
|
||||
var isFirefox = /firefox/i.test(getUAString());
|
||||
var isIE = /Trident/i.test(getUAString());
|
||||
|
||||
if (isIE && isHTMLElement(element)) {
|
||||
// In IE 9, 10 and 11 fixed elements containing block is always established by the viewport
|
||||
var elementCss = getComputedStyle(element);
|
||||
|
||||
if (elementCss.position === 'fixed') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var currentNode = getParentNode(element);
|
||||
|
||||
if (isShadowRoot(currentNode)) {
|
||||
currentNode = currentNode.host;
|
||||
}
|
||||
|
||||
while (isHTMLElement(currentNode) && ['html', 'body'].indexOf(getNodeName(currentNode)) < 0) {
|
||||
var css = getComputedStyle(currentNode); // This is non-exhaustive but covers the most common CSS properties that
|
||||
// create a containing block.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
|
||||
|
||||
if (css.transform !== 'none' || css.perspective !== 'none' || css.contain === 'paint' || ['transform', 'perspective'].indexOf(css.willChange) !== -1 || isFirefox && css.willChange === 'filter' || isFirefox && css.filter && css.filter !== 'none') {
|
||||
return currentNode;
|
||||
} else {
|
||||
currentNode = currentNode.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} // Gets the closest ancestor positioned element. Handles some edge cases,
|
||||
// such as table ancestors and cross browser bugs.
|
||||
|
||||
|
||||
function getOffsetParent(element) {
|
||||
var window = getWindow(element);
|
||||
var offsetParent = getTrueOffsetParent(element);
|
||||
|
||||
while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {
|
||||
offsetParent = getTrueOffsetParent(offsetParent);
|
||||
}
|
||||
|
||||
if (offsetParent && (getNodeName(offsetParent) === 'html' || getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static')) {
|
||||
return window;
|
||||
}
|
||||
|
||||
return offsetParent || getContainingBlock(element) || window;
|
||||
}
|
||||
|
||||
var top = 'top';
|
||||
var bottom = 'bottom';
|
||||
var right = 'right';
|
||||
var left = 'left';
|
||||
var basePlacements = [top, bottom, right, left];
|
||||
var start = 'start';
|
||||
var end = 'end';
|
||||
var clippingParents = 'clippingParents';
|
||||
var viewport = 'viewport';
|
||||
var popper = 'popper';
|
||||
var reference = 'reference';
|
||||
|
||||
var beforeRead = 'beforeRead';
|
||||
var read = 'read';
|
||||
var afterRead = 'afterRead'; // pure-logic modifiers
|
||||
|
||||
var beforeMain = 'beforeMain';
|
||||
var main = 'main';
|
||||
var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state)
|
||||
|
||||
var beforeWrite = 'beforeWrite';
|
||||
var write = 'write';
|
||||
var afterWrite = 'afterWrite';
|
||||
var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite];
|
||||
|
||||
function order(modifiers) {
|
||||
var map = new Map();
|
||||
var visited = new Set();
|
||||
var result = [];
|
||||
modifiers.forEach(function (modifier) {
|
||||
map.set(modifier.name, modifier);
|
||||
}); // On visiting object, check for its dependencies and visit them recursively
|
||||
|
||||
function sort(modifier) {
|
||||
visited.add(modifier.name);
|
||||
var requires = [].concat(modifier.requires || [], modifier.requiresIfExists || []);
|
||||
requires.forEach(function (dep) {
|
||||
if (!visited.has(dep)) {
|
||||
var depModifier = map.get(dep);
|
||||
|
||||
if (depModifier) {
|
||||
sort(depModifier);
|
||||
}
|
||||
}
|
||||
});
|
||||
result.push(modifier);
|
||||
}
|
||||
|
||||
modifiers.forEach(function (modifier) {
|
||||
if (!visited.has(modifier.name)) {
|
||||
// check for visited object
|
||||
sort(modifier);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function orderModifiers(modifiers) {
|
||||
// order based on dependencies
|
||||
var orderedModifiers = order(modifiers); // order based on phase
|
||||
|
||||
return modifierPhases.reduce(function (acc, phase) {
|
||||
return acc.concat(orderedModifiers.filter(function (modifier) {
|
||||
return modifier.phase === phase;
|
||||
}));
|
||||
}, []);
|
||||
}
|
||||
|
||||
function debounce(fn) {
|
||||
var pending;
|
||||
return function () {
|
||||
if (!pending) {
|
||||
pending = new Promise(function (resolve) {
|
||||
Promise.resolve().then(function () {
|
||||
pending = undefined;
|
||||
resolve(fn());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
|
||||
function mergeByName(modifiers) {
|
||||
var merged = modifiers.reduce(function (merged, current) {
|
||||
var existing = merged[current.name];
|
||||
merged[current.name] = existing ? Object.assign({}, existing, current, {
|
||||
options: Object.assign({}, existing.options, current.options),
|
||||
data: Object.assign({}, existing.data, current.data)
|
||||
}) : current;
|
||||
return merged;
|
||||
}, {}); // IE11 does not support Object.values
|
||||
|
||||
return Object.keys(merged).map(function (key) {
|
||||
return merged[key];
|
||||
});
|
||||
}
|
||||
|
||||
function getViewportRect(element, strategy) {
|
||||
var win = getWindow(element);
|
||||
var html = getDocumentElement(element);
|
||||
var visualViewport = win.visualViewport;
|
||||
var width = html.clientWidth;
|
||||
var height = html.clientHeight;
|
||||
var x = 0;
|
||||
var y = 0;
|
||||
|
||||
if (visualViewport) {
|
||||
width = visualViewport.width;
|
||||
height = visualViewport.height;
|
||||
var layoutViewport = isLayoutViewport();
|
||||
|
||||
if (layoutViewport || !layoutViewport && strategy === 'fixed') {
|
||||
x = visualViewport.offsetLeft;
|
||||
y = visualViewport.offsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
x: x + getWindowScrollBarX(element),
|
||||
y: y
|
||||
};
|
||||
}
|
||||
|
||||
// of the `<html>` and `<body>` rect bounds if horizontally scrollable
|
||||
|
||||
function getDocumentRect(element) {
|
||||
var _element$ownerDocumen;
|
||||
|
||||
var html = getDocumentElement(element);
|
||||
var winScroll = getWindowScroll(element);
|
||||
var body = (_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body;
|
||||
var width = max(html.scrollWidth, html.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0);
|
||||
var height = max(html.scrollHeight, html.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0);
|
||||
var x = -winScroll.scrollLeft + getWindowScrollBarX(element);
|
||||
var y = -winScroll.scrollTop;
|
||||
|
||||
if (getComputedStyle(body || html).direction === 'rtl') {
|
||||
x += max(html.clientWidth, body ? body.clientWidth : 0) - width;
|
||||
}
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
}
|
||||
|
||||
function contains(parent, child) {
|
||||
var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method
|
||||
|
||||
if (parent.contains(child)) {
|
||||
return true;
|
||||
} // then fallback to custom implementation with Shadow DOM support
|
||||
else if (rootNode && isShadowRoot(rootNode)) {
|
||||
var next = child;
|
||||
|
||||
do {
|
||||
if (next && parent.isSameNode(next)) {
|
||||
return true;
|
||||
} // $FlowFixMe[prop-missing]: need a better way to handle this...
|
||||
|
||||
|
||||
next = next.parentNode || next.host;
|
||||
} while (next);
|
||||
} // Give up, the result is false
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function rectToClientRect(rect) {
|
||||
return Object.assign({}, rect, {
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
right: rect.x + rect.width,
|
||||
bottom: rect.y + rect.height
|
||||
});
|
||||
}
|
||||
|
||||
function getInnerBoundingClientRect(element, strategy) {
|
||||
var rect = getBoundingClientRect(element, false, strategy === 'fixed');
|
||||
rect.top = rect.top + element.clientTop;
|
||||
rect.left = rect.left + element.clientLeft;
|
||||
rect.bottom = rect.top + element.clientHeight;
|
||||
rect.right = rect.left + element.clientWidth;
|
||||
rect.width = element.clientWidth;
|
||||
rect.height = element.clientHeight;
|
||||
rect.x = rect.left;
|
||||
rect.y = rect.top;
|
||||
return rect;
|
||||
}
|
||||
|
||||
function getClientRectFromMixedType(element, clippingParent, strategy) {
|
||||
return clippingParent === viewport ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element)));
|
||||
} // A "clipping parent" is an overflowable container with the characteristic of
|
||||
// clipping (or hiding) overflowing elements with a position different from
|
||||
// `initial`
|
||||
|
||||
|
||||
function getClippingParents(element) {
|
||||
var clippingParents = listScrollParents(getParentNode(element));
|
||||
var canEscapeClipping = ['absolute', 'fixed'].indexOf(getComputedStyle(element).position) >= 0;
|
||||
var clipperElement = canEscapeClipping && isHTMLElement(element) ? getOffsetParent(element) : element;
|
||||
|
||||
if (!isElement(clipperElement)) {
|
||||
return [];
|
||||
} // $FlowFixMe[incompatible-return]: https://github.com/facebook/flow/issues/1414
|
||||
|
||||
|
||||
return clippingParents.filter(function (clippingParent) {
|
||||
return isElement(clippingParent) && contains(clippingParent, clipperElement) && getNodeName(clippingParent) !== 'body';
|
||||
});
|
||||
} // Gets the maximum area that the element is visible in due to any number of
|
||||
// clipping parents
|
||||
|
||||
|
||||
function getClippingRect(element, boundary, rootBoundary, strategy) {
|
||||
var mainClippingParents = boundary === 'clippingParents' ? getClippingParents(element) : [].concat(boundary);
|
||||
var clippingParents = [].concat(mainClippingParents, [rootBoundary]);
|
||||
var firstClippingParent = clippingParents[0];
|
||||
var clippingRect = clippingParents.reduce(function (accRect, clippingParent) {
|
||||
var rect = getClientRectFromMixedType(element, clippingParent, strategy);
|
||||
accRect.top = max(rect.top, accRect.top);
|
||||
accRect.right = min(rect.right, accRect.right);
|
||||
accRect.bottom = min(rect.bottom, accRect.bottom);
|
||||
accRect.left = max(rect.left, accRect.left);
|
||||
return accRect;
|
||||
}, getClientRectFromMixedType(element, firstClippingParent, strategy));
|
||||
clippingRect.width = clippingRect.right - clippingRect.left;
|
||||
clippingRect.height = clippingRect.bottom - clippingRect.top;
|
||||
clippingRect.x = clippingRect.left;
|
||||
clippingRect.y = clippingRect.top;
|
||||
return clippingRect;
|
||||
}
|
||||
|
||||
function getBasePlacement(placement) {
|
||||
return placement.split('-')[0];
|
||||
}
|
||||
|
||||
function getVariation(placement) {
|
||||
return placement.split('-')[1];
|
||||
}
|
||||
|
||||
function getMainAxisFromPlacement(placement) {
|
||||
return ['top', 'bottom'].indexOf(placement) >= 0 ? 'x' : 'y';
|
||||
}
|
||||
|
||||
function computeOffsets(_ref) {
|
||||
var reference = _ref.reference,
|
||||
element = _ref.element,
|
||||
placement = _ref.placement;
|
||||
var basePlacement = placement ? getBasePlacement(placement) : null;
|
||||
var variation = placement ? getVariation(placement) : null;
|
||||
var commonX = reference.x + reference.width / 2 - element.width / 2;
|
||||
var commonY = reference.y + reference.height / 2 - element.height / 2;
|
||||
var offsets;
|
||||
|
||||
switch (basePlacement) {
|
||||
case top:
|
||||
offsets = {
|
||||
x: commonX,
|
||||
y: reference.y - element.height
|
||||
};
|
||||
break;
|
||||
|
||||
case bottom:
|
||||
offsets = {
|
||||
x: commonX,
|
||||
y: reference.y + reference.height
|
||||
};
|
||||
break;
|
||||
|
||||
case right:
|
||||
offsets = {
|
||||
x: reference.x + reference.width,
|
||||
y: commonY
|
||||
};
|
||||
break;
|
||||
|
||||
case left:
|
||||
offsets = {
|
||||
x: reference.x - element.width,
|
||||
y: commonY
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
offsets = {
|
||||
x: reference.x,
|
||||
y: reference.y
|
||||
};
|
||||
}
|
||||
|
||||
var mainAxis = basePlacement ? getMainAxisFromPlacement(basePlacement) : null;
|
||||
|
||||
if (mainAxis != null) {
|
||||
var len = mainAxis === 'y' ? 'height' : 'width';
|
||||
|
||||
switch (variation) {
|
||||
case start:
|
||||
offsets[mainAxis] = offsets[mainAxis] - (reference[len] / 2 - element[len] / 2);
|
||||
break;
|
||||
|
||||
case end:
|
||||
offsets[mainAxis] = offsets[mainAxis] + (reference[len] / 2 - element[len] / 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
function getFreshSideObject() {
|
||||
return {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
};
|
||||
}
|
||||
|
||||
function mergePaddingObject(paddingObject) {
|
||||
return Object.assign({}, getFreshSideObject(), paddingObject);
|
||||
}
|
||||
|
||||
function expandToHashMap(value, keys) {
|
||||
return keys.reduce(function (hashMap, key) {
|
||||
hashMap[key] = value;
|
||||
return hashMap;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function detectOverflow(state, options) {
|
||||
if (options === void 0) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
var _options = options,
|
||||
_options$placement = _options.placement,
|
||||
placement = _options$placement === void 0 ? state.placement : _options$placement,
|
||||
_options$strategy = _options.strategy,
|
||||
strategy = _options$strategy === void 0 ? state.strategy : _options$strategy,
|
||||
_options$boundary = _options.boundary,
|
||||
boundary = _options$boundary === void 0 ? clippingParents : _options$boundary,
|
||||
_options$rootBoundary = _options.rootBoundary,
|
||||
rootBoundary = _options$rootBoundary === void 0 ? viewport : _options$rootBoundary,
|
||||
_options$elementConte = _options.elementContext,
|
||||
elementContext = _options$elementConte === void 0 ? popper : _options$elementConte,
|
||||
_options$altBoundary = _options.altBoundary,
|
||||
altBoundary = _options$altBoundary === void 0 ? false : _options$altBoundary,
|
||||
_options$padding = _options.padding,
|
||||
padding = _options$padding === void 0 ? 0 : _options$padding;
|
||||
var paddingObject = mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements));
|
||||
var altContext = elementContext === popper ? reference : popper;
|
||||
var popperRect = state.rects.popper;
|
||||
var element = state.elements[altBoundary ? altContext : elementContext];
|
||||
var clippingClientRect = getClippingRect(isElement(element) ? element : element.contextElement || getDocumentElement(state.elements.popper), boundary, rootBoundary, strategy);
|
||||
var referenceClientRect = getBoundingClientRect(state.elements.reference);
|
||||
var popperOffsets = computeOffsets({
|
||||
reference: referenceClientRect,
|
||||
element: popperRect,
|
||||
strategy: 'absolute',
|
||||
placement: placement
|
||||
});
|
||||
var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets));
|
||||
var elementClientRect = elementContext === popper ? popperClientRect : referenceClientRect; // positive = overflowing the clipping rect
|
||||
// 0 or negative = within the clipping rect
|
||||
|
||||
var overflowOffsets = {
|
||||
top: clippingClientRect.top - elementClientRect.top + paddingObject.top,
|
||||
bottom: elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom,
|
||||
left: clippingClientRect.left - elementClientRect.left + paddingObject.left,
|
||||
right: elementClientRect.right - clippingClientRect.right + paddingObject.right
|
||||
};
|
||||
var offsetData = state.modifiersData.offset; // Offsets can be applied only to the popper element
|
||||
|
||||
if (elementContext === popper && offsetData) {
|
||||
var offset = offsetData[placement];
|
||||
Object.keys(overflowOffsets).forEach(function (key) {
|
||||
var multiply = [right, bottom].indexOf(key) >= 0 ? 1 : -1;
|
||||
var axis = [top, bottom].indexOf(key) >= 0 ? 'y' : 'x';
|
||||
overflowOffsets[key] += offset[axis] * multiply;
|
||||
});
|
||||
}
|
||||
|
||||
return overflowOffsets;
|
||||
}
|
||||
|
||||
var DEFAULT_OPTIONS = {
|
||||
placement: 'bottom',
|
||||
modifiers: [],
|
||||
strategy: 'absolute'
|
||||
};
|
||||
|
||||
function areValidElements() {
|
||||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
|
||||
args[_key] = arguments[_key];
|
||||
}
|
||||
|
||||
return !args.some(function (element) {
|
||||
return !(element && typeof element.getBoundingClientRect === 'function');
|
||||
});
|
||||
}
|
||||
|
||||
function popperGenerator(generatorOptions) {
|
||||
if (generatorOptions === void 0) {
|
||||
generatorOptions = {};
|
||||
}
|
||||
|
||||
var _generatorOptions = generatorOptions,
|
||||
_generatorOptions$def = _generatorOptions.defaultModifiers,
|
||||
defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def,
|
||||
_generatorOptions$def2 = _generatorOptions.defaultOptions,
|
||||
defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2;
|
||||
return function createPopper(reference, popper, options) {
|
||||
if (options === void 0) {
|
||||
options = defaultOptions;
|
||||
}
|
||||
|
||||
var state = {
|
||||
placement: 'bottom',
|
||||
orderedModifiers: [],
|
||||
options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions),
|
||||
modifiersData: {},
|
||||
elements: {
|
||||
reference: reference,
|
||||
popper: popper
|
||||
},
|
||||
attributes: {},
|
||||
styles: {}
|
||||
};
|
||||
var effectCleanupFns = [];
|
||||
var isDestroyed = false;
|
||||
var instance = {
|
||||
state: state,
|
||||
setOptions: function setOptions(setOptionsAction) {
|
||||
var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction;
|
||||
cleanupModifierEffects();
|
||||
state.options = Object.assign({}, defaultOptions, state.options, options);
|
||||
state.scrollParents = {
|
||||
reference: isElement(reference) ? listScrollParents(reference) : reference.contextElement ? listScrollParents(reference.contextElement) : [],
|
||||
popper: listScrollParents(popper)
|
||||
}; // Orders the modifiers based on their dependencies and `phase`
|
||||
// properties
|
||||
|
||||
var orderedModifiers = orderModifiers(mergeByName([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers
|
||||
|
||||
state.orderedModifiers = orderedModifiers.filter(function (m) {
|
||||
return m.enabled;
|
||||
});
|
||||
runModifierEffects();
|
||||
return instance.update();
|
||||
},
|
||||
// Sync update – it will always be executed, even if not necessary. This
|
||||
// is useful for low frequency updates where sync behavior simplifies the
|
||||
// logic.
|
||||
// For high frequency updates (e.g. `resize` and `scroll` events), always
|
||||
// prefer the async Popper#update method
|
||||
forceUpdate: function forceUpdate() {
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
var _state$elements = state.elements,
|
||||
reference = _state$elements.reference,
|
||||
popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements
|
||||
// anymore
|
||||
|
||||
if (!areValidElements(reference, popper)) {
|
||||
return;
|
||||
} // Store the reference and popper rects to be read by modifiers
|
||||
|
||||
|
||||
state.rects = {
|
||||
reference: getCompositeRect(reference, getOffsetParent(popper), state.options.strategy === 'fixed'),
|
||||
popper: getLayoutRect(popper)
|
||||
}; // Modifiers have the ability to reset the current update cycle. The
|
||||
// most common use case for this is the `flip` modifier changing the
|
||||
// placement, which then needs to re-run all the modifiers, because the
|
||||
// logic was previously ran for the previous placement and is therefore
|
||||
// stale/incorrect
|
||||
|
||||
state.reset = false;
|
||||
state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier
|
||||
// is filled with the initial data specified by the modifier. This means
|
||||
// it doesn't persist and is fresh on each update.
|
||||
// To ensure persistent data, use `${name}#persistent`
|
||||
|
||||
state.orderedModifiers.forEach(function (modifier) {
|
||||
return state.modifiersData[modifier.name] = Object.assign({}, modifier.data);
|
||||
});
|
||||
|
||||
for (var index = 0; index < state.orderedModifiers.length; index++) {
|
||||
if (state.reset === true) {
|
||||
state.reset = false;
|
||||
index = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var _state$orderedModifie = state.orderedModifiers[index],
|
||||
fn = _state$orderedModifie.fn,
|
||||
_state$orderedModifie2 = _state$orderedModifie.options,
|
||||
_options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2,
|
||||
name = _state$orderedModifie.name;
|
||||
|
||||
if (typeof fn === 'function') {
|
||||
state = fn({
|
||||
state: state,
|
||||
options: _options,
|
||||
name: name,
|
||||
instance: instance
|
||||
}) || state;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Async and optimistically optimized update – it will not be executed if
|
||||
// not necessary (debounced to run at most once-per-tick)
|
||||
update: debounce(function () {
|
||||
return new Promise(function (resolve) {
|
||||
instance.forceUpdate();
|
||||
resolve(state);
|
||||
});
|
||||
}),
|
||||
destroy: function destroy() {
|
||||
cleanupModifierEffects();
|
||||
isDestroyed = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (!areValidElements(reference, popper)) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance.setOptions(options).then(function (state) {
|
||||
if (!isDestroyed && options.onFirstUpdate) {
|
||||
options.onFirstUpdate(state);
|
||||
}
|
||||
}); // Modifiers have the ability to execute arbitrary code before the first
|
||||
// update cycle runs. They will be executed in the same order as the update
|
||||
// cycle. This is useful when a modifier adds some persistent data that
|
||||
// other modifiers need to use, but the modifier is run after the dependent
|
||||
// one.
|
||||
|
||||
function runModifierEffects() {
|
||||
state.orderedModifiers.forEach(function (_ref) {
|
||||
var name = _ref.name,
|
||||
_ref$options = _ref.options,
|
||||
options = _ref$options === void 0 ? {} : _ref$options,
|
||||
effect = _ref.effect;
|
||||
|
||||
if (typeof effect === 'function') {
|
||||
var cleanupFn = effect({
|
||||
state: state,
|
||||
name: name,
|
||||
instance: instance,
|
||||
options: options
|
||||
});
|
||||
|
||||
var noopFn = function noopFn() {};
|
||||
|
||||
effectCleanupFns.push(cleanupFn || noopFn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupModifierEffects() {
|
||||
effectCleanupFns.forEach(function (fn) {
|
||||
return fn();
|
||||
});
|
||||
effectCleanupFns = [];
|
||||
}
|
||||
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
var createPopper = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
exports.createPopper = createPopper;
|
||||
exports.detectOverflow = detectOverflow;
|
||||
exports.popperGenerator = popperGenerator;
|
||||
//# sourceMappingURL=popper-base.js.map
|
||||
3
public/assets/@popperjs/core/dist/cjs/popper-base.js.flow
vendored
Normal file
3
public/assets/@popperjs/core/dist/cjs/popper-base.js.flow
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
|
||||
export * from '../../lib/popper-base.js'
|
||||
1
public/assets/@popperjs/core/dist/cjs/popper-base.js.map
vendored
Normal file
1
public/assets/@popperjs/core/dist/cjs/popper-base.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1260
public/assets/@popperjs/core/dist/cjs/popper-lite.js
vendored
Normal file
1260
public/assets/@popperjs/core/dist/cjs/popper-lite.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
public/assets/@popperjs/core/dist/cjs/popper-lite.js.flow
vendored
Normal file
3
public/assets/@popperjs/core/dist/cjs/popper-lite.js.flow
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
|
||||
export * from '../../lib/popper-lite.js'
|
||||
1
public/assets/@popperjs/core/dist/cjs/popper-lite.js.map
vendored
Normal file
1
public/assets/@popperjs/core/dist/cjs/popper-lite.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1819
public/assets/@popperjs/core/dist/cjs/popper.js
vendored
Normal file
1819
public/assets/@popperjs/core/dist/cjs/popper.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
public/assets/@popperjs/core/dist/cjs/popper.js.flow
vendored
Normal file
3
public/assets/@popperjs/core/dist/cjs/popper.js.flow
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
|
||||
export * from '../../lib/popper.js'
|
||||
1
public/assets/@popperjs/core/dist/cjs/popper.js.map
vendored
Normal file
1
public/assets/@popperjs/core/dist/cjs/popper.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
199
public/assets/@popperjs/core/dist/esm/createPopper.js
vendored
Normal file
199
public/assets/@popperjs/core/dist/esm/createPopper.js
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
import getCompositeRect from "./dom-utils/getCompositeRect.js";
|
||||
import getLayoutRect from "./dom-utils/getLayoutRect.js";
|
||||
import listScrollParents from "./dom-utils/listScrollParents.js";
|
||||
import getOffsetParent from "./dom-utils/getOffsetParent.js";
|
||||
import orderModifiers from "./utils/orderModifiers.js";
|
||||
import debounce from "./utils/debounce.js";
|
||||
import mergeByName from "./utils/mergeByName.js";
|
||||
import detectOverflow from "./utils/detectOverflow.js";
|
||||
import { isElement } from "./dom-utils/instanceOf.js";
|
||||
var DEFAULT_OPTIONS = {
|
||||
placement: 'bottom',
|
||||
modifiers: [],
|
||||
strategy: 'absolute'
|
||||
};
|
||||
|
||||
function areValidElements() {
|
||||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
|
||||
args[_key] = arguments[_key];
|
||||
}
|
||||
|
||||
return !args.some(function (element) {
|
||||
return !(element && typeof element.getBoundingClientRect === 'function');
|
||||
});
|
||||
}
|
||||
|
||||
export function popperGenerator(generatorOptions) {
|
||||
if (generatorOptions === void 0) {
|
||||
generatorOptions = {};
|
||||
}
|
||||
|
||||
var _generatorOptions = generatorOptions,
|
||||
_generatorOptions$def = _generatorOptions.defaultModifiers,
|
||||
defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def,
|
||||
_generatorOptions$def2 = _generatorOptions.defaultOptions,
|
||||
defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2;
|
||||
return function createPopper(reference, popper, options) {
|
||||
if (options === void 0) {
|
||||
options = defaultOptions;
|
||||
}
|
||||
|
||||
var state = {
|
||||
placement: 'bottom',
|
||||
orderedModifiers: [],
|
||||
options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions),
|
||||
modifiersData: {},
|
||||
elements: {
|
||||
reference: reference,
|
||||
popper: popper
|
||||
},
|
||||
attributes: {},
|
||||
styles: {}
|
||||
};
|
||||
var effectCleanupFns = [];
|
||||
var isDestroyed = false;
|
||||
var instance = {
|
||||
state: state,
|
||||
setOptions: function setOptions(setOptionsAction) {
|
||||
var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction;
|
||||
cleanupModifierEffects();
|
||||
state.options = Object.assign({}, defaultOptions, state.options, options);
|
||||
state.scrollParents = {
|
||||
reference: isElement(reference) ? listScrollParents(reference) : reference.contextElement ? listScrollParents(reference.contextElement) : [],
|
||||
popper: listScrollParents(popper)
|
||||
}; // Orders the modifiers based on their dependencies and `phase`
|
||||
// properties
|
||||
|
||||
var orderedModifiers = orderModifiers(mergeByName([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers
|
||||
|
||||
state.orderedModifiers = orderedModifiers.filter(function (m) {
|
||||
return m.enabled;
|
||||
});
|
||||
runModifierEffects();
|
||||
return instance.update();
|
||||
},
|
||||
// Sync update – it will always be executed, even if not necessary. This
|
||||
// is useful for low frequency updates where sync behavior simplifies the
|
||||
// logic.
|
||||
// For high frequency updates (e.g. `resize` and `scroll` events), always
|
||||
// prefer the async Popper#update method
|
||||
forceUpdate: function forceUpdate() {
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
var _state$elements = state.elements,
|
||||
reference = _state$elements.reference,
|
||||
popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements
|
||||
// anymore
|
||||
|
||||
if (!areValidElements(reference, popper)) {
|
||||
return;
|
||||
} // Store the reference and popper rects to be read by modifiers
|
||||
|
||||
|
||||
state.rects = {
|
||||
reference: getCompositeRect(reference, getOffsetParent(popper), state.options.strategy === 'fixed'),
|
||||
popper: getLayoutRect(popper)
|
||||
}; // Modifiers have the ability to reset the current update cycle. The
|
||||
// most common use case for this is the `flip` modifier changing the
|
||||
// placement, which then needs to re-run all the modifiers, because the
|
||||
// logic was previously ran for the previous placement and is therefore
|
||||
// stale/incorrect
|
||||
|
||||
state.reset = false;
|
||||
state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier
|
||||
// is filled with the initial data specified by the modifier. This means
|
||||
// it doesn't persist and is fresh on each update.
|
||||
// To ensure persistent data, use `${name}#persistent`
|
||||
|
||||
state.orderedModifiers.forEach(function (modifier) {
|
||||
return state.modifiersData[modifier.name] = Object.assign({}, modifier.data);
|
||||
});
|
||||
|
||||
for (var index = 0; index < state.orderedModifiers.length; index++) {
|
||||
if (state.reset === true) {
|
||||
state.reset = false;
|
||||
index = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var _state$orderedModifie = state.orderedModifiers[index],
|
||||
fn = _state$orderedModifie.fn,
|
||||
_state$orderedModifie2 = _state$orderedModifie.options,
|
||||
_options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2,
|
||||
name = _state$orderedModifie.name;
|
||||
|
||||
if (typeof fn === 'function') {
|
||||
state = fn({
|
||||
state: state,
|
||||
options: _options,
|
||||
name: name,
|
||||
instance: instance
|
||||
}) || state;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Async and optimistically optimized update – it will not be executed if
|
||||
// not necessary (debounced to run at most once-per-tick)
|
||||
update: debounce(function () {
|
||||
return new Promise(function (resolve) {
|
||||
instance.forceUpdate();
|
||||
resolve(state);
|
||||
});
|
||||
}),
|
||||
destroy: function destroy() {
|
||||
cleanupModifierEffects();
|
||||
isDestroyed = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (!areValidElements(reference, popper)) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
instance.setOptions(options).then(function (state) {
|
||||
if (!isDestroyed && options.onFirstUpdate) {
|
||||
options.onFirstUpdate(state);
|
||||
}
|
||||
}); // Modifiers have the ability to execute arbitrary code before the first
|
||||
// update cycle runs. They will be executed in the same order as the update
|
||||
// cycle. This is useful when a modifier adds some persistent data that
|
||||
// other modifiers need to use, but the modifier is run after the dependent
|
||||
// one.
|
||||
|
||||
function runModifierEffects() {
|
||||
state.orderedModifiers.forEach(function (_ref) {
|
||||
var name = _ref.name,
|
||||
_ref$options = _ref.options,
|
||||
options = _ref$options === void 0 ? {} : _ref$options,
|
||||
effect = _ref.effect;
|
||||
|
||||
if (typeof effect === 'function') {
|
||||
var cleanupFn = effect({
|
||||
state: state,
|
||||
name: name,
|
||||
instance: instance,
|
||||
options: options
|
||||
});
|
||||
|
||||
var noopFn = function noopFn() {};
|
||||
|
||||
effectCleanupFns.push(cleanupFn || noopFn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupModifierEffects() {
|
||||
effectCleanupFns.forEach(function (fn) {
|
||||
return fn();
|
||||
});
|
||||
effectCleanupFns = [];
|
||||
}
|
||||
|
||||
return instance;
|
||||
};
|
||||
}
|
||||
export var createPopper = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
export { detectOverflow };
|
||||
23
public/assets/@popperjs/core/dist/esm/dom-utils/contains.js
vendored
Normal file
23
public/assets/@popperjs/core/dist/esm/dom-utils/contains.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import { isShadowRoot } from "./instanceOf.js";
|
||||
export default function contains(parent, child) {
|
||||
var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method
|
||||
|
||||
if (parent.contains(child)) {
|
||||
return true;
|
||||
} // then fallback to custom implementation with Shadow DOM support
|
||||
else if (rootNode && isShadowRoot(rootNode)) {
|
||||
var next = child;
|
||||
|
||||
do {
|
||||
if (next && parent.isSameNode(next)) {
|
||||
return true;
|
||||
} // $FlowFixMe[prop-missing]: need a better way to handle this...
|
||||
|
||||
|
||||
next = next.parentNode || next.host;
|
||||
} while (next);
|
||||
} // Give up, the result is false
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
41
public/assets/@popperjs/core/dist/esm/dom-utils/getBoundingClientRect.js
vendored
Normal file
41
public/assets/@popperjs/core/dist/esm/dom-utils/getBoundingClientRect.js
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
import { isElement, isHTMLElement } from "./instanceOf.js";
|
||||
import { round } from "../utils/math.js";
|
||||
import getWindow from "./getWindow.js";
|
||||
import isLayoutViewport from "./isLayoutViewport.js";
|
||||
export default function getBoundingClientRect(element, includeScale, isFixedStrategy) {
|
||||
if (includeScale === void 0) {
|
||||
includeScale = false;
|
||||
}
|
||||
|
||||
if (isFixedStrategy === void 0) {
|
||||
isFixedStrategy = false;
|
||||
}
|
||||
|
||||
var clientRect = element.getBoundingClientRect();
|
||||
var scaleX = 1;
|
||||
var scaleY = 1;
|
||||
|
||||
if (includeScale && isHTMLElement(element)) {
|
||||
scaleX = element.offsetWidth > 0 ? round(clientRect.width) / element.offsetWidth || 1 : 1;
|
||||
scaleY = element.offsetHeight > 0 ? round(clientRect.height) / element.offsetHeight || 1 : 1;
|
||||
}
|
||||
|
||||
var _ref = isElement(element) ? getWindow(element) : window,
|
||||
visualViewport = _ref.visualViewport;
|
||||
|
||||
var addVisualOffsets = !isLayoutViewport() && isFixedStrategy;
|
||||
var x = (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / scaleX;
|
||||
var y = (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / scaleY;
|
||||
var width = clientRect.width / scaleX;
|
||||
var height = clientRect.height / scaleY;
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
top: y,
|
||||
right: x + width,
|
||||
bottom: y + height,
|
||||
left: x,
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
}
|
||||
70
public/assets/@popperjs/core/dist/esm/dom-utils/getClippingRect.js
vendored
Normal file
70
public/assets/@popperjs/core/dist/esm/dom-utils/getClippingRect.js
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
import { viewport } from "../enums.js";
|
||||
import getViewportRect from "./getViewportRect.js";
|
||||
import getDocumentRect from "./getDocumentRect.js";
|
||||
import listScrollParents from "./listScrollParents.js";
|
||||
import getOffsetParent from "./getOffsetParent.js";
|
||||
import getDocumentElement from "./getDocumentElement.js";
|
||||
import getComputedStyle from "./getComputedStyle.js";
|
||||
import { isElement, isHTMLElement } from "./instanceOf.js";
|
||||
import getBoundingClientRect from "./getBoundingClientRect.js";
|
||||
import getParentNode from "./getParentNode.js";
|
||||
import contains from "./contains.js";
|
||||
import getNodeName from "./getNodeName.js";
|
||||
import rectToClientRect from "../utils/rectToClientRect.js";
|
||||
import { max, min } from "../utils/math.js";
|
||||
|
||||
function getInnerBoundingClientRect(element, strategy) {
|
||||
var rect = getBoundingClientRect(element, false, strategy === 'fixed');
|
||||
rect.top = rect.top + element.clientTop;
|
||||
rect.left = rect.left + element.clientLeft;
|
||||
rect.bottom = rect.top + element.clientHeight;
|
||||
rect.right = rect.left + element.clientWidth;
|
||||
rect.width = element.clientWidth;
|
||||
rect.height = element.clientHeight;
|
||||
rect.x = rect.left;
|
||||
rect.y = rect.top;
|
||||
return rect;
|
||||
}
|
||||
|
||||
function getClientRectFromMixedType(element, clippingParent, strategy) {
|
||||
return clippingParent === viewport ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element)));
|
||||
} // A "clipping parent" is an overflowable container with the characteristic of
|
||||
// clipping (or hiding) overflowing elements with a position different from
|
||||
// `initial`
|
||||
|
||||
|
||||
function getClippingParents(element) {
|
||||
var clippingParents = listScrollParents(getParentNode(element));
|
||||
var canEscapeClipping = ['absolute', 'fixed'].indexOf(getComputedStyle(element).position) >= 0;
|
||||
var clipperElement = canEscapeClipping && isHTMLElement(element) ? getOffsetParent(element) : element;
|
||||
|
||||
if (!isElement(clipperElement)) {
|
||||
return [];
|
||||
} // $FlowFixMe[incompatible-return]: https://github.com/facebook/flow/issues/1414
|
||||
|
||||
|
||||
return clippingParents.filter(function (clippingParent) {
|
||||
return isElement(clippingParent) && contains(clippingParent, clipperElement) && getNodeName(clippingParent) !== 'body';
|
||||
});
|
||||
} // Gets the maximum area that the element is visible in due to any number of
|
||||
// clipping parents
|
||||
|
||||
|
||||
export default function getClippingRect(element, boundary, rootBoundary, strategy) {
|
||||
var mainClippingParents = boundary === 'clippingParents' ? getClippingParents(element) : [].concat(boundary);
|
||||
var clippingParents = [].concat(mainClippingParents, [rootBoundary]);
|
||||
var firstClippingParent = clippingParents[0];
|
||||
var clippingRect = clippingParents.reduce(function (accRect, clippingParent) {
|
||||
var rect = getClientRectFromMixedType(element, clippingParent, strategy);
|
||||
accRect.top = max(rect.top, accRect.top);
|
||||
accRect.right = min(rect.right, accRect.right);
|
||||
accRect.bottom = min(rect.bottom, accRect.bottom);
|
||||
accRect.left = max(rect.left, accRect.left);
|
||||
return accRect;
|
||||
}, getClientRectFromMixedType(element, firstClippingParent, strategy));
|
||||
clippingRect.width = clippingRect.right - clippingRect.left;
|
||||
clippingRect.height = clippingRect.bottom - clippingRect.top;
|
||||
clippingRect.x = clippingRect.left;
|
||||
clippingRect.y = clippingRect.top;
|
||||
return clippingRect;
|
||||
}
|
||||
58
public/assets/@popperjs/core/dist/esm/dom-utils/getCompositeRect.js
vendored
Normal file
58
public/assets/@popperjs/core/dist/esm/dom-utils/getCompositeRect.js
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
import getBoundingClientRect from "./getBoundingClientRect.js";
|
||||
import getNodeScroll from "./getNodeScroll.js";
|
||||
import getNodeName from "./getNodeName.js";
|
||||
import { isHTMLElement } from "./instanceOf.js";
|
||||
import getWindowScrollBarX from "./getWindowScrollBarX.js";
|
||||
import getDocumentElement from "./getDocumentElement.js";
|
||||
import isScrollParent from "./isScrollParent.js";
|
||||
import { round } from "../utils/math.js";
|
||||
|
||||
function isElementScaled(element) {
|
||||
var rect = element.getBoundingClientRect();
|
||||
var scaleX = round(rect.width) / element.offsetWidth || 1;
|
||||
var scaleY = round(rect.height) / element.offsetHeight || 1;
|
||||
return scaleX !== 1 || scaleY !== 1;
|
||||
} // Returns the composite rect of an element relative to its offsetParent.
|
||||
// Composite means it takes into account transforms as well as layout.
|
||||
|
||||
|
||||
export default function getCompositeRect(elementOrVirtualElement, offsetParent, isFixed) {
|
||||
if (isFixed === void 0) {
|
||||
isFixed = false;
|
||||
}
|
||||
|
||||
var isOffsetParentAnElement = isHTMLElement(offsetParent);
|
||||
var offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent);
|
||||
var documentElement = getDocumentElement(offsetParent);
|
||||
var rect = getBoundingClientRect(elementOrVirtualElement, offsetParentIsScaled, isFixed);
|
||||
var scroll = {
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0
|
||||
};
|
||||
var offsets = {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
|
||||
if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) {
|
||||
if (getNodeName(offsetParent) !== 'body' || // https://github.com/popperjs/popper-core/issues/1078
|
||||
isScrollParent(documentElement)) {
|
||||
scroll = getNodeScroll(offsetParent);
|
||||
}
|
||||
|
||||
if (isHTMLElement(offsetParent)) {
|
||||
offsets = getBoundingClientRect(offsetParent, true);
|
||||
offsets.x += offsetParent.clientLeft;
|
||||
offsets.y += offsetParent.clientTop;
|
||||
} else if (documentElement) {
|
||||
offsets.x = getWindowScrollBarX(documentElement);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: rect.left + scroll.scrollLeft - offsets.x,
|
||||
y: rect.top + scroll.scrollTop - offsets.y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
};
|
||||
}
|
||||
4
public/assets/@popperjs/core/dist/esm/dom-utils/getComputedStyle.js
vendored
Normal file
4
public/assets/@popperjs/core/dist/esm/dom-utils/getComputedStyle.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import getWindow from "./getWindow.js";
|
||||
export default function getComputedStyle(element) {
|
||||
return getWindow(element).getComputedStyle(element);
|
||||
}
|
||||
6
public/assets/@popperjs/core/dist/esm/dom-utils/getDocumentElement.js
vendored
Normal file
6
public/assets/@popperjs/core/dist/esm/dom-utils/getDocumentElement.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { isElement } from "./instanceOf.js";
|
||||
export default function getDocumentElement(element) {
|
||||
// $FlowFixMe[incompatible-return]: assume body is always available
|
||||
return ((isElement(element) ? element.ownerDocument : // $FlowFixMe[prop-missing]
|
||||
element.document) || window.document).documentElement;
|
||||
}
|
||||
29
public/assets/@popperjs/core/dist/esm/dom-utils/getDocumentRect.js
vendored
Normal file
29
public/assets/@popperjs/core/dist/esm/dom-utils/getDocumentRect.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import getDocumentElement from "./getDocumentElement.js";
|
||||
import getComputedStyle from "./getComputedStyle.js";
|
||||
import getWindowScrollBarX from "./getWindowScrollBarX.js";
|
||||
import getWindowScroll from "./getWindowScroll.js";
|
||||
import { max } from "../utils/math.js"; // Gets the entire size of the scrollable document area, even extending outside
|
||||
// of the `<html>` and `<body>` rect bounds if horizontally scrollable
|
||||
|
||||
export default function getDocumentRect(element) {
|
||||
var _element$ownerDocumen;
|
||||
|
||||
var html = getDocumentElement(element);
|
||||
var winScroll = getWindowScroll(element);
|
||||
var body = (_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body;
|
||||
var width = max(html.scrollWidth, html.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0);
|
||||
var height = max(html.scrollHeight, html.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0);
|
||||
var x = -winScroll.scrollLeft + getWindowScrollBarX(element);
|
||||
var y = -winScroll.scrollTop;
|
||||
|
||||
if (getComputedStyle(body || html).direction === 'rtl') {
|
||||
x += max(html.clientWidth, body ? body.clientWidth : 0) - width;
|
||||
}
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
}
|
||||
6
public/assets/@popperjs/core/dist/esm/dom-utils/getHTMLElementScroll.js
vendored
Normal file
6
public/assets/@popperjs/core/dist/esm/dom-utils/getHTMLElementScroll.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
export default function getHTMLElementScroll(element) {
|
||||
return {
|
||||
scrollLeft: element.scrollLeft,
|
||||
scrollTop: element.scrollTop
|
||||
};
|
||||
}
|
||||
25
public/assets/@popperjs/core/dist/esm/dom-utils/getLayoutRect.js
vendored
Normal file
25
public/assets/@popperjs/core/dist/esm/dom-utils/getLayoutRect.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import getBoundingClientRect from "./getBoundingClientRect.js"; // Returns the layout rect of an element relative to its offsetParent. Layout
|
||||
// means it doesn't take into account transforms.
|
||||
|
||||
export default function getLayoutRect(element) {
|
||||
var clientRect = getBoundingClientRect(element); // Use the clientRect sizes if it's not been transformed.
|
||||
// Fixes https://github.com/popperjs/popper-core/issues/1223
|
||||
|
||||
var width = element.offsetWidth;
|
||||
var height = element.offsetHeight;
|
||||
|
||||
if (Math.abs(clientRect.width - width) <= 1) {
|
||||
width = clientRect.width;
|
||||
}
|
||||
|
||||
if (Math.abs(clientRect.height - height) <= 1) {
|
||||
height = clientRect.height;
|
||||
}
|
||||
|
||||
return {
|
||||
x: element.offsetLeft,
|
||||
y: element.offsetTop,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
3
public/assets/@popperjs/core/dist/esm/dom-utils/getNodeName.js
vendored
Normal file
3
public/assets/@popperjs/core/dist/esm/dom-utils/getNodeName.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function getNodeName(element) {
|
||||
return element ? (element.nodeName || '').toLowerCase() : null;
|
||||
}
|
||||
11
public/assets/@popperjs/core/dist/esm/dom-utils/getNodeScroll.js
vendored
Normal file
11
public/assets/@popperjs/core/dist/esm/dom-utils/getNodeScroll.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import getWindowScroll from "./getWindowScroll.js";
|
||||
import getWindow from "./getWindow.js";
|
||||
import { isHTMLElement } from "./instanceOf.js";
|
||||
import getHTMLElementScroll from "./getHTMLElementScroll.js";
|
||||
export default function getNodeScroll(node) {
|
||||
if (node === getWindow(node) || !isHTMLElement(node)) {
|
||||
return getWindowScroll(node);
|
||||
} else {
|
||||
return getHTMLElementScroll(node);
|
||||
}
|
||||
}
|
||||
69
public/assets/@popperjs/core/dist/esm/dom-utils/getOffsetParent.js
vendored
Normal file
69
public/assets/@popperjs/core/dist/esm/dom-utils/getOffsetParent.js
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
import getWindow from "./getWindow.js";
|
||||
import getNodeName from "./getNodeName.js";
|
||||
import getComputedStyle from "./getComputedStyle.js";
|
||||
import { isHTMLElement, isShadowRoot } from "./instanceOf.js";
|
||||
import isTableElement from "./isTableElement.js";
|
||||
import getParentNode from "./getParentNode.js";
|
||||
import getUAString from "../utils/userAgent.js";
|
||||
|
||||
function getTrueOffsetParent(element) {
|
||||
if (!isHTMLElement(element) || // https://github.com/popperjs/popper-core/issues/837
|
||||
getComputedStyle(element).position === 'fixed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.offsetParent;
|
||||
} // `.offsetParent` reports `null` for fixed elements, while absolute elements
|
||||
// return the containing block
|
||||
|
||||
|
||||
function getContainingBlock(element) {
|
||||
var isFirefox = /firefox/i.test(getUAString());
|
||||
var isIE = /Trident/i.test(getUAString());
|
||||
|
||||
if (isIE && isHTMLElement(element)) {
|
||||
// In IE 9, 10 and 11 fixed elements containing block is always established by the viewport
|
||||
var elementCss = getComputedStyle(element);
|
||||
|
||||
if (elementCss.position === 'fixed') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var currentNode = getParentNode(element);
|
||||
|
||||
if (isShadowRoot(currentNode)) {
|
||||
currentNode = currentNode.host;
|
||||
}
|
||||
|
||||
while (isHTMLElement(currentNode) && ['html', 'body'].indexOf(getNodeName(currentNode)) < 0) {
|
||||
var css = getComputedStyle(currentNode); // This is non-exhaustive but covers the most common CSS properties that
|
||||
// create a containing block.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
|
||||
|
||||
if (css.transform !== 'none' || css.perspective !== 'none' || css.contain === 'paint' || ['transform', 'perspective'].indexOf(css.willChange) !== -1 || isFirefox && css.willChange === 'filter' || isFirefox && css.filter && css.filter !== 'none') {
|
||||
return currentNode;
|
||||
} else {
|
||||
currentNode = currentNode.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} // Gets the closest ancestor positioned element. Handles some edge cases,
|
||||
// such as table ancestors and cross browser bugs.
|
||||
|
||||
|
||||
export default function getOffsetParent(element) {
|
||||
var window = getWindow(element);
|
||||
var offsetParent = getTrueOffsetParent(element);
|
||||
|
||||
while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') {
|
||||
offsetParent = getTrueOffsetParent(offsetParent);
|
||||
}
|
||||
|
||||
if (offsetParent && (getNodeName(offsetParent) === 'html' || getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static')) {
|
||||
return window;
|
||||
}
|
||||
|
||||
return offsetParent || getContainingBlock(element) || window;
|
||||
}
|
||||
19
public/assets/@popperjs/core/dist/esm/dom-utils/getParentNode.js
vendored
Normal file
19
public/assets/@popperjs/core/dist/esm/dom-utils/getParentNode.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import getNodeName from "./getNodeName.js";
|
||||
import getDocumentElement from "./getDocumentElement.js";
|
||||
import { isShadowRoot } from "./instanceOf.js";
|
||||
export default function getParentNode(element) {
|
||||
if (getNodeName(element) === 'html') {
|
||||
return element;
|
||||
}
|
||||
|
||||
return (// this is a quicker (but less type safe) way to save quite some bytes from the bundle
|
||||
// $FlowFixMe[incompatible-return]
|
||||
// $FlowFixMe[prop-missing]
|
||||
element.assignedSlot || // step into the shadow DOM of the parent of a slotted node
|
||||
element.parentNode || ( // DOM Element detected
|
||||
isShadowRoot(element) ? element.host : null) || // ShadowRoot detected
|
||||
// $FlowFixMe[incompatible-call]: HTMLElement is a Node
|
||||
getDocumentElement(element) // fallback
|
||||
|
||||
);
|
||||
}
|
||||
16
public/assets/@popperjs/core/dist/esm/dom-utils/getScrollParent.js
vendored
Normal file
16
public/assets/@popperjs/core/dist/esm/dom-utils/getScrollParent.js
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
import getParentNode from "./getParentNode.js";
|
||||
import isScrollParent from "./isScrollParent.js";
|
||||
import getNodeName from "./getNodeName.js";
|
||||
import { isHTMLElement } from "./instanceOf.js";
|
||||
export default function getScrollParent(node) {
|
||||
if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) {
|
||||
// $FlowFixMe[incompatible-return]: assume body is always available
|
||||
return node.ownerDocument.body;
|
||||
}
|
||||
|
||||
if (isHTMLElement(node) && isScrollParent(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return getScrollParent(getParentNode(node));
|
||||
}
|
||||
31
public/assets/@popperjs/core/dist/esm/dom-utils/getViewportRect.js
vendored
Normal file
31
public/assets/@popperjs/core/dist/esm/dom-utils/getViewportRect.js
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import getWindow from "./getWindow.js";
|
||||
import getDocumentElement from "./getDocumentElement.js";
|
||||
import getWindowScrollBarX from "./getWindowScrollBarX.js";
|
||||
import isLayoutViewport from "./isLayoutViewport.js";
|
||||
export default function getViewportRect(element, strategy) {
|
||||
var win = getWindow(element);
|
||||
var html = getDocumentElement(element);
|
||||
var visualViewport = win.visualViewport;
|
||||
var width = html.clientWidth;
|
||||
var height = html.clientHeight;
|
||||
var x = 0;
|
||||
var y = 0;
|
||||
|
||||
if (visualViewport) {
|
||||
width = visualViewport.width;
|
||||
height = visualViewport.height;
|
||||
var layoutViewport = isLayoutViewport();
|
||||
|
||||
if (layoutViewport || !layoutViewport && strategy === 'fixed') {
|
||||
x = visualViewport.offsetLeft;
|
||||
y = visualViewport.offsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height,
|
||||
x: x + getWindowScrollBarX(element),
|
||||
y: y
|
||||
};
|
||||
}
|
||||
12
public/assets/@popperjs/core/dist/esm/dom-utils/getWindow.js
vendored
Normal file
12
public/assets/@popperjs/core/dist/esm/dom-utils/getWindow.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function getWindow(node) {
|
||||
if (node == null) {
|
||||
return window;
|
||||
}
|
||||
|
||||
if (node.toString() !== '[object Window]') {
|
||||
var ownerDocument = node.ownerDocument;
|
||||
return ownerDocument ? ownerDocument.defaultView || window : window;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
10
public/assets/@popperjs/core/dist/esm/dom-utils/getWindowScroll.js
vendored
Normal file
10
public/assets/@popperjs/core/dist/esm/dom-utils/getWindowScroll.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import getWindow from "./getWindow.js";
|
||||
export default function getWindowScroll(node) {
|
||||
var win = getWindow(node);
|
||||
var scrollLeft = win.pageXOffset;
|
||||
var scrollTop = win.pageYOffset;
|
||||
return {
|
||||
scrollLeft: scrollLeft,
|
||||
scrollTop: scrollTop
|
||||
};
|
||||
}
|
||||
13
public/assets/@popperjs/core/dist/esm/dom-utils/getWindowScrollBarX.js
vendored
Normal file
13
public/assets/@popperjs/core/dist/esm/dom-utils/getWindowScrollBarX.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import getBoundingClientRect from "./getBoundingClientRect.js";
|
||||
import getDocumentElement from "./getDocumentElement.js";
|
||||
import getWindowScroll from "./getWindowScroll.js";
|
||||
export default function getWindowScrollBarX(element) {
|
||||
// If <html> has a CSS width greater than the viewport, then this will be
|
||||
// incorrect for RTL.
|
||||
// Popper 1 is broken in this case and never had a bug report so let's assume
|
||||
// it's not an issue. I don't think anyone ever specifies width on <html>
|
||||
// anyway.
|
||||
// Browsers where the left scrollbar doesn't cause an issue report `0` for
|
||||
// this (e.g. Edge 2019, IE11, Safari)
|
||||
return getBoundingClientRect(getDocumentElement(element)).left + getWindowScroll(element).scrollLeft;
|
||||
}
|
||||
23
public/assets/@popperjs/core/dist/esm/dom-utils/instanceOf.js
vendored
Normal file
23
public/assets/@popperjs/core/dist/esm/dom-utils/instanceOf.js
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
import getWindow from "./getWindow.js";
|
||||
|
||||
function isElement(node) {
|
||||
var OwnElement = getWindow(node).Element;
|
||||
return node instanceof OwnElement || node instanceof Element;
|
||||
}
|
||||
|
||||
function isHTMLElement(node) {
|
||||
var OwnElement = getWindow(node).HTMLElement;
|
||||
return node instanceof OwnElement || node instanceof HTMLElement;
|
||||
}
|
||||
|
||||
function isShadowRoot(node) {
|
||||
// IE 11 has no ShadowRoot
|
||||
if (typeof ShadowRoot === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var OwnElement = getWindow(node).ShadowRoot;
|
||||
return node instanceof OwnElement || node instanceof ShadowRoot;
|
||||
}
|
||||
|
||||
export { isElement, isHTMLElement, isShadowRoot };
|
||||
4
public/assets/@popperjs/core/dist/esm/dom-utils/isLayoutViewport.js
vendored
Normal file
4
public/assets/@popperjs/core/dist/esm/dom-utils/isLayoutViewport.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import getUAString from "../utils/userAgent.js";
|
||||
export default function isLayoutViewport() {
|
||||
return !/^((?!chrome|android).)*safari/i.test(getUAString());
|
||||
}
|
||||
10
public/assets/@popperjs/core/dist/esm/dom-utils/isScrollParent.js
vendored
Normal file
10
public/assets/@popperjs/core/dist/esm/dom-utils/isScrollParent.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import getComputedStyle from "./getComputedStyle.js";
|
||||
export default function isScrollParent(element) {
|
||||
// Firefox wants us to check `-x` and `-y` variations as well
|
||||
var _getComputedStyle = getComputedStyle(element),
|
||||
overflow = _getComputedStyle.overflow,
|
||||
overflowX = _getComputedStyle.overflowX,
|
||||
overflowY = _getComputedStyle.overflowY;
|
||||
|
||||
return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX);
|
||||
}
|
||||
4
public/assets/@popperjs/core/dist/esm/dom-utils/isTableElement.js
vendored
Normal file
4
public/assets/@popperjs/core/dist/esm/dom-utils/isTableElement.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import getNodeName from "./getNodeName.js";
|
||||
export default function isTableElement(element) {
|
||||
return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0;
|
||||
}
|
||||
26
public/assets/@popperjs/core/dist/esm/dom-utils/listScrollParents.js
vendored
Normal file
26
public/assets/@popperjs/core/dist/esm/dom-utils/listScrollParents.js
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
import getScrollParent from "./getScrollParent.js";
|
||||
import getParentNode from "./getParentNode.js";
|
||||
import getWindow from "./getWindow.js";
|
||||
import isScrollParent from "./isScrollParent.js";
|
||||
/*
|
||||
given a DOM element, return the list of all scroll parents, up the list of ancesors
|
||||
until we get to the top window object. This list is what we attach scroll listeners
|
||||
to, because if any of these parent elements scroll, we'll need to re-calculate the
|
||||
reference element's position.
|
||||
*/
|
||||
|
||||
export default function listScrollParents(element, list) {
|
||||
var _element$ownerDocumen;
|
||||
|
||||
if (list === void 0) {
|
||||
list = [];
|
||||
}
|
||||
|
||||
var scrollParent = getScrollParent(element);
|
||||
var isBody = scrollParent === ((_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body);
|
||||
var win = getWindow(scrollParent);
|
||||
var target = isBody ? [win].concat(win.visualViewport || [], isScrollParent(scrollParent) ? scrollParent : []) : scrollParent;
|
||||
var updatedList = list.concat(target);
|
||||
return isBody ? updatedList : // $FlowFixMe[incompatible-call]: isBody tells us target will be an HTMLElement here
|
||||
updatedList.concat(listScrollParents(getParentNode(target)));
|
||||
}
|
||||
31
public/assets/@popperjs/core/dist/esm/enums.js
vendored
Normal file
31
public/assets/@popperjs/core/dist/esm/enums.js
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
export var top = 'top';
|
||||
export var bottom = 'bottom';
|
||||
export var right = 'right';
|
||||
export var left = 'left';
|
||||
export var auto = 'auto';
|
||||
export var basePlacements = [top, bottom, right, left];
|
||||
export var start = 'start';
|
||||
export var end = 'end';
|
||||
export var clippingParents = 'clippingParents';
|
||||
export var viewport = 'viewport';
|
||||
export var popper = 'popper';
|
||||
export var reference = 'reference';
|
||||
export var variationPlacements = /*#__PURE__*/basePlacements.reduce(function (acc, placement) {
|
||||
return acc.concat([placement + "-" + start, placement + "-" + end]);
|
||||
}, []);
|
||||
export var placements = /*#__PURE__*/[].concat(basePlacements, [auto]).reduce(function (acc, placement) {
|
||||
return acc.concat([placement, placement + "-" + start, placement + "-" + end]);
|
||||
}, []); // modifiers that need to read the DOM
|
||||
|
||||
export var beforeRead = 'beforeRead';
|
||||
export var read = 'read';
|
||||
export var afterRead = 'afterRead'; // pure-logic modifiers
|
||||
|
||||
export var beforeMain = 'beforeMain';
|
||||
export var main = 'main';
|
||||
export var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state)
|
||||
|
||||
export var beforeWrite = 'beforeWrite';
|
||||
export var write = 'write';
|
||||
export var afterWrite = 'afterWrite';
|
||||
export var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite];
|
||||
8
public/assets/@popperjs/core/dist/esm/index.js
vendored
Normal file
8
public/assets/@popperjs/core/dist/esm/index.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./enums.js";
|
||||
export * from "./modifiers/index.js"; // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
export { popperGenerator, detectOverflow, createPopper as createPopperBase } from "./createPopper.js"; // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
export { createPopper } from "./popper.js"; // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
export { createPopper as createPopperLite } from "./popper-lite.js";
|
||||
84
public/assets/@popperjs/core/dist/esm/modifiers/applyStyles.js
vendored
Normal file
84
public/assets/@popperjs/core/dist/esm/modifiers/applyStyles.js
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
import getNodeName from "../dom-utils/getNodeName.js";
|
||||
import { isHTMLElement } from "../dom-utils/instanceOf.js"; // This modifier takes the styles prepared by the `computeStyles` modifier
|
||||
// and applies them to the HTMLElements such as popper and arrow
|
||||
|
||||
function applyStyles(_ref) {
|
||||
var state = _ref.state;
|
||||
Object.keys(state.elements).forEach(function (name) {
|
||||
var style = state.styles[name] || {};
|
||||
var attributes = state.attributes[name] || {};
|
||||
var element = state.elements[name]; // arrow is optional + virtual elements
|
||||
|
||||
if (!isHTMLElement(element) || !getNodeName(element)) {
|
||||
return;
|
||||
} // Flow doesn't support to extend this property, but it's the most
|
||||
// effective way to apply styles to an HTMLElement
|
||||
// $FlowFixMe[cannot-write]
|
||||
|
||||
|
||||
Object.assign(element.style, style);
|
||||
Object.keys(attributes).forEach(function (name) {
|
||||
var value = attributes[name];
|
||||
|
||||
if (value === false) {
|
||||
element.removeAttribute(name);
|
||||
} else {
|
||||
element.setAttribute(name, value === true ? '' : value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function effect(_ref2) {
|
||||
var state = _ref2.state;
|
||||
var initialStyles = {
|
||||
popper: {
|
||||
position: state.options.strategy,
|
||||
left: '0',
|
||||
top: '0',
|
||||
margin: '0'
|
||||
},
|
||||
arrow: {
|
||||
position: 'absolute'
|
||||
},
|
||||
reference: {}
|
||||
};
|
||||
Object.assign(state.elements.popper.style, initialStyles.popper);
|
||||
state.styles = initialStyles;
|
||||
|
||||
if (state.elements.arrow) {
|
||||
Object.assign(state.elements.arrow.style, initialStyles.arrow);
|
||||
}
|
||||
|
||||
return function () {
|
||||
Object.keys(state.elements).forEach(function (name) {
|
||||
var element = state.elements[name];
|
||||
var attributes = state.attributes[name] || {};
|
||||
var styleProperties = Object.keys(state.styles.hasOwnProperty(name) ? state.styles[name] : initialStyles[name]); // Set all values to an empty string to unset them
|
||||
|
||||
var style = styleProperties.reduce(function (style, property) {
|
||||
style[property] = '';
|
||||
return style;
|
||||
}, {}); // arrow is optional + virtual elements
|
||||
|
||||
if (!isHTMLElement(element) || !getNodeName(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(element.style, style);
|
||||
Object.keys(attributes).forEach(function (attribute) {
|
||||
element.removeAttribute(attribute);
|
||||
});
|
||||
});
|
||||
};
|
||||
} // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
|
||||
export default {
|
||||
name: 'applyStyles',
|
||||
enabled: true,
|
||||
phase: 'write',
|
||||
fn: applyStyles,
|
||||
effect: effect,
|
||||
requires: ['computeStyles']
|
||||
};
|
||||
90
public/assets/@popperjs/core/dist/esm/modifiers/arrow.js
vendored
Normal file
90
public/assets/@popperjs/core/dist/esm/modifiers/arrow.js
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
import getBasePlacement from "../utils/getBasePlacement.js";
|
||||
import getLayoutRect from "../dom-utils/getLayoutRect.js";
|
||||
import contains from "../dom-utils/contains.js";
|
||||
import getOffsetParent from "../dom-utils/getOffsetParent.js";
|
||||
import getMainAxisFromPlacement from "../utils/getMainAxisFromPlacement.js";
|
||||
import { within } from "../utils/within.js";
|
||||
import mergePaddingObject from "../utils/mergePaddingObject.js";
|
||||
import expandToHashMap from "../utils/expandToHashMap.js";
|
||||
import { left, right, basePlacements, top, bottom } from "../enums.js"; // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
var toPaddingObject = function toPaddingObject(padding, state) {
|
||||
padding = typeof padding === 'function' ? padding(Object.assign({}, state.rects, {
|
||||
placement: state.placement
|
||||
})) : padding;
|
||||
return mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements));
|
||||
};
|
||||
|
||||
function arrow(_ref) {
|
||||
var _state$modifiersData$;
|
||||
|
||||
var state = _ref.state,
|
||||
name = _ref.name,
|
||||
options = _ref.options;
|
||||
var arrowElement = state.elements.arrow;
|
||||
var popperOffsets = state.modifiersData.popperOffsets;
|
||||
var basePlacement = getBasePlacement(state.placement);
|
||||
var axis = getMainAxisFromPlacement(basePlacement);
|
||||
var isVertical = [left, right].indexOf(basePlacement) >= 0;
|
||||
var len = isVertical ? 'height' : 'width';
|
||||
|
||||
if (!arrowElement || !popperOffsets) {
|
||||
return;
|
||||
}
|
||||
|
||||
var paddingObject = toPaddingObject(options.padding, state);
|
||||
var arrowRect = getLayoutRect(arrowElement);
|
||||
var minProp = axis === 'y' ? top : left;
|
||||
var maxProp = axis === 'y' ? bottom : right;
|
||||
var endDiff = state.rects.reference[len] + state.rects.reference[axis] - popperOffsets[axis] - state.rects.popper[len];
|
||||
var startDiff = popperOffsets[axis] - state.rects.reference[axis];
|
||||
var arrowOffsetParent = getOffsetParent(arrowElement);
|
||||
var clientSize = arrowOffsetParent ? axis === 'y' ? arrowOffsetParent.clientHeight || 0 : arrowOffsetParent.clientWidth || 0 : 0;
|
||||
var centerToReference = endDiff / 2 - startDiff / 2; // Make sure the arrow doesn't overflow the popper if the center point is
|
||||
// outside of the popper bounds
|
||||
|
||||
var min = paddingObject[minProp];
|
||||
var max = clientSize - arrowRect[len] - paddingObject[maxProp];
|
||||
var center = clientSize / 2 - arrowRect[len] / 2 + centerToReference;
|
||||
var offset = within(min, center, max); // Prevents breaking syntax highlighting...
|
||||
|
||||
var axisProp = axis;
|
||||
state.modifiersData[name] = (_state$modifiersData$ = {}, _state$modifiersData$[axisProp] = offset, _state$modifiersData$.centerOffset = offset - center, _state$modifiersData$);
|
||||
}
|
||||
|
||||
function effect(_ref2) {
|
||||
var state = _ref2.state,
|
||||
options = _ref2.options;
|
||||
var _options$element = options.element,
|
||||
arrowElement = _options$element === void 0 ? '[data-popper-arrow]' : _options$element;
|
||||
|
||||
if (arrowElement == null) {
|
||||
return;
|
||||
} // CSS selector
|
||||
|
||||
|
||||
if (typeof arrowElement === 'string') {
|
||||
arrowElement = state.elements.popper.querySelector(arrowElement);
|
||||
|
||||
if (!arrowElement) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contains(state.elements.popper, arrowElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.elements.arrow = arrowElement;
|
||||
} // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
|
||||
export default {
|
||||
name: 'arrow',
|
||||
enabled: true,
|
||||
phase: 'main',
|
||||
fn: arrow,
|
||||
effect: effect,
|
||||
requires: ['popperOffsets'],
|
||||
requiresIfExists: ['preventOverflow']
|
||||
};
|
||||
169
public/assets/@popperjs/core/dist/esm/modifiers/computeStyles.js
vendored
Normal file
169
public/assets/@popperjs/core/dist/esm/modifiers/computeStyles.js
vendored
Normal file
@@ -0,0 +1,169 @@
|
||||
import { top, left, right, bottom, end } from "../enums.js";
|
||||
import getOffsetParent from "../dom-utils/getOffsetParent.js";
|
||||
import getWindow from "../dom-utils/getWindow.js";
|
||||
import getDocumentElement from "../dom-utils/getDocumentElement.js";
|
||||
import getComputedStyle from "../dom-utils/getComputedStyle.js";
|
||||
import getBasePlacement from "../utils/getBasePlacement.js";
|
||||
import getVariation from "../utils/getVariation.js";
|
||||
import { round } from "../utils/math.js"; // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
var unsetSides = {
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
left: 'auto'
|
||||
}; // Round the offsets to the nearest suitable subpixel based on the DPR.
|
||||
// Zooming can change the DPR, but it seems to report a value that will
|
||||
// cleanly divide the values into the appropriate subpixels.
|
||||
|
||||
function roundOffsetsByDPR(_ref, win) {
|
||||
var x = _ref.x,
|
||||
y = _ref.y;
|
||||
var dpr = win.devicePixelRatio || 1;
|
||||
return {
|
||||
x: round(x * dpr) / dpr || 0,
|
||||
y: round(y * dpr) / dpr || 0
|
||||
};
|
||||
}
|
||||
|
||||
export function mapToStyles(_ref2) {
|
||||
var _Object$assign2;
|
||||
|
||||
var popper = _ref2.popper,
|
||||
popperRect = _ref2.popperRect,
|
||||
placement = _ref2.placement,
|
||||
variation = _ref2.variation,
|
||||
offsets = _ref2.offsets,
|
||||
position = _ref2.position,
|
||||
gpuAcceleration = _ref2.gpuAcceleration,
|
||||
adaptive = _ref2.adaptive,
|
||||
roundOffsets = _ref2.roundOffsets,
|
||||
isFixed = _ref2.isFixed;
|
||||
var _offsets$x = offsets.x,
|
||||
x = _offsets$x === void 0 ? 0 : _offsets$x,
|
||||
_offsets$y = offsets.y,
|
||||
y = _offsets$y === void 0 ? 0 : _offsets$y;
|
||||
|
||||
var _ref3 = typeof roundOffsets === 'function' ? roundOffsets({
|
||||
x: x,
|
||||
y: y
|
||||
}) : {
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
|
||||
x = _ref3.x;
|
||||
y = _ref3.y;
|
||||
var hasX = offsets.hasOwnProperty('x');
|
||||
var hasY = offsets.hasOwnProperty('y');
|
||||
var sideX = left;
|
||||
var sideY = top;
|
||||
var win = window;
|
||||
|
||||
if (adaptive) {
|
||||
var offsetParent = getOffsetParent(popper);
|
||||
var heightProp = 'clientHeight';
|
||||
var widthProp = 'clientWidth';
|
||||
|
||||
if (offsetParent === getWindow(popper)) {
|
||||
offsetParent = getDocumentElement(popper);
|
||||
|
||||
if (getComputedStyle(offsetParent).position !== 'static' && position === 'absolute') {
|
||||
heightProp = 'scrollHeight';
|
||||
widthProp = 'scrollWidth';
|
||||
}
|
||||
} // $FlowFixMe[incompatible-cast]: force type refinement, we compare offsetParent with window above, but Flow doesn't detect it
|
||||
|
||||
|
||||
offsetParent = offsetParent;
|
||||
|
||||
if (placement === top || (placement === left || placement === right) && variation === end) {
|
||||
sideY = bottom;
|
||||
var offsetY = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.height : // $FlowFixMe[prop-missing]
|
||||
offsetParent[heightProp];
|
||||
y -= offsetY - popperRect.height;
|
||||
y *= gpuAcceleration ? 1 : -1;
|
||||
}
|
||||
|
||||
if (placement === left || (placement === top || placement === bottom) && variation === end) {
|
||||
sideX = right;
|
||||
var offsetX = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.width : // $FlowFixMe[prop-missing]
|
||||
offsetParent[widthProp];
|
||||
x -= offsetX - popperRect.width;
|
||||
x *= gpuAcceleration ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
var commonStyles = Object.assign({
|
||||
position: position
|
||||
}, adaptive && unsetSides);
|
||||
|
||||
var _ref4 = roundOffsets === true ? roundOffsetsByDPR({
|
||||
x: x,
|
||||
y: y
|
||||
}, getWindow(popper)) : {
|
||||
x: x,
|
||||
y: y
|
||||
};
|
||||
|
||||
x = _ref4.x;
|
||||
y = _ref4.y;
|
||||
|
||||
if (gpuAcceleration) {
|
||||
var _Object$assign;
|
||||
|
||||
return Object.assign({}, commonStyles, (_Object$assign = {}, _Object$assign[sideY] = hasY ? '0' : '', _Object$assign[sideX] = hasX ? '0' : '', _Object$assign.transform = (win.devicePixelRatio || 1) <= 1 ? "translate(" + x + "px, " + y + "px)" : "translate3d(" + x + "px, " + y + "px, 0)", _Object$assign));
|
||||
}
|
||||
|
||||
return Object.assign({}, commonStyles, (_Object$assign2 = {}, _Object$assign2[sideY] = hasY ? y + "px" : '', _Object$assign2[sideX] = hasX ? x + "px" : '', _Object$assign2.transform = '', _Object$assign2));
|
||||
}
|
||||
|
||||
function computeStyles(_ref5) {
|
||||
var state = _ref5.state,
|
||||
options = _ref5.options;
|
||||
var _options$gpuAccelerat = options.gpuAcceleration,
|
||||
gpuAcceleration = _options$gpuAccelerat === void 0 ? true : _options$gpuAccelerat,
|
||||
_options$adaptive = options.adaptive,
|
||||
adaptive = _options$adaptive === void 0 ? true : _options$adaptive,
|
||||
_options$roundOffsets = options.roundOffsets,
|
||||
roundOffsets = _options$roundOffsets === void 0 ? true : _options$roundOffsets;
|
||||
var commonStyles = {
|
||||
placement: getBasePlacement(state.placement),
|
||||
variation: getVariation(state.placement),
|
||||
popper: state.elements.popper,
|
||||
popperRect: state.rects.popper,
|
||||
gpuAcceleration: gpuAcceleration,
|
||||
isFixed: state.options.strategy === 'fixed'
|
||||
};
|
||||
|
||||
if (state.modifiersData.popperOffsets != null) {
|
||||
state.styles.popper = Object.assign({}, state.styles.popper, mapToStyles(Object.assign({}, commonStyles, {
|
||||
offsets: state.modifiersData.popperOffsets,
|
||||
position: state.options.strategy,
|
||||
adaptive: adaptive,
|
||||
roundOffsets: roundOffsets
|
||||
})));
|
||||
}
|
||||
|
||||
if (state.modifiersData.arrow != null) {
|
||||
state.styles.arrow = Object.assign({}, state.styles.arrow, mapToStyles(Object.assign({}, commonStyles, {
|
||||
offsets: state.modifiersData.arrow,
|
||||
position: 'absolute',
|
||||
adaptive: false,
|
||||
roundOffsets: roundOffsets
|
||||
})));
|
||||
}
|
||||
|
||||
state.attributes.popper = Object.assign({}, state.attributes.popper, {
|
||||
'data-popper-placement': state.placement
|
||||
});
|
||||
} // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
|
||||
export default {
|
||||
name: 'computeStyles',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
fn: computeStyles,
|
||||
data: {}
|
||||
};
|
||||
49
public/assets/@popperjs/core/dist/esm/modifiers/eventListeners.js
vendored
Normal file
49
public/assets/@popperjs/core/dist/esm/modifiers/eventListeners.js
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
import getWindow from "../dom-utils/getWindow.js"; // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
var passive = {
|
||||
passive: true
|
||||
};
|
||||
|
||||
function effect(_ref) {
|
||||
var state = _ref.state,
|
||||
instance = _ref.instance,
|
||||
options = _ref.options;
|
||||
var _options$scroll = options.scroll,
|
||||
scroll = _options$scroll === void 0 ? true : _options$scroll,
|
||||
_options$resize = options.resize,
|
||||
resize = _options$resize === void 0 ? true : _options$resize;
|
||||
var window = getWindow(state.elements.popper);
|
||||
var scrollParents = [].concat(state.scrollParents.reference, state.scrollParents.popper);
|
||||
|
||||
if (scroll) {
|
||||
scrollParents.forEach(function (scrollParent) {
|
||||
scrollParent.addEventListener('scroll', instance.update, passive);
|
||||
});
|
||||
}
|
||||
|
||||
if (resize) {
|
||||
window.addEventListener('resize', instance.update, passive);
|
||||
}
|
||||
|
||||
return function () {
|
||||
if (scroll) {
|
||||
scrollParents.forEach(function (scrollParent) {
|
||||
scrollParent.removeEventListener('scroll', instance.update, passive);
|
||||
});
|
||||
}
|
||||
|
||||
if (resize) {
|
||||
window.removeEventListener('resize', instance.update, passive);
|
||||
}
|
||||
};
|
||||
} // eslint-disable-next-line import/no-unused-modules
|
||||
|
||||
|
||||
export default {
|
||||
name: 'eventListeners',
|
||||
enabled: true,
|
||||
phase: 'write',
|
||||
fn: function fn() {},
|
||||
effect: effect,
|
||||
data: {}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user