package httpapi import ( "bytes" "encoding/json" "errors" "io" "log" "net/http" "regexp" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/i18n" ) const maxJSONBodyBytes = 2 << 20 // 2 MiB var errJSONBodyTooLarge = errors.New("request body too large") var errJSONTrailingContent = errors.New("request body must contain a single JSON object") // secretLikeRE matches common secret material that must never appear in logs. var secretLikeRE = regexp.MustCompile(`(?i)(password|passwd|secret|api[_-]?key|token|authorization|bearer|sk_live|sk_test|whsec_)[^\s]{0,64}`) func redactForLog(msg string) string { if msg == "" { return msg } return secretLikeRE.ReplaceAllStringFunc(msg, func(m string) string { parts := strings.SplitN(m, "=", 2) if len(parts) == 2 { return parts[0] + "=[REDACTED]" } if i := strings.IndexByte(m, ':'); i > 0 && i < 24 { return m[:i+1] + "[REDACTED]" } return "[REDACTED]" }) } func JSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func Error(w http.ResponseWriter, status int, msg string) { JSON(w, status, map[string]string{"error": PublicMessage(w, msg)}) } // FieldError writes the usual public error string plus an additive field map: // // { "error": "...", "code": "...?", "fields": { "": "..." } } // // `error` stays the localized human message. Optional `code` is a stable // machine token (never translated). `fields` lets dashboards highlight inputs // under any Accept-Language without English substring matching. // Clients that only read `error` keep working (no BREAKING change). func FieldError(w http.ResponseWriter, status int, msg string, code string, fields map[string]string) { localized := PublicMessage(w, msg) out := map[string]any{"error": localized} if code != "" { out["code"] = code } if len(fields) > 0 { lf := make(map[string]string, len(fields)) for k, v := range fields { if k == "" { continue } text := v if text == "" { text = msg } lf[k] = PublicMessage(w, text) } if len(lf) > 0 { out["fields"] = lf } } JSON(w, status, out) } // CodedError writes the legacy public-API error envelope: // // { "error": { "code": "...", "message": "..." } } // // Used for /api/v1 API-key auth failures so clients migrating from Descrybe // see the same shape as err(code, message) in the Next.js app. // Code is never translated; message respects Accept-Language via Locale middleware. func CodedError(w http.ResponseWriter, status int, code, message string) { JSON(w, status, map[string]any{ "error": map[string]string{"code": code, "message": PublicMessage(w, message)}, }) } // PublicMessage localizes a client-facing string for the request locale. // Stable machine codes (password_not_set, maintenance, …) stay unchanged. func PublicMessage(w http.ResponseWriter, msg string) string { return i18n.T(localeOf(w), msg) } // LogAndError logs the real error server-side (secrets redacted) and returns a safe public message. func LogAndError(w http.ResponseWriter, status int, publicMsg string, err error) { if err != nil { log.Printf("httpapi: %s: %s", publicMsg, redactForLog(err.Error())) } Error(w, status, publicMsg) } // ClientOrLog writes a known client message, or logs and returns publicFallback. func ClientOrLog(w http.ResponseWriter, status int, publicFallback string, err error, clientMsg func(error) (string, bool)) { if msg, ok := clientMsg(err); ok { Error(w, status, msg) return } LogAndError(w, status, publicFallback, err) } func DecodeJSON(r *http.Request, dst any) error { return decodeJSON(r, dst, true) } // DecodeJSONAllowUnknown decodes JSON without DisallowUnknownFields. // Used for legacy public process payloads that may include extra item keys. func DecodeJSONAllowUnknown(r *http.Request, dst any) error { return decodeJSON(r, dst, false) } func decodeJSON(r *http.Request, dst any, disallowUnknown bool) error { defer r.Body.Close() data, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1)) if err != nil { return err } if len(data) > maxJSONBodyBytes { return errJSONBodyTooLarge } dec := json.NewDecoder(bytes.NewReader(data)) if disallowUnknown { dec.DisallowUnknownFields() } if err := dec.Decode(dst); err != nil { return err } if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return errJSONTrailingContent } return nil } func DecodeJSONOptional(r *http.Request, dst any) error { err := DecodeJSON(r, dst) if errors.Is(err, io.EOF) { return nil } return err }