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

698 lines
22 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/api"
"github.com/andydixon/relata/internal/audit"
"github.com/andydixon/relata/internal/auth"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/ingest"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/parsing"
"github.com/andydixon/relata/internal/ranking"
"github.com/andydixon/relata/internal/relationships"
"github.com/andydixon/relata/internal/search"
)
// Deps bundles what the web handlers need.
type Deps struct {
Cfg *config.Config
Renderer *Renderer
Auth *auth.Service
// OAuth and Provisioner are nil when federated sign-in is not configured.
OAuth *auth.OAuthService
Provisioner *auth.Provisioner
Tokens *auth.TokenService
Version string
Items domain.ItemRepository
Chunks domain.ChunkRepository
Entities domain.EntityRepository
Aliases domain.AliasRepository
Claims domain.ClaimRepository
Rels domain.RelationshipRepository
Jobs domain.JobRepository
Runs domain.RunRepository
Models domain.ModelVersionRepository
Users domain.UserRepository
Vectors domain.VectorStore
Ingest *ingest.Service
Pipeline *ingest.Pipeline
Search *search.Service
Answer *search.Answerer
Reviewer *relationships.Reviewer
Analysis *analysis.Service
Audit *audit.Recorder
Embedder domain.Embedder
EmbedHealth domain.HealthChecker
AnalyseHealth domain.HealthChecker
}
// Handlers serves the HTML interface.
type Handlers struct{ d Deps }
// NewHandlers builds the web handlers.
func NewHandlers(d Deps) *Handlers { return &Handlers{d: d} }
// base carries the fields every page template expects.
type base struct {
Title string
Nav string
Principal *domain.Principal
CSRFToken string
Flash *flash
Error string
PendingReview int64
RequestID string
}
type flash struct {
Kind string // success | warn | error | info
Message string
}
// newBase assembles the shared page context.
func (h *Handlers) newBase(r *http.Request, title, nav string) base {
b := base{Title: title, Nav: nav, RequestID: observability.RequestID(r.Context())}
b.Principal = api.PrincipalFrom(r.Context())
if s := api.SessionFrom(r.Context()); s != nil {
b.CSRFToken = s.CSRFToken
}
if b.Principal != nil {
if n, err := h.d.Rels.CountPendingReview(r.Context(), b.Principal.WorkspaceID); err == nil {
b.PendingReview = n
}
}
// A one-shot flash carried in the query string, set by a redirect after a
// successful action.
if msg := r.URL.Query().Get("ok"); msg != "" {
b.Flash = &flash{Kind: "success", Message: knownMessage(msg)}
}
if msg := r.URL.Query().Get("warn"); msg != "" {
b.Flash = &flash{Kind: "warn", Message: knownMessage(msg)}
}
return b
}
// knownMessage maps a short code to fixed text.
//
// Flash text is never taken from the query string directly: doing so would let
// a crafted link put arbitrary words on a trusted page.
func knownMessage(code string) string {
switch code {
case "item_added":
return "The item was stored and queued for processing."
case "item_deleted":
return "The item is being deleted along with its derived records and vectors."
case "item_reprocessing":
return "The item has been queued for reprocessing."
case "rel_confirmed":
return "Relationship confirmed. The original proposal and its evidence are retained."
case "rel_rejected":
return "Relationship rejected. It is kept on record and will no longer influence search."
case "rel_updated":
return "The relationship type was changed. The original proposal is retained."
case "rel_created":
return "The relationship was recorded."
case "job_retried":
return "The job has been returned to the queue."
case "alias_confirmed":
return "Alias confirmed. The entities remain separate records; the confirmation now counts as evidence when relationships are discovered."
case "alias_rejected":
return "Alias rejected. The proposal is kept on record so it is not offered again as new."
case "reembed_queued":
return "Re-embedding has been queued for this workspace."
case "duplicate":
return "An item with identical content already exists. Tick “store even if identical” to keep both."
case "signed_out":
return "You have been signed out."
case "token_revoked":
return "The token was revoked. It will be refused on its next use."
case "token_invalid":
return "The token could not be created. Check the name, the scopes and the expiry."
}
return "Done."
}
// renderError shows a friendly error page.
func (h *Handlers) renderError(w http.ResponseWriter, r *http.Request, status int, title, msg string) {
data := struct {
base
ErrorTitle string
ErrorMessage string
}{
base: h.newBase(r, title, ""),
ErrorTitle: title,
ErrorMessage: msg,
}
h.d.Renderer.Render(w, r, "error.html", status, data)
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
// LoginPage shows the sign-in form.
func (h *Handlers) LoginPage(w http.ResponseWriter, r *http.Request) {
// An already-authenticated visitor goes straight to the dashboard.
if p, _, err := h.d.Auth.Resolve(r.Context(), h.d.Auth.TokenFromRequest(r)); err == nil && p != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// The login form needs its own CSRF token before a session exists. One is
// issued in a short-lived cookie and checked on submission.
token, err := h.issuePreAuthToken(w)
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Sign-in unavailable",
"A security token could not be generated. Please try again.")
return
}
firstRun := false
if n, err := h.d.Users.Count(r.Context()); err == nil && n == 0 {
firstRun = true
}
data := struct {
base
FirstRun bool
OAuthEnabled bool
LocalEnabled bool
ProviderName string
Next string
}{
base: base{Title: "Sign in", CSRFToken: token},
FirstRun: firstRun,
OAuthEnabled: h.oauthEnabled(),
LocalEnabled: h.localLoginEnabled(),
ProviderName: h.providerName(),
// Sanitised here rather than in the template: it becomes a URL.
Next: auth.SafeRedirectPath(r.URL.Query().Get("next")),
}
if data.Next == "/" {
data.Next = "" // nothing worth carrying
}
if r.URL.Query().Get("ok") == "signed_out" {
data.Flash = &flash{Kind: "success", Message: knownMessage("signed_out")}
}
if r.URL.Query().Get("error") == "1" {
data.Error = "The username or password is incorrect."
}
if r.URL.Query().Get("error") == "rate" {
data.Error = "Too many sign-in attempts. Please wait a few minutes and try again."
}
h.d.Renderer.Render(w, r, "login.html", http.StatusOK, data)
}
// preAuthCookie holds the CSRF token for the login form itself.
const preAuthCookie = "relata_login_csrf"
func (h *Handlers) issuePreAuthToken(w http.ResponseWriter) (string, error) {
token, err := ids.Token(32)
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: preAuthCookie,
Value: token,
Path: "/login",
MaxAge: 900,
HttpOnly: true,
Secure: h.d.Cfg.HTTP.SecureCookies,
SameSite: http.SameSiteLaxMode,
})
return token, nil
}
// Login processes the sign-in form.
//
// Failures are deliberately uniform: the same message and the same redirect,
// whether the username is unknown, the password is wrong or the account is
// disabled. Nothing about which one it was reaches the client.
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
// The route is not registered when local login is disabled, so this is
// belt and braces — but the guarantee should not rest on the router alone.
if !h.localLoginEnabled() {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
// Check the pre-authentication CSRF token.
cookie, err := r.Cookie(preAuthCookie)
if err != nil || !auth.CheckCSRF(cookie.Value, r.PostFormValue("csrf_token")) {
observability.LoggerFrom(r.Context()).Warn("login CSRF check failed")
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
username := r.PostFormValue("username")
password := r.PostFormValue("password")
rateKey := auth.ClientKey(r, h.d.Cfg.HTTP.TrustProxyHeaders)
res, err := h.d.Auth.Login(r.Context(), username, password, rateKey, r.UserAgent())
if err != nil {
h.d.Audit.RecordAnonymous(r.Context(), "", domain.AuditLoginFailed, "failure",
map[string]string{"reason": "invalid_credentials"})
if errors.Is(err, auth.ErrTooManyAttempts) {
http.Redirect(w, r, "/login?error=rate", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther)
return
}
// Clear the pre-auth cookie and issue the session cookie. The session ID is
// new, so no identifier survives across the authentication boundary.
http.SetCookie(w, &http.Cookie{
Name: preAuthCookie, Value: "", Path: "/login", MaxAge: -1,
HttpOnly: true, Secure: h.d.Cfg.HTTP.SecureCookies, SameSite: http.SameSiteLaxMode,
})
h.d.Auth.SetSessionCookie(w, res.Token, res.Session.ExpiresAt)
h.d.Audit.Record(r.Context(), &domain.Principal{
UserID: res.User.ID, Username: res.User.Username, WorkspaceID: res.User.WorkspaceID,
}, domain.AuditLogin, "user", res.User.ID, "success", nil)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// Logout ends the session.
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
if s := api.SessionFrom(r.Context()); s != nil {
_ = h.d.Auth.Logout(r.Context(), s.ID)
h.d.Audit.Record(r.Context(), api.PrincipalFrom(r.Context()),
domain.AuditLogout, "session", s.ID, "success", nil)
}
h.d.Auth.ClearSessionCookie(w)
http.Redirect(w, r, "/login?ok=signed_out", http.StatusSeeOther)
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
type dashStats struct {
Items, Chunks, Entities, Relationships int64
PendingJobs, FailedJobs int
AwaitingReview int
}
type pendingRel struct {
Rel *domain.Relationship
SourceTitle string
TargetTitle string
}
// Dashboard shows workspace totals and what needs attention.
func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
ctx := r.Context()
stats := dashStats{}
stats.Items, _ = h.d.Items.Count(ctx, p.WorkspaceID)
stats.Chunks, _ = h.d.Chunks.Count(ctx, p.WorkspaceID)
stats.Entities, _ = h.d.Entities.Count(ctx, p.WorkspaceID)
stats.Relationships, _ = h.d.Rels.Count(ctx, p.WorkspaceID)
if counts, err := h.d.Jobs.CountByStatus(ctx, p.WorkspaceID); err == nil {
stats.PendingJobs = int(counts[domain.JobPending] + counts[domain.JobRunning])
stats.FailedJobs = int(counts[domain.JobFailed])
}
if n, err := h.d.Rels.CountPendingReview(ctx, p.WorkspaceID); err == nil {
stats.AwaitingReview = int(n)
}
items, _, _ := h.d.Items.List(ctx, p.WorkspaceID, domain.ItemFilter{}, domain.Page{Limit: 8})
rels, _, _ := h.d.Rels.List(ctx, p.WorkspaceID, domain.RelationshipFilter{
ReviewStatus: []domain.ReviewStatus{domain.ReviewPending},
}, domain.Page{Limit: 8})
pending := make([]pendingRel, 0, len(rels))
titles := h.titlesFor(ctx, p.WorkspaceID, rels)
for _, rel := range rels {
pending = append(pending, pendingRel{
Rel: rel, SourceTitle: titles[rel.SourceID], TargetTitle: titles[rel.TargetID],
})
}
data := struct {
base
Stats dashStats
RecentItems []*domain.Item
PendingRelationships []pendingRel
ProviderWarnings []string
}{
base: h.newBase(r, "Dashboard", "dashboard"),
Stats: stats,
RecentItems: items,
PendingRelationships: pending,
ProviderWarnings: h.providerWarnings(ctx),
}
h.d.Renderer.Render(w, r, "dashboard.html", http.StatusOK, data)
}
// providerWarnings reports any AI provider that is unreachable, so the
// dashboard can say plainly which features are degraded rather than leaving a
// user to wonder why entities stopped appearing.
func (h *Handlers) providerWarnings(ctx context.Context) []string {
var out []string
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if h.d.EmbedHealth != nil {
if st := h.d.EmbedHealth.Health(checkCtx); !st.Available {
msg := "The embedding model is unavailable, so new items cannot be indexed for meaning-based search."
if st.Detail != "" {
msg += " " + st.Detail
}
out = append(out, msg)
}
}
if h.d.AnalyseHealth != nil && h.d.Cfg.Ollama.AnalysisEnabled {
if st := h.d.AnalyseHealth.Health(checkCtx); !st.Available {
msg := "The analysis model is unavailable. Entity and claim extraction fall back to deterministic rules, and generated answers are disabled."
if st.Detail != "" {
msg += " " + st.Detail
}
out = append(out, msg)
}
}
return out
}
// titlesFor resolves item titles at both ends of a set of relationships.
func (h *Handlers) titlesFor(ctx context.Context, workspaceID string, rels []*domain.Relationship) map[string]string {
var idList []string
for _, rel := range rels {
idList = append(idList, rel.SourceID, rel.TargetID)
}
out := map[string]string{}
if len(idList) == 0 {
return out
}
items, err := h.d.Items.GetMany(ctx, workspaceID, idList)
if err != nil {
return out
}
for _, it := range items {
out[it.ID] = it.Title
}
return out
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
type pagination struct {
Total int
From, To int
HasMore bool
PrevURL, NextURL string
}
func buildPagination(r *http.Request, total, limit, offset, shown int) pagination {
p := pagination{Total: total, From: offset + 1, To: offset + shown}
if shown == 0 {
p.From = 0
}
p.HasMore = offset+shown < total
mk := func(newOffset int) string {
q := r.URL.Query()
q.Set("offset", strconv.Itoa(newOffset))
return r.URL.Path + "?" + q.Encode()
}
if offset > 0 {
prev := offset - limit
if prev < 0 {
prev = 0
}
p.PrevURL = mk(prev)
}
if p.HasMore {
p.NextURL = mk(offset + limit)
}
return p
}
// ItemsPage lists stored items.
func (h *Handlers) ItemsPage(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
q := r.URL.Query()
f := domain.ItemFilter{TitleContains: strings.TrimSpace(q.Get("title"))}
if st := q.Get("source_type"); st != "" {
if s := domain.SourceType(st); s.Valid() {
f.SourceTypes = []domain.SourceType{s}
}
}
if s := q.Get("status"); s != "" {
f.Statuses = []domain.ProcessingStatus{domain.ProcessingStatus(s)}
}
limit, offset := pageParams(r, 25)
items, total, err := h.d.Items.List(r.Context(), p.WorkspaceID, f, domain.Page{Limit: limit, Offset: offset})
if err != nil {
h.renderError(w, r, http.StatusInternalServerError, "Could not list items", "Please try again.")
return
}
data := struct {
base
Items []*domain.Item
Filters map[string]string
Pagination pagination
}{
base: h.newBase(r, "Items", "items"),
Items: items,
Filters: map[string]string{
"Title": q.Get("title"), "SourceType": q.Get("source_type"), "Status": q.Get("status"),
},
Pagination: buildPagination(r, int(total), limit, offset, len(items)),
}
h.d.Renderer.Render(w, r, "items.html", http.StatusOK, data)
}
// ItemDetail shows one item and everything derived from it.
func (h *Handlers) ItemDetail(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
h.renderError(w, r, http.StatusNotFound, "Item not found", "That item identifier is not valid.")
return
}
ctx := r.Context()
item, err := h.d.Items.Get(ctx, p.WorkspaceID, id)
if err != nil {
h.renderError(w, r, http.StatusNotFound, "Item not found",
"That item does not exist in this workspace, or it has been deleted.")
return
}
chunks, _ := h.d.Chunks.ListByItem(ctx, p.WorkspaceID, id)
ents, _ := h.d.Entities.ListByItem(ctx, p.WorkspaceID, id)
claims, _ := h.d.Claims.ListByItem(ctx, p.WorkspaceID, id)
outRels, inRels, _ := h.d.Rels.ListForNode(ctx, p.WorkspaceID, domain.NodeItem, id, domain.RelationshipFilter{})
runs, _ := h.d.Runs.ListByItem(ctx, p.WorkspaceID, id, 10)
others, _, _ := h.d.Items.List(ctx, p.WorkspaceID, domain.ItemFilter{}, domain.Page{Limit: 100})
// Exclude this item from the "add a relationship" picker.
picker := make([]*domain.Item, 0, len(others))
for _, it := range others {
if it.ID != id {
picker = append(picker, it)
}
}
titles := h.titlesFor(ctx, p.WorkspaceID, append(append([]*domain.Relationship{}, outRels...), inRels...))
titles[item.ID] = item.Title
data := struct {
base
Item *domain.Item
Chunks []*domain.Chunk
Entities []*domain.Entity
Claims []*domain.Claim
Outgoing []relView
Incoming []relView
Runs []*domain.ProcessingRun
OtherItems []*domain.Item
RelationshipCount int
}{
base: h.newBase(r, item.Title, "items"),
Item: item,
Chunks: chunks,
Entities: ents,
Claims: claims,
Outgoing: toRelViews(outRels, titles),
Incoming: toRelViews(inRels, titles),
Runs: runs,
OtherItems: picker,
RelationshipCount: len(outRels) + len(inRels),
}
h.d.Renderer.Render(w, r, "item_detail.html", http.StatusOK, data)
}
// relView is a relationship with its endpoint titles resolved for display.
type relView struct {
Rel *domain.Relationship
SourceID string
TargetID string
SourceTitle string
TargetTitle string
}
func toRelViews(rels []*domain.Relationship, titles map[string]string) []relView {
out := make([]relView, 0, len(rels))
for _, rel := range rels {
out = append(out, relView{
Rel: rel,
SourceID: rel.SourceID, TargetID: rel.TargetID,
SourceTitle: titles[rel.SourceID], TargetTitle: titles[rel.TargetID],
})
}
return out
}
// Neighbourhood shows an item and its directly connected items.
func (h *Handlers) Neighbourhood(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
h.renderError(w, r, http.StatusNotFound, "Item not found", "That item identifier is not valid.")
return
}
ctx := r.Context()
item, err := h.d.Items.Get(ctx, p.WorkspaceID, id)
if err != nil {
h.renderError(w, r, http.StatusNotFound, "Item not found", "That item does not exist in this workspace.")
return
}
outRels, inRels, _ := h.d.Rels.ListForNode(ctx, p.WorkspaceID, domain.NodeItem, id, domain.RelationshipFilter{})
titles := h.titlesFor(ctx, p.WorkspaceID, append(append([]*domain.Relationship{}, outRels...), inRels...))
type neighbour struct {
Rel *domain.Relationship
OtherID string
OtherTitle string
}
mk := func(rels []*domain.Relationship, takeTarget bool) []neighbour {
out := make([]neighbour, 0, len(rels))
for _, rel := range rels {
other := rel.SourceID
if takeTarget {
other = rel.TargetID
}
out = append(out, neighbour{Rel: rel, OtherID: other, OtherTitle: titles[other]})
}
return out
}
data := struct {
base
Item *domain.Item
Outgoing []neighbour
Incoming []neighbour
}{
base: h.newBase(r, "Neighbourhood", "items"),
Item: item,
Outgoing: mk(outRels, true),
Incoming: mk(inRels, false),
}
h.d.Renderer.Render(w, r, "neighbourhood.html", http.StatusOK, data)
}
// DownloadOriginal serves an item's original uploaded file.
//
// The file is always sent as an attachment with a generic content type, so a
// browser downloads it rather than rendering it. That is what stops an uploaded
// HTML or SVG file executing script in Relata's origin.
func (h *Handlers) DownloadOriginal(w http.ResponseWriter, r *http.Request) {
p := api.PrincipalFrom(r.Context())
id := r.PathValue("id")
if !ids.Valid(id) {
http.NotFound(w, r)
return
}
item, err := h.d.Items.Get(r.Context(), p.WorkspaceID, id)
if err != nil {
http.NotFound(w, r)
return
}
content, err := h.d.Ingest.ReadOriginal(item)
if err != nil {
http.NotFound(w, r)
return
}
name := ingest.SanitiseFilename(item.OriginalFilename)
if name == "" {
name = "item-" + item.ID
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(name)))
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
_, _ = w.Write(content)
}
func pageParams(r *http.Request, def int) (limit, offset int) {
limit = def
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return limit, offset
}
// versionInfo is used by the settings page.
type versionInfo struct {
Version string
ParserVersion string
ScoreVersion string
RankVersion string
EmbeddingDims int
}
func currentVersions(appVersion string, dims int) versionInfo {
return versionInfo{
Version: appVersion,
ParserVersion: parsing.Version,
ScoreVersion: relationships.ScoreVersion,
RankVersion: ranking.RankVersion,
EmbeddingDims: dims,
}
}