181 lines
4.5 KiB
Go
181 lines
4.5 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
CodeAUTHZ = "AUTHZ"
|
|
CodeVALIDATION = "VALIDATION"
|
|
CodeVERSION_CONFLICT = "VERSION_CONFLICT"
|
|
CodeIDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
|
|
CodeINTERNAL = "INTERNAL"
|
|
HeaderRequestID = "X-Request-ID"
|
|
HeaderErrorCode = "X-Error-Code"
|
|
)
|
|
|
|
type Envelope struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id"`
|
|
Error string `json:"error"`
|
|
Details map[string]any `json:"details,omitempty"`
|
|
}
|
|
|
|
func CodeFromStatus(status int) string {
|
|
switch status {
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return CodeAUTHZ
|
|
case http.StatusConflict:
|
|
return CodeVERSION_CONFLICT
|
|
case http.StatusBadRequest, http.StatusUnprocessableEntity:
|
|
return CodeVALIDATION
|
|
default:
|
|
if status >= 500 {
|
|
return CodeINTERNAL
|
|
}
|
|
if status == http.StatusNotFound {
|
|
return CodeAUTHZ
|
|
}
|
|
return CodeVALIDATION
|
|
}
|
|
}
|
|
|
|
func SafeMessage(code string) string {
|
|
switch code {
|
|
case CodeAUTHZ:
|
|
return "无权访问"
|
|
case CodeVALIDATION:
|
|
return "请求参数无效"
|
|
case CodeVERSION_CONFLICT:
|
|
return "资源版本冲突"
|
|
case CodeIDEMPOTENCY_CONFLICT:
|
|
return "重复提交"
|
|
default:
|
|
return "请求无法完成"
|
|
}
|
|
}
|
|
|
|
func LooksInternal(s string) bool {
|
|
low := strings.ToLower(s)
|
|
needles := []string{"sql", "pq:", "pgx", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt", "commit", "rollback", "transaction", "constraint", "violates"}
|
|
for _, n := range needles {
|
|
if strings.Contains(low, n) {
|
|
return true
|
|
}
|
|
}
|
|
if strings.Contains(s, "/home/") || strings.Contains(s, "/var/") || strings.Contains(s, "\\") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func RequestID(r *http.Request) string {
|
|
if r == nil {
|
|
return newRequestID()
|
|
}
|
|
if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
|
|
return v
|
|
}
|
|
return newRequestID()
|
|
}
|
|
|
|
func newRequestID() string {
|
|
var b [8]byte
|
|
_, _ = rand.Read(b[:])
|
|
return "req_" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
func PublicLegacy(code, legacy string) string {
|
|
legacy = strings.TrimSpace(legacy)
|
|
if legacy == "" || LooksInternal(legacy) {
|
|
return code
|
|
}
|
|
if strings.HasPrefix(legacy, "<") || strings.Contains(legacy, "<script") {
|
|
return code
|
|
}
|
|
return legacy
|
|
}
|
|
|
|
func Write(w http.ResponseWriter, status int, code, legacy, requestID string, details map[string]any) {
|
|
write(w, nil, status, code, legacy, requestID, "", details)
|
|
}
|
|
|
|
// WriteHTTP 写入错误信封,并记录 method/path。safeMessage 为空时使用按码的固定文案。
|
|
func WriteHTTP(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) {
|
|
if requestID == "" {
|
|
requestID = requestIDFrom(w, r)
|
|
}
|
|
write(w, r, status, code, legacy, requestID, safeMessage, details)
|
|
}
|
|
|
|
func requestIDFrom(w http.ResponseWriter, r *http.Request) string {
|
|
if r != nil {
|
|
if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
if w != nil {
|
|
if v := strings.TrimSpace(w.Header().Get(HeaderRequestID)); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return RequestID(r)
|
|
}
|
|
|
|
func write(w http.ResponseWriter, r *http.Request, status int, code, legacy, requestID, safeMessage string, details map[string]any) {
|
|
if code == "" {
|
|
code = CodeFromStatus(status)
|
|
}
|
|
if requestID == "" {
|
|
requestID = requestIDFrom(w, r)
|
|
}
|
|
if safeMessage == "" {
|
|
safeMessage = SafeMessage(code)
|
|
}
|
|
env := Envelope{
|
|
Code: code,
|
|
Message: safeMessage,
|
|
RequestID: requestID,
|
|
Error: PublicLegacy(code, legacy),
|
|
Details: details,
|
|
}
|
|
if w.Header().Get("Content-Type") == "" {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
}
|
|
w.Header().Set(HeaderRequestID, requestID)
|
|
w.Header().Set(HeaderErrorCode, code)
|
|
logError(r, status, code, requestID)
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(env)
|
|
}
|
|
|
|
func logError(r *http.Request, status int, code, requestID string) {
|
|
level := slog.LevelWarn
|
|
if status >= 500 {
|
|
level = slog.LevelError
|
|
}
|
|
args := []any{"code", code, "status", status, "request_id", requestID}
|
|
if r != nil {
|
|
args = append(args, "method", r.Method, "path", stripQuery(r.URL.Path))
|
|
}
|
|
slog.Log(context.Background(), level, "http_error", args...)
|
|
}
|
|
|
|
func stripQuery(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return "/"
|
|
}
|
|
if i := strings.IndexAny(path, "?#"); i >= 0 {
|
|
path = path[:i]
|
|
}
|
|
return path
|
|
}
|