6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
170 lines
4.3 KiB
Go
170 lines
4.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type AuditEntry struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
Action string `json:"action"`
|
|
Resource string `json:"resource"`
|
|
IP string `json:"ip"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Details string `json:"details"`
|
|
}
|
|
|
|
type AuditListResponse struct {
|
|
Data []AuditEntry `json:"data"`
|
|
Total int `json:"total"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
// AuditHandler returns a handler for GET /api/v1/audit.
|
|
// Query params: page (default 1), limit (default 20, max 100),
|
|
// user (user_id filter), action, resource.
|
|
func AuditHandler(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
|
|
page, err := parsePositiveInt(q.Get("page"), 1)
|
|
if err != nil {
|
|
http.Error(w, "invalid page", http.StatusBadRequest)
|
|
return
|
|
}
|
|
limit, err := parsePositiveInt(q.Get("limit"), 20)
|
|
if err != nil {
|
|
http.Error(w, "invalid limit", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
userFilter := strings.TrimSpace(q.Get("user"))
|
|
actionFilter := strings.TrimSpace(q.Get("action"))
|
|
resourceFilter := strings.TrimSpace(q.Get("resource"))
|
|
|
|
var conditions []string
|
|
var args []interface{}
|
|
idx := 1
|
|
|
|
if userFilter != "" {
|
|
conditions = append(conditions, fmt.Sprintf("user_id = $%d", idx))
|
|
args = append(args, userFilter)
|
|
idx++
|
|
}
|
|
if actionFilter != "" {
|
|
conditions = append(conditions, fmt.Sprintf("action = $%d", idx))
|
|
args = append(args, actionFilter)
|
|
idx++
|
|
}
|
|
if resourceFilter != "" {
|
|
conditions = append(conditions, fmt.Sprintf("resource = $%d", idx))
|
|
args = append(args, resourceFilter)
|
|
idx++
|
|
}
|
|
|
|
where := ""
|
|
if len(conditions) > 0 {
|
|
where = "WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
|
|
var total int
|
|
if err := db.QueryRow(
|
|
fmt.Sprintf("SELECT COUNT(*) FROM audit_logs %s", where),
|
|
args...,
|
|
).Scan(&total); err != nil {
|
|
http.Error(w, fmt.Sprintf("db count error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
dataArgs := append(args, limit, (page-1)*limit)
|
|
rows, err := db.Query(
|
|
fmt.Sprintf(
|
|
`SELECT id, user_id, action, resource, ip, timestamp, details
|
|
FROM audit_logs %s
|
|
ORDER BY timestamp DESC
|
|
LIMIT $%d OFFSET $%d`,
|
|
where, idx, idx+1,
|
|
),
|
|
dataArgs...,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("db query error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
entries := []AuditEntry{}
|
|
for rows.Next() {
|
|
var e AuditEntry
|
|
if err := rows.Scan(&e.ID, &e.UserID, &e.Action, &e.Resource, &e.IP, &e.Timestamp, &e.Details); err != nil {
|
|
http.Error(w, fmt.Sprintf("db scan error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
http.Error(w, fmt.Sprintf("db rows error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(AuditListResponse{
|
|
Data: entries,
|
|
Total: total,
|
|
Page: page,
|
|
Limit: limit,
|
|
})
|
|
}
|
|
}
|
|
|
|
// LogAudit inserts one audit record. Non-fatal: caller may log the returned error.
|
|
func LogAudit(db *sql.DB, userID, action, resource, ip, details string) error {
|
|
id, err := auditUUID()
|
|
if err != nil {
|
|
return fmt.Errorf("generate id: %w", err)
|
|
}
|
|
_, err = db.Exec(
|
|
`INSERT INTO audit_logs (id, user_id, action, resource, ip, timestamp, details)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
id, userID, action, resource, ip, time.Now().UTC(), details,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func auditUUID() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
|
b[8] = (b[8] & 0x3f) | 0x80 // variant
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
|
}
|
|
|
|
func parsePositiveInt(s string, defaultVal int) (int, error) {
|
|
if s == "" {
|
|
return defaultVal, nil
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil || v < 1 {
|
|
return 0, fmt.Errorf("must be a positive integer")
|
|
}
|
|
return v, nil
|
|
}
|