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