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,105 @@
package logger
import (
"encoding/json"
"io"
"log"
"os"
"strings"
"time"
)
// Fields adalah data tambahan yang disisipkan ke satu baris log, contoh:
//
// logger.Info("request selesai", logger.Fields{"path": "/users", "status": 200})
type Fields map[string]interface{}
var std = log.New(os.Stdout, "", 0)
// SetOutput mengganti tujuan tulis log. Berguna untuk unit test (redirect ke buffer)
// atau kalau nanti mau kirim log ke file/agent lain.
func SetOutput(w io.Writer) {
std = log.New(w, "", 0)
}
var sensitiveKeys = map[string]bool{
"password": true,
"password_confirmation": true,
"old_password": true,
"new_password": true,
"confirm_password": true,
"token": true,
"access_token": true,
"refresh_token": true,
"authorization": true,
"secret": true,
"jwt_secret": true,
"credit_card": true,
"card_number": true,
"cvv": true,
"otp": true,
}
const redactedValue = "***REDACTED***"
// sanitize menyensor field sensitif secara rekursif (termasuk di dalam nested object/body JSON)
// supaya data seperti password tidak pernah ikut tercetak di log, walau caller lupa menyaringnya.
func sanitize(fields Fields) Fields {
if fields == nil {
return nil
}
clean := make(Fields, len(fields))
for key, value := range fields {
if sensitiveKeys[strings.ToLower(key)] {
clean[key] = redactedValue
continue
}
switch v := value.(type) {
case map[string]interface{}:
clean[key] = sanitize(Fields(v))
case Fields:
clean[key] = sanitize(v)
default:
clean[key] = value
}
}
return clean
}
type entry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
Fields Fields `json:"fields,omitempty"`
}
func write(level, msg string, fields Fields) {
e := entry{
Time: time.Now().Format(time.RFC3339),
Level: level,
Message: msg,
Fields: sanitize(fields),
}
b, err := json.Marshal(e)
if err != nil {
// fallback kalau ternyata ada value yang gagal di-marshal, tetap harus ada jejak log-nya
std.Printf("[%s] %s (gagal marshal fields: %v)", level, msg, err)
return
}
std.Println(string(b))
}
// Info dipakai untuk mencatat aktivitas normal, contoh: request masuk dan sukses diproses.
func Info(msg string, fields Fields) { write("INFO", msg, fields) }
// Warn dipakai untuk kondisi yang bukan bug tapi perlu diperhatikan, contoh: request 4xx
// (input salah, tidak diotorisasi, dst).
func Warn(msg string, fields Fields) { write("WARN", msg, fields) }
// Error dipakai untuk kegagalan sisi server (5xx) atau error tak terduga lainnya yang
// butuh investigasi lebih lanjut oleh developer.
func Error(msg string, fields Fields) { write("ERROR", msg, fields) }

View File

