Files
2026-08-11 10:40:10 +01:00

368 lines
16 KiB
Go

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
}