Initial commit - Lost and Found Revisi
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// internal/utils/encryption.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ✅ KRITERIA BASDAT: Keamanan Data - Enkripsi untuk Data Sensitif (5%)
|
||||
|
||||
var encryptionKey []byte
|
||||
|
||||
// InitEncryption inisialisasi encryption key dari environment
|
||||
func InitEncryption() error {
|
||||
key := os.Getenv("ENCRYPTION_KEY")
|
||||
if key == "" {
|
||||
// Generate random key untuk development (TIDAK untuk production!)
|
||||
key = "32-byte-long-encryption-key!!" // 32 bytes untuk AES-256
|
||||
}
|
||||
|
||||
if len(key) != 32 {
|
||||
return errors.New("encryption key must be exactly 32 bytes for AES-256")
|
||||
}
|
||||
|
||||
encryptionKey = []byte(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptString mengenkripsi string menggunakan AES-256-GCM
|
||||
// Digunakan untuk data sensitif: password, NRP, phone, contact info
|
||||
func EncryptString(plaintext string) (string, error) {
|
||||
if encryptionKey == nil {
|
||||
if err := InitEncryption(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Create AES cipher
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create GCM mode (Galois/Counter Mode) - authenticated encryption
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate nonce (number used once)
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encrypt and authenticate
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
|
||||
// Encode to base64 untuk storage di database
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// DecryptString mendekripsi string yang sudah dienkripsi
|
||||
func DecryptString(encrypted string) (string, error) {
|
||||
if encryptionKey == nil {
|
||||
if err := InitEncryption(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Decode from base64
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create AES cipher
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create GCM mode
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Extract nonce
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
|
||||
// Decrypt and verify
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// EncryptSensitiveFields helper untuk encrypt multiple fields
|
||||
func EncryptSensitiveFields(fields map[string]string) (map[string]string, error) {
|
||||
encrypted := make(map[string]string)
|
||||
|
||||
for key, value := range fields {
|
||||
if value == "" {
|
||||
encrypted[key] = ""
|
||||
continue
|
||||
}
|
||||
|
||||
encValue, err := EncryptString(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted[key] = encValue
|
||||
}
|
||||
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// DecryptSensitiveFields helper untuk decrypt multiple fields
|
||||
func DecryptSensitiveFields(fields map[string]string) (map[string]string, error) {
|
||||
decrypted := make(map[string]string)
|
||||
|
||||
for key, value := range fields {
|
||||
if value == "" {
|
||||
decrypted[key] = ""
|
||||
continue
|
||||
}
|
||||
|
||||
decValue, err := DecryptString(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decrypted[key] = decValue
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// MaskSensitiveData untuk logging (mask sebagian data)
|
||||
// Contoh: "081234567890" -> "0812****7890"
|
||||
func MaskSensitiveData(data string) string {
|
||||
if len(data) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
|
||||
prefix := data[:4]
|
||||
suffix := data[len(data)-4:]
|
||||
|
||||
return prefix + "****" + suffix
|
||||
}
|
||||
|
||||
// ValidateEncryption test apakah encryption bekerja
|
||||
func ValidateEncryption() error {
|
||||
testData := "test-sensitive-data-12345"
|
||||
|
||||
encrypted, err := EncryptString(testData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decrypted, err := DecryptString(encrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if decrypted != testData {
|
||||
return errors.New("encryption validation failed: data mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// internal/utils/error.go
|
||||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AppError represents a custom application error
|
||||
type AppError struct {
|
||||
Code string
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// NewAppError creates a new application error
|
||||
func NewAppError(code, message string, err error) *AppError {
|
||||
return &AppError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Common error codes
|
||||
const (
|
||||
ErrCodeValidation = "VALIDATION_ERROR"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeInternal = "INTERNAL_ERROR"
|
||||
ErrCodeDuplicate = "DUPLICATE_ERROR"
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
)
|
||||
|
||||
// Error constructors
|
||||
func ValidationError(message string) *AppError {
|
||||
return NewAppError(ErrCodeValidation, message, nil)
|
||||
}
|
||||
|
||||
func NotFoundError(message string) *AppError {
|
||||
return NewAppError(ErrCodeNotFound, message, nil)
|
||||
}
|
||||
|
||||
func UnauthorizedError(message string) *AppError {
|
||||
return NewAppError(ErrCodeUnauthorized, message, nil)
|
||||
}
|
||||
|
||||
func ForbiddenError(message string) *AppError {
|
||||
return NewAppError(ErrCodeForbidden, message, nil)
|
||||
}
|
||||
|
||||
func InternalError(message string, err error) *AppError {
|
||||
return NewAppError(ErrCodeInternal, message, err)
|
||||
}
|
||||
|
||||
func DuplicateError(message string) *AppError {
|
||||
return NewAppError(ErrCodeDuplicate, message, nil)
|
||||
}
|
||||
|
||||
func BadRequestError(message string) *AppError {
|
||||
return NewAppError(ErrCodeBadRequest, message, nil)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// internal/utils/excel_export.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// ExcelExporter handles Excel file generation
|
||||
type ExcelExporter struct {
|
||||
file *excelize.File
|
||||
sheetName string
|
||||
rowIndex int
|
||||
}
|
||||
|
||||
// NewExcelExporter creates a new Excel exporter
|
||||
func NewExcelExporter() *ExcelExporter {
|
||||
f := excelize.NewFile()
|
||||
return &ExcelExporter{
|
||||
file: f,
|
||||
sheetName: "Sheet1",
|
||||
rowIndex: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSheetName sets the active sheet name
|
||||
func (e *ExcelExporter) SetSheetName(name string) {
|
||||
e.file.SetSheetName("Sheet1", name)
|
||||
e.sheetName = name
|
||||
}
|
||||
|
||||
// AddRow adds a row of data
|
||||
func (e *ExcelExporter) AddRow(data []string) error {
|
||||
for colIndex, value := range data {
|
||||
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
|
||||
if err := e.file.SetCellValue(e.sheetName, cell, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e.rowIndex++
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddHeader adds a header row with bold style
|
||||
func (e *ExcelExporter) AddHeader(headers []string) error {
|
||||
style, err := e.file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{
|
||||
Bold: true,
|
||||
},
|
||||
Fill: excelize.Fill{
|
||||
Type: "pattern",
|
||||
Color: []string{"#D3D3D3"},
|
||||
Pattern: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for colIndex, header := range headers {
|
||||
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
|
||||
if err := e.file.SetCellValue(e.sheetName, cell, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.file.SetCellStyle(e.sheetName, cell, cell, style); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e.rowIndex++
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoSizeColumns auto-sizes columns
|
||||
func (e *ExcelExporter) AutoSizeColumns(columnCount int) {
|
||||
for i := 0; i < columnCount; i++ {
|
||||
col := getColumnName(i)
|
||||
e.file.SetColWidth(e.sheetName, col, col, 15)
|
||||
}
|
||||
}
|
||||
|
||||
// Output returns the Excel file as bytes
|
||||
func (e *ExcelExporter) Output() (*bytes.Buffer, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
if err := e.file.Write(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// getColumnName converts column index to Excel column name (A, B, C, ..., AA, AB, ...)
|
||||
func getColumnName(index int) string {
|
||||
name := ""
|
||||
for index >= 0 {
|
||||
name = string(rune('A'+(index%26))) + name
|
||||
index = index/26 - 1
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// internal/utils/hash.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPasswordHash compares a password with a hash
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// internal/utils/image_handler.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"regexp"
|
||||
|
||||
"github.com/nfnt/resize"
|
||||
)
|
||||
|
||||
// ImageHandler handles image upload and processing
|
||||
type ImageHandler struct {
|
||||
uploadPath string
|
||||
maxSize int64
|
||||
allowedTypes []string
|
||||
maxWidth uint
|
||||
maxHeight uint
|
||||
}
|
||||
|
||||
// NewImageHandler creates a new image handler
|
||||
func NewImageHandler(uploadPath string) *ImageHandler {
|
||||
return &ImageHandler{
|
||||
uploadPath: uploadPath,
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
allowedTypes: []string{"image/jpeg", "image/jpg", "image/png"},
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadImage uploads and processes an image
|
||||
// UploadImage uploads and processes an image
|
||||
func (h *ImageHandler) UploadImage(file *multipart.FileHeader, subfolder string) (string, error) {
|
||||
// Check file size
|
||||
if file.Size > h.maxSize {
|
||||
return "", errors.New("file size exceeds maximum allowed size")
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if !h.isAllowedType(file.Header.Get("Content-Type")) {
|
||||
return "", errors.New("file type not allowed")
|
||||
}
|
||||
|
||||
// Open uploaded file
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// ✅ GENERATE FILENAME DENGAN TIMESTAMP
|
||||
ext := filepath.Ext(file.Filename)
|
||||
timestamp := time.Now().Format("20060102_150405") // Format: YYYYMMDD_HHMMSS
|
||||
baseFilename := strings.TrimSuffix(file.Filename, ext)
|
||||
|
||||
// Remove special characters from filename
|
||||
baseFilename = sanitizeFilename(baseFilename)
|
||||
|
||||
filename := fmt.Sprintf("%s_%s%s", baseFilename, timestamp, ext)
|
||||
|
||||
// Create upload directory if not exists
|
||||
uploadDir := filepath.Join(h.uploadPath, subfolder)
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Full file path
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
// Decode image
|
||||
img, format, err := image.Decode(src)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid image file")
|
||||
}
|
||||
|
||||
// Resize if necessary
|
||||
if uint(img.Bounds().Dx()) > h.maxWidth || uint(img.Bounds().Dy()) > h.maxHeight {
|
||||
img = resize.Thumbnail(h.maxWidth, h.maxHeight, img, resize.Lanczos3)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// Encode and save image
|
||||
switch format {
|
||||
case "jpeg", "jpg":
|
||||
if err := jpeg.Encode(dst, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "png":
|
||||
if err := png.Encode(dst, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", errors.New("unsupported image format")
|
||||
}
|
||||
|
||||
// Return relative path
|
||||
return filepath.Join(subfolder, filename), nil
|
||||
}
|
||||
|
||||
// ✅ TAMBAHKAN HELPER FUNCTION INI
|
||||
func sanitizeFilename(filename string) string {
|
||||
// Remove special characters, keep only alphanumeric and hyphens
|
||||
reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
|
||||
sanitized := reg.ReplaceAllString(filename, "")
|
||||
|
||||
// Limit length
|
||||
if len(sanitized) > 50 {
|
||||
sanitized = sanitized[:50]
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// UploadImageSimple uploads image without processing
|
||||
func (h *ImageHandler) UploadImageSimple(file *multipart.FileHeader, subfolder string) (string, error) {
|
||||
// Check file size
|
||||
if file.Size > h.maxSize {
|
||||
return "", errors.New("file size exceeds maximum allowed size")
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if !h.isAllowedType(file.Header.Get("Content-Type")) {
|
||||
return "", errors.New("file type not allowed")
|
||||
}
|
||||
|
||||
// Open uploaded file
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Generate unique filename
|
||||
ext := filepath.Ext(file.Filename)
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext)
|
||||
|
||||
// Create upload directory if not exists
|
||||
uploadDir := filepath.Join(h.uploadPath, subfolder)
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Full file path
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
// Create destination file
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// Copy file
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Return relative path
|
||||
return filepath.Join(subfolder, filename), nil
|
||||
}
|
||||
|
||||
// DeleteImage deletes an image file
|
||||
func (h *ImageHandler) DeleteImage(relativePath string) error {
|
||||
if relativePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
filePath := filepath.Join(h.uploadPath, relativePath)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return nil // File doesn't exist, no error
|
||||
}
|
||||
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
// isAllowedType checks if file type is allowed
|
||||
func (h *ImageHandler) isAllowedType(contentType string) bool {
|
||||
for _, allowed := range h.allowedTypes {
|
||||
if strings.EqualFold(contentType, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// generateRandomString generates a random string
|
||||
func generateRandomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// internal/utils/matching.go
|
||||
package utils
|
||||
|
||||
|
||||
|
||||
// CalculateMatchScore calculates match score between two items
|
||||
func CalculateMatchScore(item1, item2 map[string]interface{}) float64 {
|
||||
nameScore := 0.0
|
||||
descScore := 0.0
|
||||
|
||||
// 1. Name Matching (Bobot 50%)
|
||||
if name1, ok1 := item1["name"].(string); ok1 {
|
||||
if name2, ok2 := item2["name"].(string); ok2 {
|
||||
nameScore = CalculateStringSimilarity(name1, name2) * 100
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Description/Secret Matching (Bobot 50%)
|
||||
// Cek apakah ada secret_details, jika tidak pakai description
|
||||
text1 := ""
|
||||
if val, ok := item1["secret_details"].(string); ok && val != "" {
|
||||
text1 = val
|
||||
} else if val, ok := item1["description"].(string); ok {
|
||||
text1 = val
|
||||
}
|
||||
|
||||
text2 := ""
|
||||
if val, ok := item2["secret_details"].(string); ok && val != "" {
|
||||
text2 = val
|
||||
} else if val, ok := item2["description"].(string); ok {
|
||||
text2 = val
|
||||
}
|
||||
|
||||
if text1 != "" && text2 != "" {
|
||||
descScore = CalculateStringSimilarity(text1, text2) * 100
|
||||
}
|
||||
|
||||
// Total Score = Rata-rata dari keduanya
|
||||
totalScore := (nameScore * 0.5) + (descScore * 0.5)
|
||||
|
||||
return totalScore
|
||||
}
|
||||
|
||||
// MatchItems matches items based on criteria
|
||||
func MatchItems(lostItem, foundItems []map[string]interface{}, threshold float64) []map[string]interface{} {
|
||||
var matches []map[string]interface{}
|
||||
|
||||
if len(lostItem) == 0 || len(foundItems) == 0 {
|
||||
return matches
|
||||
}
|
||||
|
||||
lost := lostItem[0]
|
||||
|
||||
for _, found := range foundItems {
|
||||
score := CalculateMatchScore(lost, found)
|
||||
if score >= threshold {
|
||||
match := make(map[string]interface{})
|
||||
match["item"] = found
|
||||
match["score"] = score
|
||||
match["level"] = getMatchLevel(score)
|
||||
matches = append(matches, match)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// getMatchLevel returns match level based on score
|
||||
func getMatchLevel(score float64) string {
|
||||
if score >= 70 {
|
||||
return "high"
|
||||
} else if score >= 50 {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// internal/utils/pdf_export.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
)
|
||||
|
||||
// PDFExporter handles PDF generation
|
||||
type PDFExporter struct {
|
||||
pdf *gofpdf.Fpdf
|
||||
}
|
||||
|
||||
// NewPDFExporter creates a new PDF exporter
|
||||
func NewPDFExporter() *PDFExporter {
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
pdf.AddPage()
|
||||
|
||||
// ✅ Coba gunakan font built-in yang pasti ada
|
||||
pdf.SetFont("Helvetica", "", 12) // Ganti dari "Arial" ke "Helvetica"
|
||||
|
||||
return &PDFExporter{
|
||||
pdf: pdf,
|
||||
}
|
||||
}
|
||||
|
||||
// AddTitle adds a title to the PDF
|
||||
func (e *PDFExporter) AddTitle(title string) {
|
||||
e.pdf.SetFont("Arial", "B", 16)
|
||||
e.pdf.Cell(0, 10, title)
|
||||
e.pdf.Ln(12)
|
||||
e.pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
|
||||
// AddSubtitle adds a subtitle to the PDF
|
||||
func (e *PDFExporter) AddSubtitle(subtitle string) {
|
||||
e.pdf.SetFont("Arial", "I", 11)
|
||||
e.pdf.Cell(0, 8, subtitle)
|
||||
e.pdf.Ln(10)
|
||||
e.pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
|
||||
// AddText adds regular text
|
||||
func (e *PDFExporter) AddText(text string) {
|
||||
e.pdf.SetFont("Arial", "", 10)
|
||||
e.pdf.MultiCell(0, 6, text, "", "", false)
|
||||
e.pdf.Ln(4)
|
||||
}
|
||||
|
||||
// AddNewLine adds a new line
|
||||
func (e *PDFExporter) AddNewLine() {
|
||||
e.pdf.Ln(6)
|
||||
}
|
||||
|
||||
// AddTable adds a table to the PDF
|
||||
func (e *PDFExporter) AddTable(headers []string, data [][]string) {
|
||||
// Calculate column widths
|
||||
pageWidth, _ := e.pdf.GetPageSize()
|
||||
margins := 20.0 // Left + Right margins
|
||||
tableWidth := pageWidth - margins
|
||||
colWidth := tableWidth / float64(len(headers))
|
||||
|
||||
// Add headers
|
||||
e.pdf.SetFont("Arial", "B", 10)
|
||||
e.pdf.SetFillColor(200, 200, 200)
|
||||
for _, header := range headers {
|
||||
e.pdf.CellFormat(colWidth, 8, header, "1", 0, "C", true, 0, "")
|
||||
}
|
||||
e.pdf.Ln(-1)
|
||||
|
||||
// Add data rows
|
||||
e.pdf.SetFont("Arial", "", 9)
|
||||
e.pdf.SetFillColor(255, 255, 255)
|
||||
|
||||
fill := false
|
||||
for _, row := range data {
|
||||
for _, cell := range row {
|
||||
if fill {
|
||||
e.pdf.SetFillColor(245, 245, 245)
|
||||
} else {
|
||||
e.pdf.SetFillColor(255, 255, 255)
|
||||
}
|
||||
e.pdf.CellFormat(colWidth, 7, cell, "1", 0, "L", true, 0, "")
|
||||
}
|
||||
e.pdf.Ln(-1)
|
||||
fill = !fill
|
||||
}
|
||||
}
|
||||
|
||||
// AddPageNumber adds page numbers
|
||||
func (e *PDFExporter) AddPageNumber() {
|
||||
e.pdf.AliasNbPages("")
|
||||
e.pdf.SetY(-15)
|
||||
e.pdf.SetFont("Arial", "I", 8)
|
||||
e.pdf.CellFormat(0, 10, fmt.Sprintf("Halaman %d/{nb}", e.pdf.PageNo()), "", 0, "C", false, 0, "")
|
||||
}
|
||||
|
||||
// Output returns the PDF as bytes
|
||||
func (e *PDFExporter) Output() *bytes.Buffer {
|
||||
var buf bytes.Buffer
|
||||
err := e.pdf.Output(&buf)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &buf
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// internal/utils/response.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response represents a standard API response
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PaginatedResponse represents a paginated API response
|
||||
type PaginatedResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
// Pagination represents pagination metadata
|
||||
type Pagination struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalRecords int64 `json:"total_records"`
|
||||
}
|
||||
|
||||
// SuccessResponse sends a success response
|
||||
func SuccessResponse(ctx *gin.Context, statusCode int, message string, data interface{}) {
|
||||
ctx.JSON(statusCode, Response{
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorResponse sends an error response
|
||||
func ErrorResponse(ctx *gin.Context, statusCode int, message string, error string) {
|
||||
ctx.JSON(statusCode, Response{
|
||||
Success: false,
|
||||
Message: message,
|
||||
Error: error,
|
||||
})
|
||||
}
|
||||
|
||||
// SendPaginatedResponse sends a paginated response (nama fungsi diubah untuk menghindari konflik)
|
||||
func SendPaginatedResponse(ctx *gin.Context, statusCode int, message string, data interface{}, total int64, page, limit int) {
|
||||
totalPages := int(total) / limit
|
||||
if int(total)%limit != 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
ctx.JSON(statusCode, PaginatedResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: data,
|
||||
Pagination: Pagination{
|
||||
CurrentPage: page,
|
||||
PerPage: limit,
|
||||
TotalPages: totalPages,
|
||||
TotalRecords: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// internal/utils/similarity.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// CalculateStringSimilarity calculates similarity between two strings using Levenshtein distance
|
||||
func CalculateStringSimilarity(s1, s2 string) float64 {
|
||||
// Normalize strings
|
||||
s1 = normalizeString(s1)
|
||||
s2 = normalizeString(s2)
|
||||
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := math.Max(float64(len(s1)), float64(len(s2)))
|
||||
|
||||
similarity := 1.0 - (float64(distance) / maxLen)
|
||||
return math.Max(0, similarity)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
len1 := len(s1)
|
||||
len2 := len(s2)
|
||||
|
||||
// Create a 2D slice for dynamic programming
|
||||
dp := make([][]int, len1+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, len2+1)
|
||||
}
|
||||
|
||||
// Initialize first row and column
|
||||
for i := 0; i <= len1; i++ {
|
||||
dp[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len2; j++ {
|
||||
dp[0][j] = j
|
||||
}
|
||||
|
||||
// Fill the dp table
|
||||
for i := 1; i <= len1; i++ {
|
||||
for j := 1; j <= len2; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
dp[i][j] = min3(
|
||||
dp[i-1][j]+1, // deletion
|
||||
dp[i][j-1]+1, // insertion
|
||||
dp[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return dp[len1][len2]
|
||||
}
|
||||
|
||||
// ExtractKeywords extracts keywords from a string
|
||||
func ExtractKeywords(text string) []string {
|
||||
// Normalize and split text
|
||||
text = normalizeString(text)
|
||||
words := strings.Fields(text)
|
||||
|
||||
// Filter stopwords and short words
|
||||
var keywords []string
|
||||
stopwords := getStopwords()
|
||||
|
||||
for _, word := range words {
|
||||
if len(word) > 2 && !contains(stopwords, word) {
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
// FindMatchedKeywords finds common keywords between two lists
|
||||
func FindMatchedKeywords(keywords1, keywords2 []string) []string {
|
||||
var matched []string
|
||||
|
||||
for _, k1 := range keywords1 {
|
||||
for _, k2 := range keywords2 {
|
||||
if strings.EqualFold(k1, k2) || CalculateStringSimilarity(k1, k2) > 0.8 {
|
||||
if !contains(matched, k1) {
|
||||
matched = append(matched, k1)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// normalizeString normalizes a string (lowercase, remove extra spaces)
|
||||
func normalizeString(s string) string {
|
||||
// Convert to lowercase
|
||||
s = strings.ToLower(s)
|
||||
|
||||
// Remove punctuation and extra spaces
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) {
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// getStopwords returns common Indonesian stopwords
|
||||
func getStopwords() []string {
|
||||
return []string{
|
||||
"dan", "atau", "dengan", "untuk", "dari", "ke", "di", "yang", "ini", "itu",
|
||||
"ada", "adalah", "akan", "telah", "sudah", "pada", "oleh", "sebagai", "dalam",
|
||||
"juga", "saya", "kamu", "dia", "kita", "mereka", "kami", "the", "a", "an",
|
||||
"of", "to", "in", "for", "on", "at", "by", "with", "from",
|
||||
}
|
||||
}
|
||||
|
||||
// contains checks if a slice contains a string
|
||||
func contains(slice []string, str string) bool {
|
||||
for _, s := range slice {
|
||||
if strings.EqualFold(s, str) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// min3 returns the minimum of three integers
|
||||
func min3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// internal/utils/validator.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var validate *validator.Validate
|
||||
|
||||
// InitValidator initializes the validator
|
||||
func InitValidator() {
|
||||
validate = validator.New()
|
||||
|
||||
// Register custom validators
|
||||
validate.RegisterValidation("phone", validatePhone)
|
||||
validate.RegisterValidation("nrp", validateNRP)
|
||||
}
|
||||
|
||||
// ValidateStruct validates a struct
|
||||
func ValidateStruct(s interface{}) error {
|
||||
if validate == nil {
|
||||
InitValidator()
|
||||
}
|
||||
return validate.Struct(s)
|
||||
}
|
||||
|
||||
// validatePhone validates Indonesian phone numbers
|
||||
func validatePhone(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if phone == "" {
|
||||
return true // Allow empty (use required tag separately)
|
||||
}
|
||||
|
||||
// Remove spaces and dashes
|
||||
phone = strings.ReplaceAll(phone, " ", "")
|
||||
phone = strings.ReplaceAll(phone, "-", "")
|
||||
|
||||
// Check if starts with 0 or +62 or 62
|
||||
pattern := `^(0|\+?62)[0-9]{8,12}$`
|
||||
matched, _ := regexp.MatchString(pattern, phone)
|
||||
return matched
|
||||
}
|
||||
|
||||
// validateNRP validates NRP (Nomor Registrasi Pokok)
|
||||
func validateNRP(fl validator.FieldLevel) bool {
|
||||
nrp := fl.Field().String()
|
||||
if nrp == "" {
|
||||
return true // Allow empty (use required tag separately)
|
||||
}
|
||||
|
||||
// NRP format: 10 digits
|
||||
pattern := `^[0-9]{10}$`
|
||||
matched, _ := regexp.MatchString(pattern, nrp)
|
||||
return matched
|
||||
}
|
||||
|
||||
// IsValidEmail checks if email is valid
|
||||
func IsValidEmail(email string) bool {
|
||||
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||
matched, _ := regexp.MatchString(pattern, email)
|
||||
return matched
|
||||
}
|
||||
|
||||
// IsValidURL checks if URL is valid
|
||||
func IsValidURL(url string) bool {
|
||||
pattern := `^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$`
|
||||
matched, _ := regexp.MatchString(pattern, url)
|
||||
return matched
|
||||
}
|
||||
|
||||
// SanitizeString removes potentially dangerous characters
|
||||
func SanitizeString(s string) string {
|
||||
// Remove control characters
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
Reference in New Issue
Block a user