698 lines
22 KiB
Go
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,
|
|
}
|
|
}
|