@@ -0,0 +1,131 @@
package response
import (
"math"
"net/http"
"cardverse/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
// Response adalah struktur baku untuk seluruh response API
type Response struct {
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Meta interface{} `json:"meta,omitempty"`
Error interface{} `json:"error,omitempty"`
}
// Pagination berisi metadata pagination yang dikirim di field "meta"
type Pagination struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalData int64 `json:"total_data"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
// NewPagination menghitung metadata pagination dari page, limit, dan total data.
// Dipakai di service/handler setelah query COUNT(*) ke database.
func NewPagination(page, limit int, totalData int64) Pagination {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 10
}
totalPages := int(math.Ceil(float64(totalData) / float64(limit)))
if totalPages < 1 {
totalPages = 1
}
return Pagination{
Page: page,
Limit: limit,
TotalData: totalData,
TotalPages: totalPages,
HasNext: page < totalPages,
HasPrev: page > 1,
}
}
func Success(c *gin.Context, statusCode int, message string, data interface{}) {
c.JSON(statusCode, Response{
Message: message,
Data: data,
})
}
func SuccessWithPagination(c *gin.Context, statusCode int, message string, data interface{}, pagination Pagination) {
c.JSON(statusCode, Response{
Message: message,
Data: data,
Meta: pagination,
})
}
var defaultErrorMessages = map[int]string{
http.StatusUnauthorized: "Anda tidak memiliki akses, silakan login kembali",
http.StatusForbidden: "Anda tidak memiliki izin untuk melakukan aksi ini",
http.StatusNotFound: "Data yang diminta tidak ditemukan",
http.StatusConflict: "Data sudah ada atau bertentangan dengan data yang tersimpan",
http.StatusUnprocessableEntity: "Data yang dikirim tidak dapat diproses",
http.StatusTooManyRequests: "Terlalu banyak request, silakan coba lagi nanti",
http.StatusInternalServerError: "Terjadi kesalahan pada server, silakan coba lagi nanti",
http.StatusBadGateway: "Layanan sedang tidak tersedia, silakan coba lagi nanti",
http.StatusServiceUnavailable: "Layanan sedang tidak tersedia, silakan coba lagi nanti",
}
const fallbackErrorMessage = "Terjadi kesalahan, silakan coba lagi nanti"
// Error mengirim response gagal ke client.
//
// - Untuk 400 Bad Request: message & err (data) dikirim apa adanya ke client — biasanya
// cuma detail validasi input, aman untuk membantu client memperbaiki request-nya.
// - Untuk status lain (401, 403, 404, 409, 500, dst): client HANYA menerima pesan generik
// dari defaultErrorMessages, TIDAK menerima err/message asli sama sekali. Detail asli
// (message & err yang dikirim caller) dicatat lewat logger supaya tetap bisa di-debug
// dari log server tanpa membocorkannya ke response API.
func Error(c *gin.Context, statusCode int, message string, err interface{}) {
if statusCode == http.StatusBadRequest {
c.JSON(statusCode, Response{
Message: message,
Error: err,
})
return
}
logFields := logger.Fields{
"method": c.Request.Method,
"path": c.Request.URL.Path,
"status": statusCode,
"ip": c.ClientIP(),
"detail": message,
}
if err != nil {
logFields["error"] = err
}
if statusCode >= http.StatusInternalServerError {
logger.Error("request gagal diproses", logFields)
} else {
logger.Warn("request ditolak", logFields)
}
clientMessage := message
if clientMessage == "" {
if msg, ok := defaultErrorMessages[statusCode]; ok {
clientMessage = msg
} else {
clientMessage = fallbackErrorMessage
}
}
c.JSON(statusCode, Response{
Message: clientMessage,
})
}

View File

@@ -0,0 +1,28 @@
package utils
import (
"os"
"path/filepath"
"strings"
)
// DeleteLocalFile menghapus file lokal jika file tersebut berada di folder ./public
func DeleteLocalFile(relPath string) {
if relPath == "" || strings.HasPrefix(relPath, "http://") || strings.HasPrefix(relPath, "https://") {
return
}
cleaned := filepath.Clean(relPath)
var localPath string
if strings.HasPrefix(cleaned, "/images/") {
localPath = "./public" + cleaned
} else if strings.HasPrefix(cleaned, "/public/") {
localPath = "." + cleaned
} else {
return
}
if _, err := os.Stat(localPath); err == nil {
_ = os.Remove(localPath)
}
}

95
internal/pkg/utils/jwt.go Normal file
View File

@@ -0,0 +1,95 @@
package utils
import (
"errors"
"time"
"cardverse/config"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"user_id"`
Email string `json:"email,omitempty"`
Role string `json:"role,omitempty"`
jwt.RegisteredClaims
}
func GenerateAccessToken(userID uint, email string, role string) (string, error) {
cfg := config.Cfg
expiration := time.Now().Add(time.Duration(cfg.JWTExpiresHours) * time.Hour)
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiration),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func GenerateRefreshToken(userID uint) (string, error) {
cfg := config.Cfg
expiration := time.Now().Add(time.Duration(cfg.JWTRefreshExpiresHours) * time.Hour)
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiration),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTRefreshSecret))
}
func GenerateToken(userID uint, email string, role string) (string, error) {
return GenerateAccessToken(userID, email, role)
}
func ValidateToken(tokenString string) (*Claims, error) {
cfg := config.Cfg
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("metode signing token tidak valid")
}
return []byte(cfg.JWTSecret), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("token tidak valid")
}
return claims, nil
}
func ValidateRefreshToken(tokenString string) (*Claims, error) {
cfg := config.Cfg
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("metode signing token tidak valid")
}
return []byte(cfg.JWTRefreshSecret), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("refresh token tidak valid")
}
return claims, nil
}

View File

