Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
158 lines
4.5 KiB
Go
158 lines
4.5 KiB
Go
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": { "<field>": "..." } }
|
|
//
|
|
// `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
|
|
}
|