Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
// Package api implements Relata's versioned REST interface under /api/v1.
|
||||
//
|
||||
// Handlers here never expose internal detail to clients: stack traces, driver
|
||||
// errors and file paths stay in the logs, while the response carries a stable
|
||||
// machine-readable code and a message safe to show a user.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/relata/internal/analysis"
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/ingest"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
"github.com/andydixon/relata/internal/search"
|
||||
)
|
||||
|
||||
// Stable error codes. Clients may branch on these; the messages may change.
|
||||
const (
|
||||
CodeInvalidRequest = "invalid_request"
|
||||
CodeValidationFailed = "validation_failed"
|
||||
CodeUnauthorised = "unauthorised"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeDuplicate = "duplicate_content"
|
||||
CodeConflict = "conflict"
|
||||
CodePayloadTooLarge = "payload_too_large"
|
||||
CodeUnsupportedMedia = "unsupported_media_type"
|
||||
CodeRateLimited = "rate_limited"
|
||||
CodeInsufficientScope = "insufficient_scope"
|
||||
CodeCSRFFailed = "csrf_check_failed"
|
||||
CodeDependencyDown = "dependency_unavailable"
|
||||
CodeDependencyTimeout = "dependency_timeout"
|
||||
CodeClientClosed = "client_closed_request"
|
||||
CodeModelInvalid = "model_output_invalid"
|
||||
CodeFeatureDisabled = "feature_disabled"
|
||||
CodeInternal = "internal_error"
|
||||
)
|
||||
|
||||
// ErrorResponse is the body returned for every failed API request.
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorBody carries the code, message and any field-level detail.
|
||||
type ErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Fields []FieldError `json:"fields,omitempty"`
|
||||
// RequestID lets a user quote a reference when reporting a problem, and
|
||||
// lets an operator find the corresponding log line.
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
// Detail carries safe supplementary information, such as the ID of an
|
||||
// existing duplicate item.
|
||||
Detail map[string]string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// FieldError describes one invalid input field.
|
||||
type FieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// APIError is an error with an HTTP status and a stable code.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
Fields []FieldError
|
||||
Detail map[string]string
|
||||
// Internal is logged but never sent to the client.
|
||||
Internal error
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Internal != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Internal)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *APIError) Unwrap() error { return e.Internal }
|
||||
|
||||
// Errorf builds an APIError.
|
||||
func Errorf(status int, code, format string, args ...any) *APIError {
|
||||
return &APIError{Status: status, Code: code, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// BadRequest builds a 400.
|
||||
func BadRequest(msg string, fields ...FieldError) *APIError {
|
||||
return &APIError{Status: http.StatusBadRequest, Code: CodeValidationFailed, Message: msg, Fields: fields}
|
||||
}
|
||||
|
||||
// WriteError sends an error response, mapping domain errors onto HTTP.
|
||||
//
|
||||
// Unmapped errors become a generic 500: the internal detail is logged with the
|
||||
// request ID but never returned, so a database error cannot leak schema or
|
||||
// connection information to a client.
|
||||
func WriteError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
reqID := observability.RequestID(r.Context())
|
||||
log := observability.LoggerFrom(r.Context())
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
apiErr = mapDomainError(err)
|
||||
}
|
||||
if apiErr.RequestIDSuppressed() {
|
||||
reqID = ""
|
||||
}
|
||||
|
||||
if apiErr.Status >= 500 {
|
||||
log.Error("request failed",
|
||||
"code", apiErr.Code, "path", r.URL.Path, "method", r.Method,
|
||||
"error", err.Error())
|
||||
} else {
|
||||
log.Info("request rejected",
|
||||
"code", apiErr.Code, "status", apiErr.Status, "path", r.URL.Path, "method", r.Method,
|
||||
"reason", apiErr.Message)
|
||||
}
|
||||
|
||||
body := ErrorResponse{Error: ErrorBody{
|
||||
Code: apiErr.Code,
|
||||
Message: apiErr.Message,
|
||||
Fields: apiErr.Fields,
|
||||
RequestID: reqID,
|
||||
Detail: apiErr.Detail,
|
||||
}}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(apiErr.Status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// RequestIDSuppressed reports whether the request ID should be withheld. It
|
||||
// never is at present, but the hook keeps the decision in one place.
|
||||
func (e *APIError) RequestIDSuppressed() bool { return false }
|
||||
|
||||
// mapDomainError converts a domain or service error into an API error.
|
||||
func mapDomainError(err error) *APIError {
|
||||
var ve *ingest.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
return &APIError{
|
||||
Status: http.StatusBadRequest, Code: CodeValidationFailed,
|
||||
Message: "The request could not be accepted.",
|
||||
Fields: []FieldError{{Field: ve.Field, Message: ve.Message}},
|
||||
}
|
||||
}
|
||||
|
||||
var de *ingest.DuplicateError
|
||||
if errors.As(err, &de) {
|
||||
return &APIError{
|
||||
Status: http.StatusConflict, Code: CodeDuplicate,
|
||||
Message: "An item with identical content already exists. " +
|
||||
"Send allow_duplicate=true to store it anyway.",
|
||||
Detail: map[string]string{
|
||||
"existing_item_id": de.ExistingID,
|
||||
"existing_title": de.ExistingTitle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
return &APIError{Status: http.StatusNotFound, Code: CodeNotFound,
|
||||
Message: "The requested item does not exist."}
|
||||
|
||||
case errors.Is(err, domain.ErrForbidden):
|
||||
return &APIError{Status: http.StatusForbidden, Code: CodeForbidden,
|
||||
Message: "You do not have permission to perform this action."}
|
||||
|
||||
// A cross-workspace access attempt is reported as "not found" rather than
|
||||
// "forbidden", so that a probe cannot confirm an ID exists elsewhere.
|
||||
case errors.Is(err, domain.ErrWorkspaceMiss):
|
||||
return &APIError{Status: http.StatusNotFound, Code: CodeNotFound,
|
||||
Message: "The requested item does not exist."}
|
||||
|
||||
case errors.Is(err, domain.ErrDuplicate):
|
||||
return &APIError{Status: http.StatusConflict, Code: CodeDuplicate,
|
||||
Message: "That content already exists."}
|
||||
|
||||
case errors.Is(err, domain.ErrConflict):
|
||||
return &APIError{Status: http.StatusConflict, Code: CodeConflict,
|
||||
Message: "The record changed while your request was being processed. Please try again."}
|
||||
|
||||
case errors.Is(err, domain.ErrTooLarge):
|
||||
return &APIError{Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
|
||||
Message: "The request body is larger than the configured limit."}
|
||||
|
||||
case errors.Is(err, domain.ErrUnsupported):
|
||||
return &APIError{Status: http.StatusUnsupportedMediaType, Code: CodeUnsupportedMedia,
|
||||
Message: "That content type is not supported."}
|
||||
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
return &APIError{Status: http.StatusBadRequest, Code: CodeInvalidRequest,
|
||||
Message: firstLine(err.Error())}
|
||||
|
||||
case errors.Is(err, domain.ErrRateLimited), errors.Is(err, auth.ErrTooManyAttempts):
|
||||
return &APIError{Status: http.StatusTooManyRequests, Code: CodeRateLimited,
|
||||
Message: "Too many attempts. Please wait and try again."}
|
||||
|
||||
case errors.Is(err, auth.ErrNoSession), errors.Is(err, auth.ErrInvalidCredentials):
|
||||
return &APIError{Status: http.StatusUnauthorized, Code: CodeUnauthorised,
|
||||
Message: "Authentication is required."}
|
||||
|
||||
case errors.Is(err, analysis.ErrInvalidOutput):
|
||||
return &APIError{Status: http.StatusBadGateway, Code: CodeModelInvalid,
|
||||
Message: "The language model returned a response Relata could not validate. Nothing was stored."}
|
||||
|
||||
case errors.Is(err, analysis.ErrDisabled), errors.Is(err, search.ErrAnswerDisabled):
|
||||
return &APIError{Status: http.StatusServiceUnavailable, Code: CodeFeatureDisabled,
|
||||
Message: "This feature requires a language model, which is not configured."}
|
||||
|
||||
case errors.Is(err, domain.ErrUnavailable):
|
||||
return &APIError{Status: http.StatusServiceUnavailable, Code: CodeDependencyDown,
|
||||
Message: "A required service is unavailable. Please try again shortly."}
|
||||
|
||||
// A provider that ran out of time is a dependency problem, not an
|
||||
// unexplained internal failure. Reporting it as one leaves a user with no
|
||||
// idea that the fix is a bigger timeout or a smaller model.
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return &APIError{Status: http.StatusGatewayTimeout, Code: CodeDependencyTimeout,
|
||||
Message: "A language model or storage call took longer than the configured timeout. " +
|
||||
"Try a smaller request, or raise RELATA_ANALYSIS_TIMEOUT and RELATA_HTTP_WRITE_TIMEOUT."}
|
||||
|
||||
case errors.Is(err, context.Canceled):
|
||||
return &APIError{Status: 499, Code: CodeClientClosed,
|
||||
Message: "The request was cancelled before it completed."}
|
||||
}
|
||||
|
||||
return &APIError{
|
||||
Status: http.StatusInternalServerError, Code: CodeInternal,
|
||||
Message: "An unexpected error occurred. The request reference below can be quoted when reporting it.",
|
||||
Internal: err,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteJSON sends a successful JSON response.
|
||||
//
|
||||
// The response is encoded into a buffer first, so that a marshalling failure is
|
||||
// reported as a 500 rather than truncating a body whose 200 status has already
|
||||
// been written.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "application/json; charset=utf-8")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
if v == nil {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(v); err != nil {
|
||||
slog.Default().Error("could not encode response body", "error", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"The response could not be encoded."}}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// DecodeJSON reads a JSON request body strictly.
|
||||
//
|
||||
// Unknown fields are rejected so a client sending a misspelled or unsupported
|
||||
// field is told, rather than having it silently ignored; trailing content is
|
||||
// rejected so two concatenated documents cannot be smuggled through; and the
|
||||
// body is size-limited before parsing.
|
||||
func DecodeJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, dst any) error {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" {
|
||||
base, _, _ := strings.Cut(ct, ";")
|
||||
if strings.TrimSpace(base) != "application/json" {
|
||||
return &APIError{
|
||||
Status: http.StatusUnsupportedMediaType, Code: CodeUnsupportedMedia,
|
||||
Message: "The request body must be application/json.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return decodeError(err, maxBytes)
|
||||
}
|
||||
if err := dec.Decode(&struct{}{}); err != io.EOF {
|
||||
return BadRequest("The request body must contain exactly one JSON object.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeError turns a JSON decoding failure into a specific, safe message.
|
||||
func decodeError(err error, maxBytes int64) error {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
var maxErr *http.MaxBytesError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxErr):
|
||||
return BadRequest(fmt.Sprintf("The request body is not valid JSON (at position %d).", syntaxErr.Offset))
|
||||
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return BadRequest("The request body ended unexpectedly and is not valid JSON.")
|
||||
|
||||
case errors.As(err, &typeErr):
|
||||
return BadRequest("A field has the wrong type.", FieldError{
|
||||
Field: typeErr.Field,
|
||||
Message: fmt.Sprintf("expected %s", typeErr.Type.String()),
|
||||
})
|
||||
|
||||
case errors.Is(err, io.EOF):
|
||||
return BadRequest("The request body is empty.")
|
||||
|
||||
case errors.As(err, &maxErr):
|
||||
return &APIError{
|
||||
Status: http.StatusRequestEntityTooLarge, Code: CodePayloadTooLarge,
|
||||
Message: fmt.Sprintf("The request body must be no larger than %d bytes.", maxBytes),
|
||||
}
|
||||
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
field := strings.Trim(strings.TrimPrefix(err.Error(), "json: unknown field "), `"`)
|
||||
return BadRequest("The request contains a field Relata does not accept.", FieldError{
|
||||
Field: field,
|
||||
Message: "unknown field",
|
||||
})
|
||||
}
|
||||
return BadRequest("The request body could not be read.")
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) > 300 {
|
||||
s = s[:300]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user