penambahan All case Satu sehat
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GenerateRSAKeyPair menghasilkan private dan public key RSA dalam format string PEM.
|
||||
// Umumnya Satu Sehat mewajibkan RSA dengan panjang minimal 2048 bit.
|
||||
func GenerateRSAKeyPair(bits int) (privateKeyPEM string, publicKeyPEM string, err error) {
|
||||
// 1. Generate RSA Private Key
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("gagal mengenerate private key: %w", err)
|
||||
}
|
||||
|
||||
// 2. Encode Private Key ke format PEM (PKCS#1)
|
||||
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
privBlock := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privBytes,
|
||||
}
|
||||
privateKeyPEM = string(pem.EncodeToMemory(privBlock))
|
||||
|
||||
// 3. Extract dan Encode Public Key ke format PEM (SPKI)
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("gagal marshal public key: %w", err)
|
||||
}
|
||||
|
||||
pubBlock := &pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pubBytes,
|
||||
}
|
||||
// Output standard PEM dari Go (pem.EncodeToMemory) sudah identik dengan openssl_pkey_get_details di PHP.
|
||||
publicKeyPEM = string(pem.EncodeToMemory(pubBlock))
|
||||
|
||||
return privateKeyPEM, publicKeyPEM, nil
|
||||
}
|
||||
|
||||
// Public Key resmi Kemenkes (Production / Staging) untuk mengenkripsi kunci AES.
|
||||
const KemkesPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxLwvebfOrPLIODIxAwFp
|
||||
4Qhksdtn7bEby5OhkQNLTdClGAbTe2tOO5Tiib9pcdruKxTodo481iGXTHR5033I
|
||||
A5X55PegFeoY95NH5Noj6UUhyTFfRuwnhtGJgv9buTeBa4pLgHakfebqzKXr0Lce
|
||||
/Ff1MnmQAdJTlvpOdVWJggsb26fD3cXyxQsbgtQYntmek2qvex/gPM9Nqa5qYrXx
|
||||
8KuGuqHIFQa5t7UUH8WcxlLVRHWOtEQ3+Y6TQr8sIpSVszfhpjh9+Cag1EgaMzk+
|
||||
HhAxMtXZgpyHffGHmPJ9eXbBO008tUzrE88fcuJ5pMF0LATO6ayXTKgZVU0WO/4e
|
||||
iQIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
const (
|
||||
beginEncryptedMsg = "-----BEGIN ENCRYPTED MESSAGE-----\r\n"
|
||||
endEncryptedMsg = "-----END ENCRYPTED MESSAGE-----"
|
||||
)
|
||||
|
||||
// EncryptSatuSehatPayload mengenkripsi JSON payload sesuai standar SatuSehat (AES-256-GCM & RSA-OAEP).
|
||||
func EncryptSatuSehatPayload(message []byte) (string, error) {
|
||||
block, _ := pem.Decode([]byte(KemkesPublicKeyPEM))
|
||||
if block == nil {
|
||||
return "", errors.New("gagal mem-parsing Kemenkes Public Key")
|
||||
}
|
||||
pubKeyAny, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
kemkesPubKey := pubKeyAny.(*rsa.PublicKey)
|
||||
|
||||
// 1. Generate 32 bytes AES Symmetric Key
|
||||
aesKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, aesKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. Encrypt AES Key menggunakan Kemenkes Public Key (RSA-OAEP)
|
||||
wrappedAesKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, kemkesPubKey, aesKey, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 3. Encrypt payload menggunakan AES-256-GCM
|
||||
blockCipher, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aesgcm, err := cipher.NewGCM(blockCipher)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, 12) // 12 bytes IV / Nonce
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Seal menggabungkan Ciphertext + Authentication Tag (16 bytes)
|
||||
ciphertextWithTag := aesgcm.Seal(nil, nonce, message, nil)
|
||||
|
||||
// 4. Concat: WrappedKey(256) + IV(12) + CipherText + Tag(16)
|
||||
encryptedData := append(nonce, ciphertextWithTag...)
|
||||
payload := append(wrappedAesKey, encryptedData...)
|
||||
|
||||
// 5. Base64 Encode & Chunk Split (76 characters)
|
||||
b64 := base64.StdEncoding.EncodeToString(payload)
|
||||
var chunked strings.Builder
|
||||
for i := 0; i < len(b64); i += 76 {
|
||||
end := i + 76
|
||||
if end > len(b64) {
|
||||
end = len(b64)
|
||||
}
|
||||
chunked.WriteString(b64[i:end])
|
||||
chunked.WriteString("\r\n")
|
||||
}
|
||||
|
||||
return beginEncryptedMsg + chunked.String() + endEncryptedMsg, nil
|
||||
}
|
||||
|
||||
// DecryptSatuSehatPayload mendekripsi response Kemenkes yang telah dienkripsi.
|
||||
func DecryptSatuSehatPayload(encryptedStr string, privPEM string) ([]byte, error) {
|
||||
startIdx := strings.Index(encryptedStr, "-----BEGIN ENCRYPTED MESSAGE-----")
|
||||
endIdx := strings.Index(encryptedStr, "-----END ENCRYPTED MESSAGE-----")
|
||||
if startIdx == -1 || endIdx == -1 {
|
||||
return nil, errors.New("respons tidak memiliki tag ENCRYPTED MESSAGE")
|
||||
}
|
||||
|
||||
b64Data := encryptedStr[startIdx+len("-----BEGIN ENCRYPTED MESSAGE-----") : endIdx]
|
||||
b64Data = strings.ReplaceAll(strings.ReplaceAll(b64Data, "\r", ""), "\n", "")
|
||||
|
||||
binaryData, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64Data))
|
||||
if err != nil || len(binaryData) < 256+12 {
|
||||
return nil, errors.New("gagal me-decode base64 payload / panjang payload tidak valid")
|
||||
}
|
||||
|
||||
wrappedKey := binaryData[:256]
|
||||
iv := binaryData[256 : 256+12]
|
||||
ciphertextWithTag := binaryData[256+12:]
|
||||
|
||||
block, _ := pem.Decode([]byte(privPEM))
|
||||
privKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
|
||||
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privKey, wrappedKey, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gagal me-unwrap kunci AES: %w", err)
|
||||
}
|
||||
|
||||
blockCipher, _ := aes.NewCipher(aesKey)
|
||||
aesgcm, _ := cipher.NewGCM(blockCipher)
|
||||
|
||||
return aesgcm.Open(nil, iv, ciphertextWithTag, nil)
|
||||
}
|
||||
+39
-15
@@ -13,17 +13,19 @@ import (
|
||||
|
||||
// HTTPErrorResponse represents standardized HTTP error response
|
||||
type HTTPErrorResponse struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Retryable bool `json:"retryable"`
|
||||
StackTrace interface{} `json:"stack_trace"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
} `json:"error"`
|
||||
Meta struct {
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Category string `json:"category"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
@@ -50,18 +52,30 @@ func HandleHTTPError(c *gin.Context, err error) {
|
||||
|
||||
// Create error response
|
||||
response := &HTTPErrorResponse{}
|
||||
response.Status = "error"
|
||||
response.Error.Code = appErr.Code()
|
||||
response.Error.Message = appErr.GetLocalizedMessage(getLanguageFromContext(c))
|
||||
response.Error.Details = appErr.Metadata()
|
||||
if response.Error.Details == nil {
|
||||
response.Error.Details = make(map[string]interface{})
|
||||
}
|
||||
response.Error.Message = appErr.GetLocalizedMessage(getLanguageFromContext(c))
|
||||
response.Error.RequestID = c.GetString("request_id")
|
||||
response.Error.Timestamp = appErr.Metadata()["timestamp"].(string)
|
||||
response.Error.Retryable = IsRetryable(appErr.Code())
|
||||
response.Meta.HTTPStatus = appErr.HTTPStatus()
|
||||
response.Error.StackTrace = nil
|
||||
if ts, ok := appErr.Metadata()["timestamp"].(string); ok {
|
||||
response.Error.Timestamp = ts
|
||||
}
|
||||
response.Meta.Category = appErr.Category()
|
||||
response.Meta.HTTPStatus = appErr.HTTPStatus()
|
||||
|
||||
// Log error
|
||||
LogHTTPError(c, appErr)
|
||||
|
||||
// Cegah print ganda jika controller sudah print
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
// Send response
|
||||
c.JSON(appErr.HTTPStatus(), response)
|
||||
}
|
||||
@@ -120,13 +134,16 @@ func ValidationErrorHandler(c *gin.Context, err error) {
|
||||
if appErr.Category() == CategoryValidation {
|
||||
if details, ok := appErr.Metadata()["validation_errors"]; ok {
|
||||
response := gin.H{
|
||||
"status": "error",
|
||||
"error": gin.H{
|
||||
"code": appErr.Code(),
|
||||
"message": appErr.GetLocalizedMessage(getLanguageFromContext(c)),
|
||||
"fields": details,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -158,13 +175,20 @@ func WriteErrorResponse(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
response := &HTTPErrorResponse{}
|
||||
response.Status = "error"
|
||||
response.Error.Code = appErr.Code()
|
||||
response.Error.Message = appErr.GetLocalizedMessage("en")
|
||||
response.Error.Details = appErr.Metadata()
|
||||
response.Error.Timestamp = appErr.Metadata()["timestamp"].(string)
|
||||
if response.Error.Details == nil {
|
||||
response.Error.Details = make(map[string]interface{})
|
||||
}
|
||||
response.Error.Message = appErr.GetLocalizedMessage("en")
|
||||
response.Error.Retryable = IsRetryable(appErr.Code())
|
||||
response.Meta.HTTPStatus = appErr.HTTPStatus()
|
||||
response.Error.StackTrace = nil
|
||||
if ts, ok := appErr.Metadata()["timestamp"].(string); ok {
|
||||
response.Error.Timestamp = ts
|
||||
}
|
||||
response.Meta.Category = appErr.Category()
|
||||
response.Meta.HTTPStatus = appErr.HTTPStatus()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(appErr.HTTPStatus())
|
||||
|
||||
@@ -247,7 +247,9 @@ func handleErrors(c *gin.Context, err error, cfg *ErrorMiddlewareConfig) {
|
||||
response := createErrorResponse(c, appErr, cfg)
|
||||
|
||||
// Send response
|
||||
c.JSON(appErr.HTTPStatus(), response)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(appErr.HTTPStatus(), response)
|
||||
}
|
||||
}
|
||||
|
||||
// createErrorResponse creates error response
|
||||
@@ -255,18 +257,30 @@ func createErrorResponse(c *gin.Context, err Error, cfg *ErrorMiddlewareConfig)
|
||||
lang := c.GetString("language")
|
||||
requestID := c.GetString("request_id")
|
||||
|
||||
details := err.Metadata()
|
||||
if details == nil {
|
||||
details = make(map[string]interface{})
|
||||
}
|
||||
|
||||
timestamp := time.Now().UTC().Format(time.RFC3339)
|
||||
if ts, ok := details["timestamp"].(string); ok {
|
||||
timestamp = ts
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"status": "error",
|
||||
"error": gin.H{
|
||||
"code": err.Code(),
|
||||
"message": err.GetLocalizedMessage(lang),
|
||||
"details": err.Metadata(),
|
||||
"request_id": requestID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"retryable": IsRetryable(err.Code()),
|
||||
"code": err.Code(),
|
||||
"details": details,
|
||||
"message": err.GetLocalizedMessage(lang),
|
||||
"request_id": requestID,
|
||||
"retryable": IsRetryable(err.Code()),
|
||||
"stack_trace": nil,
|
||||
"timestamp": timestamp,
|
||||
},
|
||||
"meta": gin.H{
|
||||
"http_status": err.HTTPStatus(),
|
||||
"category": err.Category(),
|
||||
"http_status": err.HTTPStatus(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -302,13 +316,16 @@ func ValidationHandler(c *gin.Context, err error) {
|
||||
if appErr.Category() == CategoryValidation {
|
||||
if details, ok := appErr.Metadata()["validation_errors"]; ok {
|
||||
response := gin.H{
|
||||
"status": "error",
|
||||
"error": gin.H{
|
||||
"code": appErr.Code(),
|
||||
"message": appErr.GetLocalizedMessage(getLanguageFromContext(c)),
|
||||
"fields": details,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadRequest, response)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseSatuSehatError mengekstrak dan menerjemahkan balasan OperationOutcome
|
||||
// dari Kemenkes (SatuSehat) menjadi standard AppError aplikasi.
|
||||
func ParseSatuSehatError(result map[string]interface{}) Error {
|
||||
errMsg := "Gagal memproses data ke SatuSehat"
|
||||
var errMessages []string
|
||||
|
||||
// Ekstrak pesan dari array "issue"
|
||||
if issues, ok := result["issue"].([]interface{}); ok && len(issues) > 0 {
|
||||
for _, issueItem := range issues {
|
||||
if issue, ok := issueItem.(map[string]interface{}); ok {
|
||||
if diagnostics, ok := issue["diagnostics"].(string); ok {
|
||||
diagLower := strings.ToLower(diagnostics)
|
||||
|
||||
// Translasi pesan error spesifik SatuSehat
|
||||
if strings.Contains(diagLower, "reference target(s) not found") {
|
||||
errMessages = append(errMessages, "Data referensi (seperti ID) tidak ditemukan di SatuSehat")
|
||||
} else if strings.Contains(diagLower, "nik") {
|
||||
errMessages = append(errMessages, "NIK tidak valid atau tidak terdaftar di SatuSehat")
|
||||
} else {
|
||||
errMessages = append(errMessages, diagnostics) // Tampilkan pesan aslinya jika belum ada di kamus translasi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gabungkan semua error (jika > 1) dengan pemisah koma atau titik koma
|
||||
if len(errMessages) > 0 {
|
||||
errMsg = "Validasi SatuSehat: " + strings.Join(errMessages, "; ")
|
||||
}
|
||||
|
||||
// Gunakan NewValidationError agar status HTTP menjadi 400 (Bad Request)
|
||||
return NewValidationError().
|
||||
Message(errMsg).
|
||||
Metadata("operation_outcome", result).
|
||||
Build()
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"service/pkg/errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -13,6 +17,26 @@ type Response struct {
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// getErrorCodeAndCategory memetakan HTTP Status Code ke Error Code terpusat
|
||||
func getErrorCodeAndCategory(statusCode int) (string, string) {
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return errors.ErrCodeInvalidInput, errors.CategoryValidation
|
||||
case http.StatusUnauthorized:
|
||||
return errors.ErrCodeUnauthorized, errors.CategoryUnauthorized
|
||||
case http.StatusForbidden:
|
||||
return errors.ErrCodeForbidden, errors.CategoryForbidden
|
||||
case http.StatusNotFound:
|
||||
return errors.ErrCodeNotFound, errors.CategoryNotFound
|
||||
case http.StatusConflict:
|
||||
return errors.ErrCodeConflict, errors.CategoryConflict
|
||||
case http.StatusTooManyRequests:
|
||||
return errors.ErrCodeRateLimitExceeded, errors.CategoryRateLimit
|
||||
default:
|
||||
return errors.ErrCodeInternalError, errors.CategoryInternal
|
||||
}
|
||||
}
|
||||
|
||||
// Success sends a success response
|
||||
func Success(c *gin.Context, statusCode int, message string, data interface{}) {
|
||||
c.JSON(statusCode, Response{
|
||||
@@ -24,16 +48,35 @@ func Success(c *gin.Context, statusCode int, message string, data interface{}) {
|
||||
|
||||
// Error sends an error response
|
||||
func Error(c *gin.Context, statusCode int, message string, err interface{}) {
|
||||
// Handle jika tipe yang dikirim adalah native error Go agar tidak menjadi objek kosong '{}' di JSON
|
||||
if e, ok := err.(error); ok {
|
||||
err = e.Error()
|
||||
var e error
|
||||
if err != nil {
|
||||
if errVal, ok := err.(error); ok {
|
||||
e = errVal
|
||||
} else {
|
||||
e = fmt.Errorf("%v", err)
|
||||
}
|
||||
} else {
|
||||
e = fmt.Errorf("%s", message)
|
||||
}
|
||||
|
||||
c.JSON(statusCode, Response{
|
||||
Status: "error",
|
||||
Message: message,
|
||||
Error: err,
|
||||
})
|
||||
// Jika error sudah berupa AppError dari pkg/errors, cetak langsung formatnya
|
||||
if errors.IsAppError(e) {
|
||||
errors.HandleHTTPError(c, e.(errors.Error))
|
||||
return
|
||||
}
|
||||
|
||||
// Jika berupa error standard/native biasa, konversi menjadi format terpusat AppError
|
||||
code, category := getErrorCodeAndCategory(statusCode)
|
||||
wrappedErr := errors.NewBuilder().
|
||||
Code(code).
|
||||
Category(category).
|
||||
HTTPStatus(statusCode).
|
||||
Message(message).
|
||||
Cause(e).
|
||||
Metadata("reason", e.Error()).
|
||||
Build()
|
||||
|
||||
errors.HandleHTTPError(c, wrappedErr)
|
||||
}
|
||||
|
||||
// ErrorWithLog mengirimkan respons error HTTP sekaligus menyisipkan error asli
|
||||
@@ -41,8 +84,31 @@ func Error(c *gin.Context, statusCode int, message string, err interface{}) {
|
||||
func ErrorWithLog(c *gin.Context, err error, statusCode int, message string, details interface{}) {
|
||||
if err != nil {
|
||||
c.Error(err) // Meneruskan error asli ke Gin Context untuk dicatat logger
|
||||
} else {
|
||||
err = fmt.Errorf("%s", message)
|
||||
}
|
||||
Error(c, statusCode, message, details) // Panggil fungsi Error() standar
|
||||
|
||||
// Sama seperti fungsi Error(), kita teruskan ke format sentral
|
||||
if errors.IsAppError(err) {
|
||||
errors.HandleHTTPError(c, err.(errors.Error))
|
||||
return
|
||||
}
|
||||
|
||||
code, category := getErrorCodeAndCategory(statusCode)
|
||||
builder := errors.NewBuilder().
|
||||
Code(code).
|
||||
Category(category).
|
||||
HTTPStatus(statusCode).
|
||||
Message(message).
|
||||
Cause(err).
|
||||
Metadata("reason", err.Error())
|
||||
|
||||
// Sisipkan details tambahan ke dalam metadata error builder
|
||||
if details != nil {
|
||||
builder.Metadata("additional_details", details)
|
||||
}
|
||||
|
||||
errors.HandleHTTPError(c, builder.Build())
|
||||
}
|
||||
|
||||
// Meta contains pagination metadata
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package custom
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CustomTime membungkus time.Time untuk mendukung format custom dan RFC3339
|
||||
type CustomTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (c *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
s := strings.Trim(string(b), "\"")
|
||||
if s == "null" || s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Coba parse dengan format "2006-01-02 15:04:05" dan set timezone
|
||||
t, err := time.Parse("2006-01-02 15:04:05", s)
|
||||
if err == nil {
|
||||
loc, _ := time.LoadLocation("Asia/Jakarta")
|
||||
c.Time = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback ke format RFC3339 bawaan
|
||||
t, err = time.Parse(time.RFC3339, s)
|
||||
c.Time = t
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user