Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Route registration is explicit so that the authentication treatment of every
|
||||
// endpoint is readable in one place. These tests read that place and assert it,
|
||||
// so a route added without auth — or a page accidentally made public — fails
|
||||
// here rather than in production.
|
||||
|
||||
// webRoutePattern captures a browser route and the options it was registered with.
|
||||
var webRoutePattern = regexp.MustCompile(
|
||||
`mux\.Handle\("(GET|POST|PATCH|DELETE|PUT) ([^"]+)",\s*webChain\("[^"]*",\s*wb\.\w+,\s*([^)]+)\)\)`)
|
||||
|
||||
// publicRoutes are the only browser routes an unauthenticated visitor may reach.
|
||||
//
|
||||
// Adding to this list is a deliberate act: every entry is a page that must read
|
||||
// nothing from the database.
|
||||
var publicRoutes = map[string]bool{
|
||||
// Go's exact-match pattern for the root. Dual-purpose: the dashboard when
|
||||
// signed in, the landing page when not.
|
||||
"/{$}": true,
|
||||
"/login": true,
|
||||
"/auth/oauth/start": true,
|
||||
"/auth/oauth/callback": true,
|
||||
"/how-it-works": true,
|
||||
"/api": true,
|
||||
}
|
||||
|
||||
func TestOnlyIntendedBrowserRoutesArePublic(t *testing.T) {
|
||||
src := readFile(t, routerPath)
|
||||
|
||||
matches := webRoutePattern.FindAllStringSubmatch(src, -1)
|
||||
if len(matches) < 20 {
|
||||
t.Fatalf("only %d browser routes matched; the pattern has probably drifted from the router",
|
||||
len(matches))
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
method, path, opts := m[1], m[2], strings.TrimSpace(m[3])
|
||||
|
||||
authenticated := strings.Contains(opts, "read") ||
|
||||
strings.Contains(opts, "write") ||
|
||||
strings.Contains(opts, "authenticated: true")
|
||||
optional := strings.Contains(opts, "optionalAuth")
|
||||
public := opts == "pub" || optional
|
||||
|
||||
if public && !publicRoutes[path] {
|
||||
t.Errorf("%s %s is registered as public (%s) but is not in the intended public list; "+
|
||||
"if that is deliberate, add it there and make sure the page reads no stored data",
|
||||
method, path, opts)
|
||||
}
|
||||
if !public && !authenticated {
|
||||
t.Errorf("%s %s is registered with %q, which neither authenticates nor is public",
|
||||
method, path, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every route under /api/v1 must name the scope it needs. A route that reached
|
||||
// the mux without one would authenticate but authorise nothing.
|
||||
func TestEveryAPIRouteNamesAScope(t *testing.T) {
|
||||
src := readFile(t, routerPath)
|
||||
|
||||
registration := regexp.MustCompile(`mux\.Handle\("(GET|POST|PATCH|DELETE|PUT) (/api/v1[^"]*)",\s*(\w+)\(`)
|
||||
matches := registration.FindAllStringSubmatch(src, -1)
|
||||
if len(matches) < 15 {
|
||||
t.Fatalf("only %d API routes matched; the pattern has probably drifted", len(matches))
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
method, path, chain := m[1], m[2], m[3]
|
||||
switch chain {
|
||||
case "apiChain":
|
||||
// apiChain takes a scope as its third argument; check this call has one.
|
||||
call := regexp.MustCompile(
|
||||
`mux\.Handle\("` + regexp.QuoteMeta(method+" "+path) + `",\s*apiChain\([^)]*domain\.Scope\w+\)\)`)
|
||||
if !call.MatchString(src) {
|
||||
t.Errorf("%s %s uses apiChain without a domain.Scope argument", method, path)
|
||||
}
|
||||
case "tokenChain":
|
||||
// Token management is session-only by design, not scoped.
|
||||
default:
|
||||
t.Errorf("%s %s is registered with %q, which is neither apiChain nor tokenChain — "+
|
||||
"its authentication and scope treatment is not readable in one place",
|
||||
method, path, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password endpoint must be conditional on local login being enabled, so a
|
||||
// disabled break-glass path is absent rather than present and refusing.
|
||||
func TestPasswordLoginIsRegisteredConditionally(t *testing.T) {
|
||||
src := readFile(t, routerPath)
|
||||
|
||||
idx := strings.Index(src, `mux.Handle("POST /login"`)
|
||||
if idx < 0 {
|
||||
t.Fatal("the password login route is not registered at all")
|
||||
}
|
||||
// Look back a little way for the guard.
|
||||
window := src[max(0, idx-500):idx]
|
||||
if !strings.Contains(window, "LocalLoginEnabled") {
|
||||
t.Fatal("POST /login is registered unconditionally; it must be guarded by " +
|
||||
"cfg.Auth.LocalLoginEnabled so a disabled local login has no handler to reach")
|
||||
}
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
)
|
||||
|
||||
// HealthChecker reports whether Relata and its dependencies are working.
|
||||
type HealthChecker struct {
|
||||
Mongo func(context.Context) error
|
||||
Vectors domain.VectorStore
|
||||
Embedder domain.HealthChecker
|
||||
Analyser domain.HealthChecker
|
||||
Version string
|
||||
Started time.Time
|
||||
}
|
||||
|
||||
// checkResult is one dependency's status.
|
||||
type checkResult struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Required marks dependencies without which Relata cannot serve requests.
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
// Live reports that the process is running.
|
||||
//
|
||||
// It deliberately checks nothing else: a liveness probe that fails when a
|
||||
// database is briefly unavailable causes an orchestrator to restart a process
|
||||
// that was working perfectly well.
|
||||
func (h *HealthChecker) Live(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"version": h.Version,
|
||||
"uptime_sec": int(time.Since(h.Started).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// Ready reports whether Relata can serve requests.
|
||||
//
|
||||
// MongoDB and Qdrant are required: without them nothing can be stored or
|
||||
// retrieved. The AI providers are reported but not required, because Relata's
|
||||
// deterministic pipeline, search and browsing all work without a model — saying
|
||||
// otherwise would take the whole service down when Ollama restarts.
|
||||
func (h *HealthChecker) Ready(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
checks := map[string]checkResult{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
set := func(name string, res checkResult) {
|
||||
mu.Lock()
|
||||
checks[name] = res
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// unavailable records a failed dependency. The driver's own error text is
|
||||
// logged but never returned: readiness probes are unauthenticated, and
|
||||
// MongoDB's topology errors carry the host, port and sometimes the
|
||||
// credentials from the connection string. The caller gets a stable
|
||||
// description; the operator gets the specifics in the log.
|
||||
unavailable := func(name, detail string, required bool, err error) {
|
||||
slog.WarnContext(ctx, "readiness check failed",
|
||||
"dependency", name, "error", err, "request_id", observability.RequestID(ctx))
|
||||
set(name, checkResult{Status: "unavailable", Detail: detail, Required: required})
|
||||
}
|
||||
|
||||
if h.Mongo != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := h.Mongo(ctx); err != nil {
|
||||
unavailable("mongodb",
|
||||
"the database did not respond to a health check; see the server log for the cause",
|
||||
true, err)
|
||||
return
|
||||
}
|
||||
set("mongodb", checkResult{Status: "ok", Required: true})
|
||||
}()
|
||||
}
|
||||
|
||||
if h.Vectors != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := h.Vectors.Health(ctx); err != nil {
|
||||
unavailable("qdrant",
|
||||
"the vector store did not respond to a health check; see the server log for the cause",
|
||||
true, err)
|
||||
return
|
||||
}
|
||||
set("qdrant", checkResult{Status: "ok", Required: true})
|
||||
}()
|
||||
}
|
||||
|
||||
if h.Embedder != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
st := h.Embedder.Health(ctx)
|
||||
if !st.Available {
|
||||
unavailable("embedding_provider",
|
||||
"the embedding model is not reachable; new items cannot be embedded",
|
||||
false, errors.New(st.Detail))
|
||||
return
|
||||
}
|
||||
set("embedding_provider", checkResult{Status: "ok"})
|
||||
}()
|
||||
}
|
||||
|
||||
if h.Analyser != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
st := h.Analyser.Health(ctx)
|
||||
if !st.Available {
|
||||
unavailable("analysis_provider",
|
||||
"the analysis model is not reachable; extraction falls back to deterministic rules only",
|
||||
false, errors.New(st.Detail))
|
||||
return
|
||||
}
|
||||
set("analysis_provider", checkResult{Status: "ok"})
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
status := http.StatusOK
|
||||
overall := "ready"
|
||||
for _, c := range checks {
|
||||
if c.Required && c.Status != "ok" {
|
||||
status = http.StatusServiceUnavailable
|
||||
overall = "not ready"
|
||||
}
|
||||
}
|
||||
|
||||
WriteJSON(w, status, map[string]any{
|
||||
"status": overall,
|
||||
"checks": checks,
|
||||
"version": h.Version,
|
||||
"note": "AI providers are reported but not required: ingestion, search and browsing work without them.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The readiness probe is unauthenticated. MongoDB's driver errors embed the
|
||||
// topology — host, port, and, when the connection string carries them,
|
||||
// credentials — so the raw error must never reach the response body.
|
||||
func TestReadinessDoesNotEchoDependencyErrorText(t *testing.T) {
|
||||
secret := "mongodb://relata:sup3rs3cr3t@db.internal:27017/relata?authSource=admin"
|
||||
h := &HealthChecker{
|
||||
Mongo: func(context.Context) error {
|
||||
return fmt.Errorf("server selection error: current topology: { Servers: [{ Addr: %s }] }", secret)
|
||||
},
|
||||
Version: "test",
|
||||
Started: time.Now(),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.Ready(w, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
|
||||
body := w.Body.String()
|
||||
|
||||
for _, leak := range []string{"sup3rs3cr3t", "db.internal", "27017", "authSource", "topology"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("readiness response leaks %q from the driver error:\n%s", leak, body)
|
||||
}
|
||||
}
|
||||
|
||||
// It must still report the failure clearly.
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d when a required dependency is down",
|
||||
w.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
if !strings.Contains(body, "unavailable") {
|
||||
t.Fatalf("readiness hid the failure entirely:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The OpenAPI specification is written by hand, so it can drift from the code.
|
||||
// This test compares it against the routes the router actually registers, which
|
||||
// is the thing the specification claims to describe.
|
||||
//
|
||||
// The route list below is checked against router.go itself, so a route added
|
||||
// there without a corresponding entry here also fails.
|
||||
|
||||
// specPath is the specification under test.
|
||||
const specPath = "../../docs/openapi.yaml"
|
||||
|
||||
// routerPath is the file whose route registrations are the source of truth.
|
||||
const routerPath = "router.go"
|
||||
|
||||
// apiRoutePattern matches the API routes registered in the router.
|
||||
var apiRoutePattern = regexp.MustCompile(`mux\.Handle\("(GET|POST|PATCH|DELETE|PUT) (/api/v1[^"]*|/health/[^"]*)"`)
|
||||
|
||||
// specPathPattern matches top-level path keys in the specification.
|
||||
var specPathPattern = regexp.MustCompile(`(?m)^ (/(?:api/v1|health)[^:]*):`)
|
||||
|
||||
// specMethodPattern matches an HTTP method under a path.
|
||||
var specMethodPattern = regexp.MustCompile(`(?m)^ (get|post|patch|delete|put):`)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// routerRoutes returns "METHOD /path" for every API route the router registers.
|
||||
func routerRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
out := map[string]bool{}
|
||||
for _, m := range apiRoutePattern.FindAllStringSubmatch(readFile(t, routerPath), -1) {
|
||||
out[m[1]+" "+m[2]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no routes were found in router.go; the matcher needs updating")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// specRoutes returns "METHOD /path" for every operation in the specification.
|
||||
func specRoutes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
spec := readFile(t, specPath)
|
||||
|
||||
// Split the document at each path key, so methods are attributed correctly.
|
||||
locs := specPathPattern.FindAllStringSubmatchIndex(spec, -1)
|
||||
out := map[string]bool{}
|
||||
|
||||
for i, loc := range locs {
|
||||
path := spec[loc[2]:loc[3]]
|
||||
end := len(spec)
|
||||
if i+1 < len(locs) {
|
||||
end = locs[i+1][0]
|
||||
}
|
||||
for _, m := range specMethodPattern.FindAllStringSubmatch(spec[loc[1]:end], -1) {
|
||||
out[strings.ToUpper(m[1])+" "+path] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("no operations were found in the specification; the matcher needs updating")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestOpenAPISpecMatchesRegisteredRoutes(t *testing.T) {
|
||||
router := routerRoutes(t)
|
||||
spec := specRoutes(t)
|
||||
|
||||
var missing, extra []string
|
||||
for route := range router {
|
||||
if !spec[route] {
|
||||
missing = append(missing, route)
|
||||
}
|
||||
}
|
||||
for route := range spec {
|
||||
if !router[route] {
|
||||
extra = append(extra, route)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("these routes exist but are not documented in %s:\n %s",
|
||||
specPath, strings.Join(missing, "\n "))
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
t.Errorf("these routes are documented in %s but are not registered:\n %s",
|
||||
specPath, strings.Join(extra, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// The specification enumerates several closed sets that also exist as Go
|
||||
// constants. A value added in one place and not the other would make the
|
||||
// specification wrong in a way clients would act on.
|
||||
func TestOpenAPIEnumsMatchTheDomain(t *testing.T) {
|
||||
spec := readFile(t, specPath)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
values []string
|
||||
}{
|
||||
{"relationship types", []string{
|
||||
"similar_to", "mentions", "involves", "supports", "contradicts", "updates",
|
||||
"supersedes", "provides_context_for", "possibly_explains", "occurred_before",
|
||||
"occurred_after", "derived_from", "same_entity_as",
|
||||
}},
|
||||
{"provenance", []string{
|
||||
"explicit", "deterministic", "model_inferred", "user_confirmed", "user_rejected",
|
||||
}},
|
||||
{"entity types", []string{
|
||||
"person", "organisation", "location", "product", "event", "date", "subject", "concept",
|
||||
}},
|
||||
{"source types", []string{
|
||||
"note", "document", "json", "email", "transcript", "markdown",
|
||||
}},
|
||||
{"job types", []string{
|
||||
"parse_item", "chunk_item", "generate_embeddings", "extract_entities",
|
||||
"extract_claims", "discover_relationships", "reprocess_item",
|
||||
"delete_item_data", "reembed_workspace",
|
||||
}},
|
||||
{"error codes", []string{
|
||||
CodeInvalidRequest, CodeValidationFailed, CodeUnauthorised, CodeForbidden,
|
||||
CodeNotFound, CodeDuplicate, CodeConflict, CodePayloadTooLarge,
|
||||
CodeUnsupportedMedia, CodeRateLimited, CodeCSRFFailed, CodeDependencyDown,
|
||||
CodeModelInvalid, CodeFeatureDisabled, CodeInternal,
|
||||
}},
|
||||
{"signal types", []string{
|
||||
"semantic_similarity", "shared_entity", "alias_entity", "shared_concept",
|
||||
"temporal_proximity", "shared_metadata", "claim_support", "claim_contradiction",
|
||||
"model_classification", "user_confirmation", "user_rejection", "content_hash_match",
|
||||
}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
for _, v := range c.values {
|
||||
if !strings.Contains(spec, v) {
|
||||
t.Errorf("%s: %q is not documented in the specification", c.name, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The specification must not describe scores as probabilities, since the whole
|
||||
// design rests on their not being probabilities.
|
||||
func TestOpenAPIDescribesScoresHonestly(t *testing.T) {
|
||||
spec := readFile(t, specPath)
|
||||
|
||||
for _, required := range []string{
|
||||
"NOT a calibrated probability",
|
||||
"Not a probability",
|
||||
"not a fact",
|
||||
} {
|
||||
if !strings.Contains(spec, required) {
|
||||
t.Errorf("the specification does not state %q", required)
|
||||
}
|
||||
}
|
||||
// A claim that would be untrue.
|
||||
for _, forbidden := range []string{"probability that", "confidence interval", "calibrated probability that"} {
|
||||
if strings.Contains(strings.ToLower(spec), strings.ToLower(forbidden)) {
|
||||
t.Errorf("the specification contains the misleading phrase %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/config"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
"github.com/andydixon/relata/internal/observability"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// WebRoutes is implemented by the HTML handler set, so the router can mount the
|
||||
// browser interface without importing the web package (which imports this one).
|
||||
type WebRoutes interface {
|
||||
LoginPage(http.ResponseWriter, *http.Request)
|
||||
Login(http.ResponseWriter, *http.Request)
|
||||
Logout(http.ResponseWriter, *http.Request)
|
||||
OAuthStart(http.ResponseWriter, *http.Request)
|
||||
OAuthCallback(http.ResponseWriter, *http.Request)
|
||||
LandingPage(http.ResponseWriter, *http.Request)
|
||||
HowItWorksPage(http.ResponseWriter, *http.Request)
|
||||
PublicAPIPage(http.ResponseWriter, *http.Request)
|
||||
TokensPage(http.ResponseWriter, *http.Request)
|
||||
CreateToken(http.ResponseWriter, *http.Request)
|
||||
RevokeToken(http.ResponseWriter, *http.Request)
|
||||
Dashboard(http.ResponseWriter, *http.Request)
|
||||
ItemsPage(http.ResponseWriter, *http.Request)
|
||||
ItemDetail(http.ResponseWriter, *http.Request)
|
||||
Neighbourhood(http.ResponseWriter, *http.Request)
|
||||
DownloadOriginal(http.ResponseWriter, *http.Request)
|
||||
AddTextPage(http.ResponseWriter, *http.Request)
|
||||
AddText(http.ResponseWriter, *http.Request)
|
||||
UploadPage(http.ResponseWriter, *http.Request)
|
||||
Upload(http.ResponseWriter, *http.Request)
|
||||
DeleteItem(http.ResponseWriter, *http.Request)
|
||||
ReprocessItem(http.ResponseWriter, *http.Request)
|
||||
SearchPage(http.ResponseWriter, *http.Request)
|
||||
ReviewPage(http.ResponseWriter, *http.Request)
|
||||
ConfirmRelationship(http.ResponseWriter, *http.Request)
|
||||
RejectRelationship(http.ResponseWriter, *http.Request)
|
||||
ChangeRelationshipType(http.ResponseWriter, *http.Request)
|
||||
CreateRelationship(http.ResponseWriter, *http.Request)
|
||||
EntitiesPage(http.ResponseWriter, *http.Request)
|
||||
EntityDetail(http.ResponseWriter, *http.Request)
|
||||
ConfirmAlias(http.ResponseWriter, *http.Request)
|
||||
RejectAlias(http.ResponseWriter, *http.Request)
|
||||
JobsPage(http.ResponseWriter, *http.Request)
|
||||
JobsTable(http.ResponseWriter, *http.Request)
|
||||
RetryJob(http.ResponseWriter, *http.Request)
|
||||
SettingsPage(http.ResponseWriter, *http.Request)
|
||||
Reembed(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// RouterConfig holds what the router needs to wire the application together.
|
||||
type RouterConfig struct {
|
||||
Cfg *config.Config
|
||||
Auth *auth.Service
|
||||
Tokens *auth.TokenService
|
||||
API *Handlers
|
||||
Web WebRoutes
|
||||
Health *HealthChecker
|
||||
Metrics *observability.Metrics
|
||||
Static http.Handler
|
||||
Logger interface{ Handler() any }
|
||||
}
|
||||
|
||||
// NewRouter builds the complete HTTP handler.
|
||||
//
|
||||
// Route registration is explicit rather than pattern-driven, so that the exact
|
||||
// authentication, CSRF and body-size treatment of every endpoint is readable in
|
||||
// one place.
|
||||
func NewRouter(rc RouterConfig) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
logging := Logging{Metrics: rc.Metrics}
|
||||
authn := Authenticator{Auth: rc.Auth, LoginPath: "/login"}
|
||||
apiAuthn := Authenticator{Auth: rc.Auth, Tokens: rc.Tokens, APIMode: true}
|
||||
|
||||
csrf := CSRF{
|
||||
TrustedOrigins: trustedOrigins(rc.Cfg),
|
||||
HeaderName: "X-CSRF-Token",
|
||||
FieldName: "csrf_token",
|
||||
}
|
||||
|
||||
// --- Health and metrics -------------------------------------------------
|
||||
//
|
||||
// Health endpoints are unauthenticated so an orchestrator can reach them,
|
||||
// but they expose only status, never workspace content.
|
||||
mux.Handle("GET /health/live", wrap(rc, "/health/live", http.HandlerFunc(rc.Health.Live)))
|
||||
mux.Handle("GET /health/ready", wrap(rc, "/health/ready", http.HandlerFunc(rc.Health.Ready)))
|
||||
|
||||
if rc.Cfg.HTTP.MetricsEnabled && rc.Metrics != nil {
|
||||
// Metrics describe the service, not stored knowledge. They are bound to
|
||||
// the same loopback listener as everything else.
|
||||
mux.Handle("GET /metrics", promhttp.HandlerFor(rc.Metrics.Registry, promhttp.HandlerOpts{
|
||||
ErrorHandling: promhttp.ContinueOnError,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Static assets ------------------------------------------------------
|
||||
mux.Handle("GET /static/", staticHandler(rc.Static))
|
||||
|
||||
// --- REST API -----------------------------------------------------------
|
||||
//
|
||||
// API routes authenticate from the session cookie and enforce CSRF on
|
||||
// state-changing methods, exactly as the browser interface does.
|
||||
// Every API route names the scope it needs. CSRF is applied unconditionally
|
||||
// because the middleware skips safe methods itself and exempts bearer
|
||||
// requests — so no route can be registered having forgotten it.
|
||||
//
|
||||
// RequireScope subsumes the old RequireWrite: the scope check is the
|
||||
// intersection of the caller's role and, for a token, its granted scopes.
|
||||
apiChain := func(route string, h http.HandlerFunc, scope domain.TokenScope) http.Handler {
|
||||
return Chain(h,
|
||||
RequestID(),
|
||||
Recover(),
|
||||
logging.Middleware(route),
|
||||
SecurityHeaders(false),
|
||||
apiAuthn.RequireAuth(),
|
||||
csrf.Middleware(),
|
||||
RequireScope(scope),
|
||||
)
|
||||
}
|
||||
|
||||
// Token management is deliberately not scoped: it requires an interactive
|
||||
// session, so a leaked token cannot mint its own replacements.
|
||||
tokenChain := func(route string, h http.HandlerFunc) http.Handler {
|
||||
return Chain(h,
|
||||
RequestID(),
|
||||
Recover(),
|
||||
logging.Middleware(route),
|
||||
SecurityHeaders(false),
|
||||
apiAuthn.RequireAuth(),
|
||||
csrf.Middleware(),
|
||||
RequireSession(),
|
||||
)
|
||||
}
|
||||
|
||||
a := rc.API
|
||||
mux.Handle("POST /api/v1/items", apiChain("/api/v1/items", a.CreateItem, domain.ScopeIngest))
|
||||
mux.Handle("POST /api/v1/items/upload", apiChain("/api/v1/items/upload", a.UploadItem, domain.ScopeIngest))
|
||||
mux.Handle("GET /api/v1/items", apiChain("/api/v1/items", a.ListItems, domain.ScopeQuery))
|
||||
mux.Handle("GET /api/v1/items/{id}", apiChain("/api/v1/items/{id}", a.GetItem, domain.ScopeQuery))
|
||||
mux.Handle("DELETE /api/v1/items/{id}", apiChain("/api/v1/items/{id}", a.DeleteItem, domain.ScopeIngest))
|
||||
mux.Handle("POST /api/v1/items/{id}/reprocess", apiChain("/api/v1/items/{id}/reprocess", a.ReprocessItem, domain.ScopeIngest))
|
||||
mux.Handle("GET /api/v1/items/{id}/relationships", apiChain("/api/v1/items/{id}/relationships", a.GetItemRelationships, domain.ScopeQuery))
|
||||
|
||||
mux.Handle("GET /api/v1/search", apiChain("/api/v1/search", a.Search, domain.ScopeQuery))
|
||||
// Answering reads; it creates nothing. It needs the query scope, not ingest.
|
||||
mux.Handle("POST /api/v1/search/answer", apiChain("/api/v1/search/answer", a.Answer, domain.ScopeQuery))
|
||||
|
||||
mux.Handle("GET /api/v1/relationships", apiChain("/api/v1/relationships", a.ListRelationships, domain.ScopeQuery))
|
||||
mux.Handle("POST /api/v1/relationships", apiChain("/api/v1/relationships", a.CreateRelationship, domain.ScopeReview))
|
||||
mux.Handle("PATCH /api/v1/relationships/{id}", apiChain("/api/v1/relationships/{id}", a.PatchRelationship, domain.ScopeReview))
|
||||
mux.Handle("POST /api/v1/relationships/{id}/confirm", apiChain("/api/v1/relationships/{id}/confirm", a.ConfirmRelationship, domain.ScopeReview))
|
||||
mux.Handle("POST /api/v1/relationships/{id}/reject", apiChain("/api/v1/relationships/{id}/reject", a.RejectRelationship, domain.ScopeReview))
|
||||
|
||||
mux.Handle("GET /api/v1/entities", apiChain("/api/v1/entities", a.ListEntities, domain.ScopeQuery))
|
||||
mux.Handle("GET /api/v1/entities/{id}", apiChain("/api/v1/entities/{id}", a.GetEntity, domain.ScopeQuery))
|
||||
mux.Handle("POST /api/v1/aliases/{id}/confirm", apiChain("/api/v1/aliases/{id}/confirm", a.ConfirmAlias, domain.ScopeReview))
|
||||
mux.Handle("POST /api/v1/aliases/{id}/reject", apiChain("/api/v1/aliases/{id}/reject", a.RejectAlias, domain.ScopeReview))
|
||||
|
||||
mux.Handle("GET /api/v1/jobs", apiChain("/api/v1/jobs", a.ListJobs, domain.ScopeQuery))
|
||||
mux.Handle("POST /api/v1/jobs/{id}/retry", apiChain("/api/v1/jobs/{id}/retry", a.RetryJob, domain.ScopeIngest))
|
||||
|
||||
mux.Handle("GET /api/v1/tokens", tokenChain("/api/v1/tokens", a.ListTokens))
|
||||
mux.Handle("POST /api/v1/tokens", tokenChain("/api/v1/tokens", a.CreateToken))
|
||||
mux.Handle("DELETE /api/v1/tokens/{id}", tokenChain("/api/v1/tokens/{id}", a.RevokeToken))
|
||||
|
||||
// --- Browser interface --------------------------------------------------
|
||||
webChain := func(route string, h http.HandlerFunc, opts webOpts) http.Handler {
|
||||
mw := []Middleware{
|
||||
RequestID(),
|
||||
Recover(),
|
||||
logging.Middleware(route),
|
||||
SecurityHeaders(false),
|
||||
}
|
||||
if opts.authenticated {
|
||||
mw = append(mw, authn.RequireAuth())
|
||||
} else if opts.optionalAuth {
|
||||
mw = append(mw, authn.OptionalAuth())
|
||||
}
|
||||
if opts.csrf {
|
||||
mw = append(mw, csrf.Middleware())
|
||||
}
|
||||
if opts.write {
|
||||
mw = append(mw, RequireWrite())
|
||||
}
|
||||
return Chain(h, mw...)
|
||||
}
|
||||
|
||||
pub := webOpts{}
|
||||
read := webOpts{authenticated: true}
|
||||
write := webOpts{authenticated: true, csrf: true, write: true}
|
||||
|
||||
wb := rc.Web
|
||||
mux.Handle("GET /login", webChain("/login", wb.LoginPage, pub))
|
||||
// The password endpoint is registered only when local login is enabled, so
|
||||
// a disabled break-glass path is genuinely absent rather than present and
|
||||
// refusing. There is no handler to reach and nothing to probe.
|
||||
if rc.Cfg.Auth.LocalLoginEnabled {
|
||||
// It has its own CSRF handling: no session exists yet, so the
|
||||
// session-token check cannot apply.
|
||||
mux.Handle("POST /login", webChain("/login", wb.Login, pub))
|
||||
}
|
||||
mux.Handle("POST /logout", webChain("/logout", wb.Logout, webOpts{authenticated: true, csrf: true}))
|
||||
|
||||
// Federated sign-in. Both are public: the first has no session yet, and the
|
||||
// second is where the provider sends the browser back to.
|
||||
mux.Handle("GET /auth/oauth/start", webChain("/auth/oauth/start", wb.OAuthStart, pub))
|
||||
mux.Handle("GET "+config.CallbackPath, webChain(config.CallbackPath, wb.OAuthCallback, pub))
|
||||
|
||||
// The public site. These pages read nothing from the database.
|
||||
mux.Handle("GET /how-it-works", webChain("/how-it-works", wb.HowItWorksPage, pub))
|
||||
mux.Handle("GET /api", webChain("/api", wb.PublicAPIPage, pub))
|
||||
|
||||
// The only dual-purpose route: the dashboard when signed in, the landing
|
||||
// page when not. OptionalAuth rather than RequireAuth, so an anonymous
|
||||
// visitor is served rather than redirected.
|
||||
mux.Handle("GET /{$}", webChain("/", wb.LandingPage, webOpts{optionalAuth: true}))
|
||||
mux.Handle("GET /items", webChain("/items", wb.ItemsPage, read))
|
||||
mux.Handle("GET /items/{id}", webChain("/items/{id}", wb.ItemDetail, read))
|
||||
mux.Handle("GET /items/{id}/neighbourhood", webChain("/items/{id}/neighbourhood", wb.Neighbourhood, read))
|
||||
mux.Handle("GET /items/{id}/download", webChain("/items/{id}/download", wb.DownloadOriginal, read))
|
||||
mux.Handle("POST /items/{id}/delete", webChain("/items/{id}/delete", wb.DeleteItem, write))
|
||||
mux.Handle("POST /items/{id}/reprocess", webChain("/items/{id}/reprocess", wb.ReprocessItem, write))
|
||||
|
||||
mux.Handle("GET /add", webChain("/add", wb.AddTextPage, read))
|
||||
mux.Handle("POST /add", webChain("/add", wb.AddText, write))
|
||||
mux.Handle("GET /upload", webChain("/upload", wb.UploadPage, read))
|
||||
mux.Handle("POST /upload", webChain("/upload", wb.Upload, write))
|
||||
|
||||
mux.Handle("GET /search", webChain("/search", wb.SearchPage, read))
|
||||
|
||||
mux.Handle("GET /review", webChain("/review", wb.ReviewPage, read))
|
||||
mux.Handle("POST /relationships/create", webChain("/relationships/create", wb.CreateRelationship, write))
|
||||
mux.Handle("POST /relationships/{id}/confirm", webChain("/relationships/{id}/confirm", wb.ConfirmRelationship, write))
|
||||
mux.Handle("POST /relationships/{id}/reject", webChain("/relationships/{id}/reject", wb.RejectRelationship, write))
|
||||
mux.Handle("POST /relationships/{id}/type", webChain("/relationships/{id}/type", wb.ChangeRelationshipType, write))
|
||||
|
||||
mux.Handle("GET /entities", webChain("/entities", wb.EntitiesPage, read))
|
||||
mux.Handle("GET /entities/{id}", webChain("/entities/{id}", wb.EntityDetail, read))
|
||||
mux.Handle("POST /aliases/{id}/confirm", webChain("/aliases/{id}/confirm", wb.ConfirmAlias, write))
|
||||
mux.Handle("POST /aliases/{id}/reject", webChain("/aliases/{id}/reject", wb.RejectAlias, write))
|
||||
|
||||
mux.Handle("GET /jobs", webChain("/jobs", wb.JobsPage, read))
|
||||
mux.Handle("GET /jobs/table", webChain("/jobs/table", wb.JobsTable, read))
|
||||
mux.Handle("POST /jobs/{id}/retry", webChain("/jobs/{id}/retry", wb.RetryJob, write))
|
||||
|
||||
mux.Handle("GET /settings", webChain("/settings", wb.SettingsPage, read))
|
||||
mux.Handle("POST /settings/reembed", webChain("/settings/reembed", wb.Reembed, write))
|
||||
|
||||
mux.Handle("GET /settings/tokens", webChain("/settings/tokens", wb.TokensPage, read))
|
||||
mux.Handle("POST /settings/tokens", webChain("/settings/tokens", wb.CreateToken, write))
|
||||
mux.Handle("POST /settings/tokens/{id}/revoke", webChain("/settings/tokens/{id}/revoke", wb.RevokeToken, write))
|
||||
|
||||
// Anything unmatched. The handler distinguishes API paths from browser
|
||||
// paths so a mistyped API URL gets JSON, not HTML.
|
||||
mux.Handle("/", Chain(http.HandlerFunc(notFound),
|
||||
RequestID(), Recover(), logging.Middleware("not_found"), SecurityHeaders(false)))
|
||||
|
||||
// The outermost body limit applies to every route. Upload routes raise it
|
||||
// themselves, since a document is legitimately larger than a JSON request.
|
||||
return MaxBody(maxBodyFor(rc.Cfg))(mux)
|
||||
}
|
||||
|
||||
type webOpts struct {
|
||||
authenticated bool
|
||||
// optionalAuth resolves a session when one is present but serves anonymous
|
||||
// callers rather than redirecting them.
|
||||
optionalAuth bool
|
||||
csrf bool
|
||||
write bool
|
||||
}
|
||||
|
||||
// maxBodyFor returns the global request-body ceiling.
|
||||
//
|
||||
// It is the larger of the JSON limit and the upload limit plus multipart
|
||||
// overhead, because one middleware guards both kinds of route.
|
||||
func maxBodyFor(cfg *config.Config) int64 {
|
||||
limit := cfg.HTTP.MaxRequestBytes
|
||||
if upload := cfg.Uploads.MaxUploadBytes + (2 << 20); upload > limit {
|
||||
limit = upload
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// wrap applies the standard middleware to an unauthenticated endpoint.
|
||||
func wrap(rc RouterConfig, route string, h http.Handler) http.Handler {
|
||||
logging := Logging{Metrics: rc.Metrics}
|
||||
return Chain(h, RequestID(), Recover(), logging.Middleware(route), SecurityHeaders(false))
|
||||
}
|
||||
|
||||
// staticHandler serves embedded assets with long-lived caching.
|
||||
//
|
||||
// Static files carry no user content, so they are the one place where caching
|
||||
// is safe; the no-store default set by SecurityHeaders is overridden here.
|
||||
func staticHandler(fs http.Handler) http.Handler {
|
||||
return http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Reject any path attempting to escape the asset root. http.FS already
|
||||
// refuses these, but failing early keeps the intent explicit.
|
||||
if strings.Contains(r.URL.Path, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
fs.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// notFound answers unmatched routes in the right format.
|
||||
func notFound(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
WriteError(w, r, &APIError{
|
||||
Status: http.StatusNotFound, Code: CodeNotFound,
|
||||
Message: "No such endpoint. The API is versioned under /api/v1.",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html><html lang="en-GB"><head><meta charset="utf-8">` +
|
||||
`<title>Not found — Relata</title><link rel="stylesheet" href="/static/app.css"></head>` +
|
||||
`<body><main class="page"><div class="error-page"><h1>Page not found</h1>` +
|
||||
`<p>There is nothing at this address.</p>` +
|
||||
`<p><a class="btn" href="/">Back to the dashboard</a></p></div></main></body></html>`))
|
||||
}
|
||||
|
||||
// trustedOrigins derives the origins permitted to submit state-changing
|
||||
// requests, from the configured base URL and listen address.
|
||||
func trustedOrigins(cfg *config.Config) []string {
|
||||
origins := []string{}
|
||||
if base := strings.TrimRight(cfg.HTTP.BaseURL, "/"); base != "" {
|
||||
origins = append(origins, base)
|
||||
}
|
||||
// The listen address is also accepted, so that reaching Relata directly at
|
||||
// http://127.0.0.1:8080 works when BaseURL names something else.
|
||||
addr := cfg.HTTP.Addr
|
||||
if strings.HasPrefix(addr, ":") {
|
||||
addr = "127.0.0.1" + addr
|
||||
}
|
||||
for _, scheme := range []string{"http://", "https://"} {
|
||||
o := scheme + addr
|
||||
if !containsStr(origins, o) {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
// localhost and 127.0.0.1 are the same host to a person but different
|
||||
// origins to a browser.
|
||||
if strings.HasPrefix(addr, "127.0.0.1:") {
|
||||
port := strings.TrimPrefix(addr, "127.0.0.1")
|
||||
origins = append(origins, "http://localhost"+port, "https://localhost"+port)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func containsStr(list []string, v string) bool {
|
||||
for _, e := range list {
|
||||
if e == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/relata/internal/auth"
|
||||
"github.com/andydixon/relata/internal/domain"
|
||||
)
|
||||
|
||||
// tokenView is a token as returned to a caller.
|
||||
//
|
||||
// There is no field for the secret because there is no secret to return: only
|
||||
// its hash was stored. The prefix is enough to tell one token from another.
|
||||
type tokenView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Scopes []domain.TokenScope `json:"scopes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func viewToken(t *domain.APIToken, now time.Time) tokenView {
|
||||
v := tokenView{
|
||||
ID: t.ID, Name: t.Name, Prefix: t.Prefix, Scopes: t.Scopes,
|
||||
CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt,
|
||||
LastUsedAt: t.LastUsedAt, RevokedAt: t.RevokedAt,
|
||||
Status: "active",
|
||||
}
|
||||
switch {
|
||||
case t.RevokedAt != nil:
|
||||
v.Status = "revoked"
|
||||
case !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt):
|
||||
v.Status = "expired"
|
||||
}
|
||||
if v.Scopes == nil {
|
||||
v.Scopes = []domain.TokenScope{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ListTokens returns the caller's own API tokens.
|
||||
func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := principal(r)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
toks, err := h.d.Tokens.List(r.Context(), p)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
out := make([]tokenView, 0, len(toks))
|
||||
for _, t := range toks {
|
||||
out = append(out, viewToken(t, now))
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]any{"tokens": out, "total": len(out)})
|
||||
}
|
||||
|
||||
// createTokenRequest is the JSON body for issuing a token.
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
// TTLHours is optional; the configured default applies when it is absent.
|
||||
TTLHours int `json:"ttl_hours"`
|
||||
}
|
||||
|
||||
// createTokenResponse is the only place a token secret ever appears.
|
||||
type createTokenResponse struct {
|
||||
Token tokenView `json:"token"`
|
||||
// Secret is shown once. It cannot be retrieved again.
|
||||
Secret string `json:"secret"`
|
||||
Warning string `json:"warning"`
|
||||
}
|
||||
|
||||
// CreateToken issues an API token for the caller.
|
||||
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := principal(r)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req createTokenRequest
|
||||
if err := DecodeJSON(w, r, 8<<10, &req); err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
scopes, err := parseScopes(req.Scopes)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
ttl := time.Duration(req.TTLHours) * time.Hour
|
||||
issued, err := h.d.Tokens.Create(r.Context(), p, auth.CreateRequest{
|
||||
Name: req.Name, Scopes: scopes, TTL: ttl,
|
||||
})
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Audit.Record(r.Context(), p, domain.AuditTokenCreated, "api_token", issued.Token.ID, "success",
|
||||
map[string]string{"name": issued.Token.Name, "prefix": issued.Token.Prefix})
|
||||
|
||||
WriteJSON(w, http.StatusCreated, createTokenResponse{
|
||||
Token: viewToken(issued.Token, time.Now().UTC()),
|
||||
Secret: issued.Secret,
|
||||
Warning: "This is the only time the token is shown. Store it now; " +
|
||||
"Relata keeps only a hash and cannot show it again.",
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeToken makes one of the caller's tokens unusable.
|
||||
func (h *Handlers) RevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := principal(r)
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.d.Tokens.Revoke(r.Context(), p, id); err != nil {
|
||||
WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Audit.Record(r.Context(), p, domain.AuditTokenRevoked, "api_token", id, "success", nil)
|
||||
WriteJSON(w, http.StatusOK, map[string]any{"revoked": true, "id": id})
|
||||
}
|
||||
|
||||
// parseScopes validates requested scopes against the closed set.
|
||||
//
|
||||
// An unrecognised scope is refused rather than ignored: silently dropping one
|
||||
// would issue a token weaker than the caller believes they hold.
|
||||
func parseScopes(raw []string) ([]domain.TokenScope, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, &APIError{
|
||||
Status: http.StatusBadRequest, Code: CodeInvalidRequest,
|
||||
Message: "Choose at least one scope for the token.",
|
||||
Detail: map[string]string{"available": "ingest, query, review"},
|
||||
}
|
||||
}
|
||||
out := make([]domain.TokenScope, 0, len(raw))
|
||||
for _, s := range raw {
|
||||
sc := domain.TokenScope(s)
|
||||
if !sc.Valid() {
|
||||
return nil, &APIError{
|
||||
Status: http.StatusBadRequest, Code: CodeInvalidRequest,
|
||||
Message: "That is not a recognised scope.",
|
||||
Detail: map[string]string{"scope": s, "available": "ingest, query, review"},
|
||||
}
|
||||
}
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user