@@ -0,0 +1,67 @@
package utils
import (
"testing"
"time"
"cardverse/config"
)
func setupTestConfig() {
config.Cfg = &config.Config{
JWTSecret: "test-secret-key",
JWTExpiresHours: 1,
}
}
func TestGenerateToken_AndValidateToken(t *testing.T) {
setupTestConfig()
token, err := GenerateToken(42, "user@example.com", "admin")
if err != nil {
t.Fatalf("GenerateToken expected no error, got: %v", err)
}
if token == "" {
t.Fatal("token should not be empty")
}
claims, err := ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken expected no error for valid token, got: %v", err)
}
if claims.UserID != 42 {
t.Errorf("UserID expected 42, got %d", claims.UserID)
}
if claims.Email != "user@example.com" {
t.Errorf("Email expected user@example.com, got %s", claims.Email)
}
if claims.Role != "admin" {
t.Errorf("Role expected admin, got %s", claims.Role)
}
}
func TestValidateToken_InvalidToken(t *testing.T) {
setupTestConfig()
_, err := ValidateToken("invalid.random.token")
if err == nil {
t.Fatal("ValidateToken should fail for invalid token")
}
}
func TestValidateToken_ExpiredToken(t *testing.T) {
config.Cfg = &config.Config{
JWTSecret: "test-secret-key",
JWTExpiresHours: 0,
}
token, _ := GenerateToken(1, "expired@example.com", "user")
time.Sleep(1100 * time.Millisecond)
_, err := ValidateToken(token)
if err == nil {
t.Fatal("ValidateToken should fail for expired token")
}
}

View File

@@ -0,0 +1,15 @@
package utils
import "golang.org/x/crypto/bcrypt"
// HashPassword meng-hash plain password menggunakan bcrypt
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword membandingkan plain password dengan hash yang tersimpan
func CheckPassword(hashedPassword, plainPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
return err == nil
}

View File

@@ -0,0 +1,34 @@
package utils
import "testing"
func TestHashPassword_AndCheckPassword(t *testing.T) {
plain := "rahasia123"
hashed, err := HashPassword(plain)
if err != nil {
t.Fatalf("HashPassword tidak boleh error, dapat: %v", err)
}
if hashed == plain {
t.Fatal("hasil hash tidak boleh sama dengan plain password")
}
if !CheckPassword(hashed, plain) {
t.Fatal("CheckPassword harus true untuk password yang benar")
}
if CheckPassword(hashed, "password_salah") {
t.Fatal("CheckPassword harus false untuk password yang salah")
}
}
func TestHashPassword_HasilBerbedaSetiapKaliDipanggil(t *testing.T) {
// bcrypt menyisipkan salt acak, jadi 2x hash dari password sama harus menghasilkan string berbeda
hash1, _ := HashPassword("sama-sama")
hash2, _ := HashPassword("sama-sama")
if hash1 == hash2 {
t.Fatal("dua hash dari password yang sama seharusnya berbeda karena salt")
}
}

View File

@@ -0,0 +1,31 @@
package utils
import (
"regexp"
"strings"
)
var nonAlphanumericRegexp = regexp.MustCompile(`[^a-z0-9]+`)
// UnderscoreString mengubah string judul/nama menjadi format_ular (misal: "Evolusi Mega" -> "evolusi_mega")
func UnderscoreString(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "&", "and")
s = nonAlphanumericRegexp.ReplaceAllString(s, "_")
s = strings.Trim(s, "_")
if s == "" {
s = "underscore_string"
}
return s
}
func Slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "&", "and")
s = nonAlphanumericRegexp.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
s = "slugify"
}
return s
}

77
internal/pkg/utils/url.go Normal file
View File

