修复bug及添加日志打印
This commit is contained in:
480
server/internal/httpapi/http_logging.go
Normal file
480
server/internal/httpapi/http_logging.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"calllinesystem/server/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLoggedBody = 64 << 10
|
||||
redactedLogText = "[REDACTED]"
|
||||
)
|
||||
|
||||
var (
|
||||
phoneInLogTextPattern = regexp.MustCompile(`(?:\+[0-9][0-9 ()-]{6,20}[0-9]|[0-9]{10,15})`)
|
||||
importantLogRoutes = map[string]struct{}{
|
||||
"POST /api/staff/auth/login": {},
|
||||
"POST /api/staff/auth/logout": {},
|
||||
"GET /api/staff/auth/me": {},
|
||||
"POST /api/admin/auth/login": {},
|
||||
"POST /api/admin/auth/logout": {},
|
||||
"GET /api/admin/auth/me": {},
|
||||
"GET /api/staff/projects": {},
|
||||
"GET /api/staff/projects/{id}/queue": {},
|
||||
"POST /api/staff/projects/{id}/tickets": {},
|
||||
"POST /api/staff/projects/{id}/call-next": {},
|
||||
"GET /api/public/status/{token}": {},
|
||||
"GET /api/public/projects": {},
|
||||
"POST /api/public/projects/{id}/tickets": {},
|
||||
"POST /api/public/status/search": {},
|
||||
"POST /api/internal/status/search": {},
|
||||
"GET /api/display/{token}/snapshot": {},
|
||||
"GET /api/events": {},
|
||||
"GET /api/admin/overview": {},
|
||||
"GET /api/admin/users": {},
|
||||
"POST /api/admin/users": {},
|
||||
"PUT /api/admin/users/{id}": {},
|
||||
"POST /api/admin/projects": {},
|
||||
"PUT /api/admin/projects/{id}": {},
|
||||
"PUT /api/admin/projects/{id}/settings": {},
|
||||
"GET /api/admin/history/tickets": {},
|
||||
"GET /api/admin/history/tickets/{id}": {},
|
||||
"GET /api/admin/history/batches": {},
|
||||
"GET /api/admin/history/batches/{id}": {},
|
||||
"GET /api/admin/history/summary": {},
|
||||
"GET /api/admin/history/export.csv": {},
|
||||
}
|
||||
)
|
||||
|
||||
type logBodyCapture struct {
|
||||
body bytes.Buffer
|
||||
total int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (capture *logBodyCapture) Write(value []byte) (int, error) {
|
||||
capture.total += len(value)
|
||||
remaining := maxLoggedBody - capture.body.Len()
|
||||
if remaining <= 0 {
|
||||
if len(value) > 0 {
|
||||
capture.truncated = true
|
||||
}
|
||||
return len(value), nil
|
||||
}
|
||||
toCopy := min(remaining, len(value))
|
||||
if toCopy > 0 {
|
||||
_, _ = capture.body.Write(value[:toCopy])
|
||||
}
|
||||
if toCopy < len(value) {
|
||||
capture.truncated = true
|
||||
}
|
||||
return len(value), nil
|
||||
}
|
||||
|
||||
type logCaptureReadCloser struct {
|
||||
io.ReadCloser
|
||||
capture *logBodyCapture
|
||||
}
|
||||
|
||||
func (reader *logCaptureReadCloser) Read(value []byte) (int, error) {
|
||||
n, err := reader.ReadCloser.Read(value)
|
||||
if n > 0 {
|
||||
_, _ = reader.capture.Write(value[:n])
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func shouldCaptureLogBody(r *http.Request) bool {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
return false
|
||||
}
|
||||
return r.URL.Path != "/api/events" && r.URL.Path != "/api/admin/history/export.csv"
|
||||
}
|
||||
|
||||
func isImportantLogRoute(r *http.Request) bool {
|
||||
_, ok := importantLogRoutes[logRoutePattern(r)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func logRoutePattern(r *http.Request) string {
|
||||
if route := strings.TrimSpace(r.Pattern); route != "" {
|
||||
return route
|
||||
}
|
||||
return r.Method + " " + r.URL.Path
|
||||
}
|
||||
|
||||
func logPath(r *http.Request) string {
|
||||
pattern := strings.TrimSpace(r.Pattern)
|
||||
if pattern != "" {
|
||||
if _, path, found := strings.Cut(pattern, " "); found {
|
||||
return path
|
||||
}
|
||||
return pattern
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/public/status/"):
|
||||
return "/api/public/status/{token}"
|
||||
case strings.HasPrefix(r.URL.Path, "/api/display/") && strings.HasSuffix(r.URL.Path, "/snapshot"):
|
||||
return "/api/display/{token}/snapshot"
|
||||
default:
|
||||
return r.URL.Path
|
||||
}
|
||||
}
|
||||
|
||||
func requestLogDetails(r *http.Request, capture *logBodyCapture) map[string]any {
|
||||
details := make(map[string]any)
|
||||
pathParams := make(map[string]any)
|
||||
if id := strings.TrimSpace(r.PathValue("id")); id != "" {
|
||||
if validateUUID(id) == nil {
|
||||
pathParams["id"] = id
|
||||
} else {
|
||||
pathParams["id"] = "[INVALID]"
|
||||
}
|
||||
}
|
||||
if token := strings.TrimSpace(r.PathValue("token")); token != "" {
|
||||
pathParams["token"] = redactedLogText
|
||||
}
|
||||
if len(pathParams) > 0 {
|
||||
details["path_params"] = pathParams
|
||||
}
|
||||
|
||||
if values := r.URL.Query(); len(values) > 0 {
|
||||
query := make(map[string]any, len(values))
|
||||
omitted := 0
|
||||
for field, items := range values {
|
||||
if !allowedLogQueryField(logRoutePattern(r), field) {
|
||||
omitted += len(items)
|
||||
continue
|
||||
}
|
||||
sanitized := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
sanitized = append(sanitized, sanitizeLogQueryValue(field, item))
|
||||
}
|
||||
query[field] = sanitized
|
||||
}
|
||||
if omitted > 0 {
|
||||
query["_omitted_parameter_count"] = omitted
|
||||
}
|
||||
details["query"] = query
|
||||
}
|
||||
if strings.TrimSpace(r.Header.Get("Idempotency-Key")) != "" {
|
||||
details["idempotency_key_present"] = true
|
||||
}
|
||||
appendCapturedLogBody(details, capture, r.Header.Get("Content-Type"), func(value any) (any, bool) {
|
||||
return sanitizeRequestLogBody(r, capture.body.Bytes(), value)
|
||||
})
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func responseLogDetails(r *http.Request, recorder *statusRecorder) map[string]any {
|
||||
if recorder.body == nil {
|
||||
switch r.URL.Path {
|
||||
case "/api/events":
|
||||
return map[string]any{"body_omitted": "event_stream"}
|
||||
case "/api/admin/history/export.csv":
|
||||
return map[string]any{"body_omitted": "file_download"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
details := make(map[string]any)
|
||||
appendCapturedLogBody(details, recorder.body, recorder.Header().Get("Content-Type"), func(value any) (any, bool) {
|
||||
return sanitizeLogValue("", value), true
|
||||
})
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func appendCapturedLogBody(
|
||||
target map[string]any,
|
||||
capture *logBodyCapture,
|
||||
contentType string,
|
||||
sanitize func(any) (any, bool),
|
||||
) {
|
||||
if capture == nil || capture.total == 0 {
|
||||
return
|
||||
}
|
||||
target["body_bytes"] = capture.total
|
||||
if contentType != "" {
|
||||
target["content_type"] = contentType
|
||||
}
|
||||
if capture.truncated {
|
||||
target["body_truncated"] = true
|
||||
return
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := json.Unmarshal(capture.body.Bytes(), &value); err != nil {
|
||||
target["body_omitted"] = "invalid_or_non_json"
|
||||
return
|
||||
}
|
||||
sanitized, ok := sanitize(value)
|
||||
if !ok {
|
||||
target["body_omitted"] = "invalid_or_unsupported_request_json"
|
||||
return
|
||||
}
|
||||
target["body"] = sanitized
|
||||
}
|
||||
|
||||
func sanitizeRequestLogBody(r *http.Request, raw []byte, value any) (any, bool) {
|
||||
schema := requestLogSchema(logRoutePattern(r))
|
||||
if schema == nil {
|
||||
return nil, false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(schema); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, false
|
||||
}
|
||||
return sanitizeLogValue("", value), true
|
||||
}
|
||||
|
||||
func requestLogSchema(route string) any {
|
||||
switch route {
|
||||
case "POST /api/staff/auth/login", "POST /api/admin/auth/login":
|
||||
return &loginRequest{}
|
||||
case "POST /api/staff/projects/{id}/tickets", "POST /api/public/projects/{id}/tickets":
|
||||
return &createTicketRequest{}
|
||||
case "POST /api/staff/projects/{id}/call-next":
|
||||
return &callNextRequest{}
|
||||
case "POST /api/public/status/search", "POST /api/internal/status/search":
|
||||
return &publicPhoneQueryRequest{}
|
||||
case "POST /api/admin/users", "PUT /api/admin/users/{id}":
|
||||
return &adminUserRequest{}
|
||||
case "POST /api/admin/projects", "PUT /api/admin/projects/{id}":
|
||||
return &adminProjectRequest{}
|
||||
case "PUT /api/admin/projects/{id}/settings":
|
||||
return &updateProjectSettingsRequest{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func allowedLogQueryField(route, field string) bool {
|
||||
switch route {
|
||||
case "GET /api/events":
|
||||
return field == "project_id"
|
||||
case "GET /api/admin/history/tickets":
|
||||
return isLogQueryField(field, "from", "to", "project_id", "status", "query", "page", "page_size")
|
||||
case "GET /api/admin/history/tickets/{id}":
|
||||
return field == "reveal"
|
||||
case "GET /api/admin/history/batches":
|
||||
return isLogQueryField(field, "from", "to", "project_id", "status", "query", "page", "page_size")
|
||||
case "GET /api/admin/history/summary":
|
||||
return isLogQueryField(field, "from", "to", "project_id", "status", "query")
|
||||
case "GET /api/admin/history/export.csv":
|
||||
return isLogQueryField(field, "from", "to", "project_id", "status", "query", "include_personal", "confirm")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isLogQueryField(field string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if field == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeLogQueryValue(field, value string) any {
|
||||
switch field {
|
||||
case "project_id":
|
||||
if validateUUID(value) != nil {
|
||||
return "[INVALID]"
|
||||
}
|
||||
return value
|
||||
case "from", "to":
|
||||
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||
return "[INVALID]"
|
||||
}
|
||||
return value
|
||||
case "status":
|
||||
status := strings.ToUpper(strings.TrimSpace(value))
|
||||
if !historyTicketStatuses[status] && !historyBatchStatuses[status] {
|
||||
return "[INVALID]"
|
||||
}
|
||||
return status
|
||||
case "page", "page_size":
|
||||
number, err := strconv.Atoi(value)
|
||||
if err != nil || number < 1 {
|
||||
return "[INVALID]"
|
||||
}
|
||||
return number
|
||||
case "include_personal", "confirm", "reveal":
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true", "1":
|
||||
return true
|
||||
case "false", "0":
|
||||
return false
|
||||
default:
|
||||
return "[INVALID]"
|
||||
}
|
||||
case "query":
|
||||
if normalized, err := security.NormalizePhone(value); err == nil {
|
||||
return maskPhoneForLog(normalized)
|
||||
}
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return "[PRESENT]"
|
||||
default:
|
||||
return "[OMITTED]"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeLogValue(field string, value any) any {
|
||||
normalizedField := normalizeLogField(field)
|
||||
switch {
|
||||
case isPhoneLogField(normalizedField):
|
||||
if strings.Contains(normalizedField, "hmac") ||
|
||||
strings.Contains(normalizedField, "cipher") ||
|
||||
strings.Contains(normalizedField, "nonce") {
|
||||
return redactedLogText
|
||||
}
|
||||
phone, ok := value.(string)
|
||||
if !ok {
|
||||
return redactedLogText
|
||||
}
|
||||
return maskPhoneForLog(phone)
|
||||
case normalizedField == "last_name" ||
|
||||
normalizedField == "surname" ||
|
||||
normalizedField == "honorific" ||
|
||||
normalizedField == "public_url" ||
|
||||
normalizedField == "status_path":
|
||||
return redactedLogText
|
||||
case isUserIdentifierLogField(normalizedField):
|
||||
identifier, ok := value.(string)
|
||||
if !ok {
|
||||
return redactedLogText
|
||||
}
|
||||
return maskIdentifierForLog(identifier)
|
||||
case normalizedField == "query" ||
|
||||
normalizedField == "search" ||
|
||||
normalizedField == "q":
|
||||
query, ok := value.(string)
|
||||
if ok {
|
||||
if normalized, err := security.NormalizePhone(query); err == nil {
|
||||
return maskPhoneForLog(normalized)
|
||||
}
|
||||
}
|
||||
case normalizedField == "visitor_notice":
|
||||
return "[OMITTED]"
|
||||
case isCredentialLogField(normalizedField):
|
||||
return redactedLogText
|
||||
}
|
||||
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
sanitized := make(map[string]any, len(typed))
|
||||
for childField, childValue := range typed {
|
||||
sanitized[childField] = sanitizeLogValue(childField, childValue)
|
||||
}
|
||||
return sanitized
|
||||
case []any:
|
||||
sanitized := make([]any, 0, len(typed))
|
||||
for _, childValue := range typed {
|
||||
sanitized = append(sanitized, sanitizeLogValue(field, childValue))
|
||||
}
|
||||
return sanitized
|
||||
case string:
|
||||
return sanitizeLogString(field, typed)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeLogString(field string, value string) string {
|
||||
if strings.Contains(value, "/visitor/") ||
|
||||
strings.Contains(value, "/api/public/status/") ||
|
||||
strings.Contains(value, "/api/display/") {
|
||||
return redactedLogText
|
||||
}
|
||||
return phoneInLogTextPattern.ReplaceAllStringFunc(value, maskPhoneForLog)
|
||||
}
|
||||
|
||||
func normalizeLogField(field string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(field), "-", "_"))
|
||||
}
|
||||
|
||||
func isPhoneLogField(field string) bool {
|
||||
return strings.Contains(field, "phone") ||
|
||||
strings.Contains(field, "mobile") ||
|
||||
strings.Contains(field, "contact") ||
|
||||
field == "tel" ||
|
||||
strings.HasSuffix(field, "_tel")
|
||||
}
|
||||
|
||||
func isCredentialLogField(field string) bool {
|
||||
return strings.Contains(field, "password") ||
|
||||
strings.Contains(field, "passwd") ||
|
||||
strings.Contains(field, "token") ||
|
||||
strings.Contains(field, "cookie") ||
|
||||
strings.Contains(field, "authorization") ||
|
||||
strings.Contains(field, "secret") ||
|
||||
strings.Contains(field, "ciphertext") ||
|
||||
strings.HasSuffix(field, "_nonce") ||
|
||||
strings.Contains(field, "hmac") ||
|
||||
strings.HasSuffix(field, "_hash") ||
|
||||
strings.Contains(field, "digest") ||
|
||||
field == "key" ||
|
||||
strings.HasSuffix(field, "_key") ||
|
||||
field == "pin" ||
|
||||
strings.Contains(field, "otp") ||
|
||||
strings.Contains(field, "captcha") ||
|
||||
strings.Contains(field, "verification_code")
|
||||
}
|
||||
|
||||
func isUserIdentifierLogField(field string) bool {
|
||||
switch field {
|
||||
case "username", "display_name", "created_by", "created_by_name", "requested_by", "requested_by_name":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(field, "_username")
|
||||
}
|
||||
}
|
||||
|
||||
func maskPhoneForLog(value string) string {
|
||||
digits := make([]byte, 0, len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] >= '0' && value[i] <= '9' {
|
||||
digits = append(digits, value[i])
|
||||
}
|
||||
}
|
||||
if len(digits) < 4 {
|
||||
return redactedLogText
|
||||
}
|
||||
return "****" + string(digits[len(digits)-4:])
|
||||
}
|
||||
|
||||
func maskIdentifierForLog(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
switch len(runes) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return "***"
|
||||
case 2:
|
||||
return string(runes[0]) + "***"
|
||||
default:
|
||||
return string(runes[0]) + "***" + string(runes[len(runes)-1])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user