Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user