chore: merge backend repo history into monorepo at backend/

git-subtree-dir: backend
git-subtree-mainline: dc11190527
git-subtree-split: acae976622
This commit is contained in:
2026-09-27 10:30:16 +03:30
67 changed files with 6835 additions and 0 deletions
@@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"dating-app/internal/models"
"dating-app/internal/utils"
)
// AdminRequired باید بعد از AuthRequired استفاده بشه. فقط کاربرانی که IsAdmin=true
// دارن اجازه‌ی عبور دارن.
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userVal, exists := c.Get(ContextUserKey)
if !exists {
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
c.Abort()
return
}
user, ok := userVal.(*models.User)
if !ok || !user.IsAdmin {
utils.Fail(c, http.StatusForbidden, "دسترسی فقط برای ادمین است", nil)
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
const (
ContextUserKey = "current_user"
)
// AuthRequired توکن access رو از هدر Authorization می‌خونه، اعتبارسنجی می‌کنه
// و کاربر مربوطه رو توی context قرار می‌ده.
func AuthRequired(jwtManager *utils.JWTManager, userRepo *repository.UserRepository) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") {
utils.Fail(c, http.StatusUnauthorized, "توکن احراز هویت ارسال نشده است", nil)
c.Abort()
return
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
claims, err := jwtManager.ParseAccessToken(tokenStr)
if err != nil {
utils.Fail(c, http.StatusUnauthorized, "توکن نامعتبر یا منقضی شده است", nil)
c.Abort()
return
}
user, err := userRepo.FindByID(claims.UserID)
if err != nil {
utils.Fail(c, http.StatusUnauthorized, "کاربر یافت نشد", nil)
c.Abort()
return
}
if !user.IsActive {
utils.Fail(c, http.StatusForbidden, "حساب کاربری غیرفعال است", nil)
c.Abort()
return
}
// اگه کاربر بعد از صادر شدن access token فعلی بلاک شده باشه، نباید تا
// انقضای طبیعی همون توکن بتونه درخواست بزنه؛ این چک همون لحظه قطعش می‌کنه.
if user.State == models.UserStateBlocked {
utils.Fail(c, http.StatusForbidden, "حساب کاربری مسدود شده است", nil)
c.Abort()
return
}
c.Set(ContextUserKey, user)
c.Next()
}
}
+23
View File
@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS یک میان‌افزار ساده برای اجازه دادن به درخواست‌های Cross-Origin
// (مناسب برای توسعه؛ در پروداکشن origin ها رو محدود کن)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
@@ -0,0 +1,49 @@
package middleware
import (
"compress/gzip"
"io"
"strings"
"github.com/gin-gonic/gin"
)
type gzipWriter struct {
gin.ResponseWriter
writer io.Writer
}
func (g *gzipWriter) Write(data []byte) (int, error) {
return g.writer.Write(data)
}
func (g *gzipWriter) WriteString(s string) (int, error) {
return g.writer.Write([]byte(s))
}
// Gzip پاسخ‌های JSON رو (وقتی کلاینت gzip رو توی Accept-Encoding پشتیبانی کنه)
// فشرده می‌کنه؛ بدون هیچ وابستگی بیرونی، فقط با compress/gzip استاندارد گو.
// برای مسیرهایی مثل فید دیسکاوری که پاسخ JSON نسبتاً بزرگی برمی‌گردونن، حجم
// انتقال رو محسوس کم می‌کنه.
func Gzip() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") {
c.Next()
return
}
// فایل‌های عکسِ آپلودشده از قبل فشرده‌ان (jpeg/png/webp)؛ دوباره gzip
// کردنشون فقط CPU هدر می‌ده بدون فایده‌ی محسوس.
if strings.HasPrefix(c.Request.URL.Path, "/uploads") {
c.Next()
return
}
gz := gzip.NewWriter(c.Writer)
defer gz.Close()
c.Header("Content-Encoding", "gzip")
c.Header("Vary", "Accept-Encoding")
c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz}
c.Next()
}
}
@@ -0,0 +1,44 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"dating-app/internal/models"
"dating-app/internal/monitoring"
)
// Metrics هر درخواست رو (متد، مسیر، کد پاسخ، مدت زمان) توی Store ثبت می‌کنه.
// باید قبل از Recovery ثبت بشه (بیرونی‌تر) تا حتی درخواست‌هایی که پنیک می‌کنن
// و توسط Recovery مهار می‌شن هم توی آمار لحاظ بشن.
func Metrics(store *monitoring.Store) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
defer func() {
path := c.FullPath()
if path == "" {
path = c.Request.URL.Path
}
rec := monitoring.RequestRecord{
Time: start,
Method: c.Request.Method,
Path: path,
StatusCode: c.Writer.Status(),
DurationMs: float64(time.Since(start).Microseconds()) / 1000.0,
ClientIP: c.ClientIP(),
}
if userVal, exists := c.Get(ContextUserKey); exists {
if user, ok := userVal.(*models.User); ok {
id := user.ID
rec.UserID = &id
}
}
store.Record(rec)
}()
c.Next()
}
}
@@ -0,0 +1,70 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"dating-app/internal/utils"
)
type bucket struct {
tokens float64
lastSeenAt time.Time
}
// RateLimiter یک rate limiter ساده‌ی token-bucket به‌ازای هر IP؛ برای جلوگیری
// از سوءاستفاده از اندپوینت‌های حساس مثل درخواست OTP (که هزینه‌ی پیامک داره
// و هدف مناسبی برای اسپم/brute-force هست). maxTokens یعنی سقف درخواست
// پشت‌سرهم مجاز، refillPerSecond یعنی سرعت شارژ دوباره‌ی سطل.
func RateLimiter(maxTokens, refillPerSecond float64) gin.HandlerFunc {
var mu sync.Mutex
buckets := make(map[string]*bucket)
// سطل‌های راکد (IP هایی که مدتیه درخواست نزدن) رو دوره‌ای پاک می‌کنه تا
// حافظه با تعداد IP های دیده‌شده بی‌نهایت رشد نکنه.
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-10 * time.Minute)
mu.Lock()
for k, b := range buckets {
if b.lastSeenAt.Before(cutoff) {
delete(buckets, k)
}
}
mu.Unlock()
}
}()
return func(c *gin.Context) {
key := c.ClientIP()
now := time.Now()
mu.Lock()
b, ok := buckets[key]
if !ok {
b = &bucket{tokens: maxTokens, lastSeenAt: now}
buckets[key] = b
}
elapsed := now.Sub(b.lastSeenAt).Seconds()
b.tokens = min(maxTokens, b.tokens+elapsed*refillPerSecond)
b.lastSeenAt = now
allowed := b.tokens >= 1
if allowed {
b.tokens--
}
mu.Unlock()
if !allowed {
utils.Fail(c, http.StatusTooManyRequests, "درخواست‌های زیادی فرستادی؛ کمی صبر کن و دوباره امتحان کن", nil)
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,74 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func newTestEngine(mw gin.HandlerFunc) *gin.Engine {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/x", mw, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
return engine
}
func TestRateLimiter_AllowsWithinLimit(t *testing.T) {
engine := newTestEngine(RateLimiter(3, 1))
for i := 0; i < 3; i++ {
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.RemoteAddr = "1.2.3.4:1111"
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
}
func TestRateLimiter_BlocksOverLimit(t *testing.T) {
engine := newTestEngine(RateLimiter(2, 0)) // بدون شارژ دوباره، فقط ۲ تا مجازه
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.RemoteAddr = "5.6.7.8:2222"
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.RemoteAddr = "5.6.7.8:2222"
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 on 3rd request, got %d", w.Code)
}
}
func TestRateLimiter_SeparateBucketsPerIP(t *testing.T) {
engine := newTestEngine(RateLimiter(1, 0))
req1 := httptest.NewRequest(http.MethodGet, "/x", nil)
req1.RemoteAddr = "9.9.9.9:1"
w1 := httptest.NewRecorder()
engine.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200 for first IP, got %d", w1.Code)
}
req2 := httptest.NewRequest(http.MethodGet, "/x", nil)
req2.RemoteAddr = "8.8.8.8:1"
w2 := httptest.NewRecorder()
engine.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 for a different IP (separate bucket), got %d", w2.Code)
}
}
@@ -0,0 +1,30 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
"dating-app/internal/utils"
)
// Recovery از کرش کردن کل پروسه به‌خاطر panic غیرمنتظره‌ی یک هندلر
// (nil pointer، index out of range و...) جلوگیری می‌کنه: استک‌تریس رو لاگ
// می‌کنه و به‌جای متن ساده‌ی پیش‌فرض Gin، همون envelope استاندارد JSON
// پروژه (utils.Fail) رو برمی‌گردونه تا کلاینت‌ها فرمت یکسانی ببینن.
func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Printf("[PANIC RECOVERED] %v\n%s", r, debug.Stack())
if !c.Writer.Written() {
utils.Fail(c, http.StatusInternalServerError, "خطای غیرمنتظره‌ای رخ داد؛ لطفاً دوباره امتحان کن", nil)
}
c.Abort()
}
}()
c.Next()
}
}