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,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)
}