@@ -0,0 +1,77 @@
package utils
import (
"os"
"strings"
"cardverse/config"
)
// getBaseURL mengambil Base URL dari config (APP_BASE_URL) atau environment variable dengan fallback ke http://localhost:PORT
func getBaseURL() string {
if config.Cfg != nil && config.Cfg.AppBaseURL != "" {
return strings.TrimRight(config.Cfg.AppBaseURL, "/")
}
if envURL := os.Getenv("APP_BASE_URL"); envURL != "" {
return strings.TrimRight(envURL, "/")
}
port := os.Getenv("APP_PORT")
if port == "" {
port = "8080"
}
return "http://localhost:" + port
}
// FormatMediaURL mengubah path relatif gambar (seperti /images/...) menjadi URL lengkap menggunakan APP_BASE_URL
func FormatMediaURL(urlStr string) string {
if urlStr == "" {
return ""
}
// Jika sudah berupa URL lengkap (http:// atau https://), kembalikan langsung
if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") {
return urlStr
}
baseURL := getBaseURL()
if !strings.HasPrefix(urlStr, "/") {
urlStr = "/" + urlStr
}
return baseURL + urlStr
}
// FormatMediaURLPtr mengubah pointer path relatif gambar menjadi pointer URL lengkap
func FormatMediaURLPtr(urlPtr *string) *string {
if urlPtr == nil || *urlPtr == "" {
return urlPtr
}
formatted := FormatMediaURL(*urlPtr)
return &formatted
}
// FormatJSONBMediaURLs mengubah seluruh path relatif gambar (/images/... atau /public/...) di dalam data byte JSONB menjadi URL lengkap
func FormatJSONBMediaURLs(data []byte) []byte {
if len(data) == 0 {
return data
}
baseURL := getBaseURL()
s := string(data)
// Replace "/images/" dengan "APP_BASE_URL/images/"
targetImages := `"/images/`
replaceImages := `"` + baseURL + `/images/`
s = strings.ReplaceAll(s, targetImages, replaceImages)
// Replace "/public/" dengan "APP_BASE_URL/public/"
targetPublic := `"/public/`
replacePublic := `"` + baseURL + `/public/`
s = strings.ReplaceAll(s, targetPublic, replacePublic)
return []byte(s)
}

View File

@@ -0,0 +1,96 @@
package validator
import (
"errors"
"fmt"
"reflect"
"strings"
"github.com/gin-gonic/gin/binding"
validatorlib "github.com/go-playground/validator/v10"
)
// FieldError merepresentasikan 1 field DTO yang gagal validasi.
// Field mengikuti nama di tag `json` DTO (bukan nama struct Go), supaya konsisten
// dengan nama yang dikirim client di body request.
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// RegisterJSONTagNameFunc membuat validator Gin melaporkan error pakai nama tag `json`
// dari DTO (misal "email", "password"), bukan nama struct Go (misal "Email", "Password").
// WAJIB dipanggil sekali saat startup aplikasi (lihat internal/router/router.go),
// sebelum ada request yang divalidasi.
func RegisterJSONTagNameFunc() {
v, ok := binding.Validator.Engine().(*validatorlib.Validate)
if !ok {
return
}
v.RegisterTagNameFunc(func(field reflect.StructField) string {
name := strings.SplitN(field.Tag.Get("json"), ",", 2)[0]
if name == "-" || name == "" {
return field.Name
}
return name
})
}
func TranslateError(err error) []FieldError {
var validationErrs validatorlib.ValidationErrors
if !errors.As(err, &validationErrs) {
return []FieldError{{
Field: "body",
Message: "format request tidak valid, pastikan body berupa JSON yang benar",
}}
}
fieldErrors := make([]FieldError, 0, len(validationErrs))
for _, fe := range validationErrs {
fieldErrors = append(fieldErrors, FieldError{
Field: fe.Field(),
Message: buildMessage(fe),
})
}
return fieldErrors
}
// buildMessage menerjemahkan tag validasi (required, email, min, dst) jadi kalimat
// Bahasa Indonesia yang jelas. Tambahkan case baru di sini kalau ada tag validasi baru
// yang dipakai di DTO tapi belum ada terjemahannya.
func buildMessage(fe validatorlib.FieldError) string {
field := fe.Field()
switch fe.Tag() {
case "required":
return fmt.Sprintf("%s wajib diisi", field)
case "email":
return fmt.Sprintf("%s harus berupa alamat email yang valid", field)
case "min":
if fe.Kind() == reflect.String {
return fmt.Sprintf("%s minimal %s karakter", field, fe.Param())
}
return fmt.Sprintf("%s minimal bernilai %s", field, fe.Param())
case "max":
if fe.Kind() == reflect.String {
return fmt.Sprintf("%s maksimal %s karakter", field, fe.Param())
}
return fmt.Sprintf("%s maksimal bernilai %s", field, fe.Param())
case "gte":
return fmt.Sprintf("%s minimal bernilai %s", field, fe.Param())
case "lte":
return fmt.Sprintf("%s maksimal bernilai %s", field, fe.Param())
case "oneof":
return fmt.Sprintf("%s harus salah satu dari: %s", field, fe.Param())
case "len":
return fmt.Sprintf("%s harus tepat %s karakter", field, fe.Param())
case "numeric":
return fmt.Sprintf("%s harus berupa angka", field)
case "alphanum":
return fmt.Sprintf("%s hanya boleh berisi huruf dan angka", field)
default:
return fmt.Sprintf("%s tidak valid", field)
}
}