first commit

This commit is contained in:
2026-07-30 09:51:25 +07:00
commit e3aca176b7
82 changed files with 7847 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
package middleware
import (
"net/http"
"strings"
"cardverse/internal/pkg/response"
"cardverse/internal/pkg/utils"
"github.com/gin-gonic/gin"
)
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Error(c, http.StatusUnauthorized, "token tidak ditemukan", nil)
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
response.Error(c, http.StatusUnauthorized, "format token tidak valid", nil)
c.Abort()
return
}
claims, err := utils.ValidateToken(parts[1])
if err != nil {
response.Error(c, http.StatusUnauthorized, "token tidak valid atau kedaluwarsa", err.Error())
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}
func RequireRoles(allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
roleVal, exists := c.Get("role")
if !exists {
response.Error(c, http.StatusUnauthorized, "akses ditolak: autentikasi diperlukan", nil)
c.Abort()
return
}
userRole, ok := roleVal.(string)
if !ok {
response.Error(c, http.StatusForbidden, "akses ditolak: role tidak valid", nil)
c.Abort()
return
}
for _, role := range allowedRoles {
if strings.EqualFold(userRole, role) {
c.Next()
return
}
}
response.Error(c, http.StatusForbidden, "akses ditolak: anda tidak memiliki hak akses", nil)
c.Abort()
}
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS mengizinkan request dari origin lain
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,78 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"time"
"cardverse/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// Logger mencatat setiap request: method, path, status, durasi, IP, dan body request
// (kalau JSON) supaya gampang di-debug. Field sensitif seperti password, token, dsb
// OTOMATIS disensor oleh package logger (lihat internal/pkg/logger), jadi aman
// dipasang bahkan di endpoint login/register.
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
bodyFields := readBodyForLog(c)
c.Next()
duration := time.Since(start)
status := c.Writer.Status()
fields := logger.Fields{
"method": c.Request.Method,
"path": c.Request.URL.Path,
"status": status,
"duration": duration.String(),
"ip": c.ClientIP(),
}
if bodyFields != nil {
fields["body"] = bodyFields
}
if len(c.Errors) > 0 {
fields["gin_errors"] = c.Errors.String()
}
switch {
case status >= 500:
logger.Error("request selesai", fields)
case status >= 400:
logger.Warn("request selesai", fields)
default:
logger.Info("request selesai", fields)
}
}
}
// readBodyForLog membaca body request (kalau berupa JSON) untuk keperluan log,
// lalu mengembalikan body itu ke request supaya handler asli tetap bisa membacanya
// seperti biasa. Field sensitif di dalam body (misal "password") akan disensor
// otomatis oleh package logger saat log ditulis, bukan di sini.
func readBodyForLog(c *gin.Context) map[string]interface{} {
if c.Request.Body == nil {
return nil
}
if c.Request.Method != "POST" && c.Request.Method != "PUT" && c.Request.Method != "PATCH" {
return nil
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil
}
// kembalikan body supaya bisa dibaca lagi oleh ShouldBindJSON di handler
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
var parsed map[string]interface{}
if err := json.Unmarshal(bodyBytes, &parsed); err != nil {
return nil // bukan JSON (atau body kosong), tidak masalah, cukup skip
}
return parsed
}

View File

@@ -0,0 +1,94 @@
package middleware
import (
"net/http"
"sync"
"time"
"cardverse/internal/pkg/response"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// visitor menyimpan limiter token bucket milik satu IP + waktu terakhir dia request
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
// visitorStore menyimpan limiter per-IP secara thread-safe.
// Setiap IP punya "kantong token" sendiri, jadi satu IP yang spam tidak
// mempengaruhi jatah IP lain.
type visitorStore struct {
mu sync.Mutex
visitors map[string]*visitor
rps rate.Limit
burst int
}
func newVisitorStore(requestsPerSecond float64, burst int) *visitorStore {
vs := &visitorStore{
visitors: make(map[string]*visitor),
rps: rate.Limit(requestsPerSecond),
burst: burst,
}
go vs.cleanupLoop()
return vs
}
func (vs *visitorStore) getLimiter(ip string) *rate.Limiter {
vs.mu.Lock()
defer vs.mu.Unlock()
v, exists := vs.visitors[ip]
if !exists {
limiter := rate.NewLimiter(vs.rps, vs.burst)
vs.visitors[ip] = &visitor{limiter: limiter, lastSeen: time.Now()}
return limiter
}
v.lastSeen = time.Now()
return v.limiter
}
// cleanupLoop membuang data IP yang sudah tidak aktif > 3 menit supaya memori tidak terus membengkak
func (vs *visitorStore) cleanupLoop() {
for {
time.Sleep(time.Minute)
vs.mu.Lock()
for ip, v := range vs.visitors {
if time.Since(v.lastSeen) > 3*time.Minute {
delete(vs.visitors, ip)
}
}
vs.mu.Unlock()
}
}
// RateLimiter membatasi jumlah request per-IP menggunakan algoritma token bucket.
//
// - requestsPerSecond: rata-rata request yang diizinkan per detik (token yang "diisi ulang" per detik)
// - burst: jumlah request maksimum yang boleh "meledak" sekaligus (kapasitas kantong token)
//
// Contoh: RateLimiter(5, 10) artinya rata-rata 5 request/detik diizinkan,
// tapi boleh burst sampai 10 request sekaligus selama tokennya masih ada.
//
// Response saat limit terlampaui: HTTP 429 Too Many Requests.
func RateLimiter(requestsPerSecond float64, burst int) gin.HandlerFunc {
store := newVisitorStore(requestsPerSecond, burst)
return func(c *gin.Context) {
ip := c.ClientIP()
limiter := store.getLimiter(ip)
if !limiter.Allow() {
response.Error(c, http.StatusTooManyRequests, "terlalu banyak request, coba lagi nanti", nil)
c.Abort()
return
}
c.Next()
}
}

View File

@@ -0,0 +1,78 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func setupRouterWithRateLimit(rps float64, burst int) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(RateLimiter(rps, burst))
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
return r
}
func TestRateLimiter_MengizinkanRequestSelamaTokenTersedia(t *testing.T) {
// burst 3 artinya 3 request pertama harus tetap lolos meski dikirim beruntun
r := setupRouterWithRateLimit(1, 3)
for i := 0; i < 3; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request ke-%d: mau status 200, dapat %d", i+1, w.Code)
}
}
}
func TestRateLimiter_MenolakSetelahBurstHabis(t *testing.T) {
// burst 2: request ke-3 yang dikirim beruntun (tanpa jeda) harus ditolak 429
r := setupRouterWithRateLimit(1, 2)
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request ke-%d seharusnya lolos, dapat status %d", i+1, w.Code)
}
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("request ke-3 seharusnya ditolak 429, dapat status %d", w.Code)
}
}
func TestRateLimiter_IPBerbedaPunyaJatahTerpisah(t *testing.T) {
// burst 1: IP A dan IP B masing-masing harus dapat 1 jatah request sendiri-sendiri
r := setupRouterWithRateLimit(1, 1)
reqA := httptest.NewRequest(http.MethodGet, "/ping", nil)
reqA.RemoteAddr = "1.1.1.1:1234"
wA := httptest.NewRecorder()
r.ServeHTTP(wA, reqA)
reqB := httptest.NewRequest(http.MethodGet, "/ping", nil)
reqB.RemoteAddr = "2.2.2.2:5678"
wB := httptest.NewRecorder()
r.ServeHTTP(wB, reqB)
if wA.Code != http.StatusOK {
t.Errorf("IP A request pertama seharusnya lolos, dapat status %d", wA.Code)
}
if wB.Code != http.StatusOK {
t.Errorf("IP B request pertama seharusnya lolos meski IP A sudah pakai jatahnya, dapat status %d", wB.Code)
}
}