package web import ( "context" "fmt" "html/template" "log/slog" "net/http" "net/url" "strings" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/sr-ht-dolt/beads" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // beadsView renders a "beads" (bd) issue database as a Mardi Gras parade board: // four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a // per-issue detail pane reachable via ?issue=. ?layout=stream draws the same // filtered set as one column of sections instead — a layout of this view and not // a second one, which is why it is a query parameter and not another tab. All // data is read through the BrowseSession surface (Rows/Tables) — there is no SQL // engine behind it. // // The reading itself is not here: the fingerprint, the lane bucketing, the ready // rule and the whole view model live in the beads package, which the MCP surface // shares. This type is only the View adapter — slug, label, template, and the // hand-off of the request's ref and query. type beadsView struct { // prefixes is the cross-database link index's cache: one issue prefix per // database, gated on that database's head hash and expiring on // beads.ReadyCacheTTL — /ready's bounds, shared rather than restated (see // beads/cache.go). // // It lives on the view because the view is the one piece of per-process state // this rendering has, and because a zero value is a working cache: views are // registered as a bare &beadsView{}, here and in every test. // // The cache is keyed on the database and never on the caller, which is safe // precisely because what it holds is a database's own prefix — a fact about // the store, not about who may see it. Who may see it is decided per request, // before a store is opened, and the index built from it belongs to that // request alone. prefixes beads.PrefixCache } // The two Data.Mode values that render one issue rather than a board. They are // what decides whether this request pays for the cross-database index at all. const ( beadsModeDetail = "detail" beadsModeEpic = "epic" ) func (*beadsView) Name() string { return "beads" } func (*beadsView) Label() string { return "Beads" } func (*beadsView) Template() string { return "beads.html" } // Applies fingerprints a beads DB; see beads.Applies for the rule. func (*beadsView) Applies(tables []browse.TableInfo) bool { return beads.Applies(tables) } // Build reads the issue graph and produces either the board or, when ?issue= // names an issue, that issue's detail pane. The result is a *beads.Data, // handed to beads.html as its .Data. func (*beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) { data, err := beads.Build(ctx, sess, ref, query) if err != nil { return nil, err } return data, nil } // --- cross-database issue links ----------------------------------------------- // beadLinks renders one stored text with the issue ids in it linked to the // databases that own them. It is the envelope's .Links on the issue detail pane // and nil everywhere else, and a nil one renders the text with no links at all — // which is a whole answer, not a degraded one: an id nobody here owns is text. type beadLinks struct { index *beads.PrefixIndex } // Text renders a stored text — a description, a comment body, an event summary — // as HTML with every recognised issue id wrapped in a link. // // It escapes first and wraps second. The result is built as a sequence of // escaped segments and anchors this function generated itself, and only the // finished whole is marked template.HTML: marking user-stored text as HTML and // then running a regexp over it is how a stored payload becomes a rendered one. // Nothing that came out of the database is ever handed to the browser unescaped, // including the id inside the anchor and the href built from it. func (l *beadLinks) Text(s string) template.HTML { var refs []beads.Reference if l != nil { refs = l.index.Scan(s) } var b strings.Builder b.Grow(len(s)) last := 0 for _, ref := range refs { b.WriteString(template.HTMLEscapeString(s[last:ref.Start])) b.WriteString(``) b.WriteString(template.HTMLEscapeString(ref.ID)) b.WriteString(``) last = ref.End } b.WriteString(template.HTMLEscapeString(s[last:])) return template.HTML(b.String()) } // beadIssueHref is the detail-pane URL of one issue in one database: the same // address beads.html writes for a dependency edge, built here because this one // is assembled in Go rather than by the template. func beadIssueHref(owner, name, id string) string { return "/~" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/view/beads?issue=" + url.QueryEscape(id) } // beadCrossLinks builds the link index for one render, and returns nil for every // page that is not an issue detail pane: the board carries ids in card headers // that are already links, and nothing else in this service renders stored prose. // Building the index opens a store per database, so it is asked for by the one // rendering that needs it and never as part of the envelope. // // Which databases may enter the index is linkableDatabases' answer and not this // function's. What that leaves here: a database this caller may not browse never // enters it, so an id belonging to it renders as plain text — indistinguishable // from one whose prefix matches nothing. That indistinguishability is the point: // a tooltip, a class or an "unknown tracker" marker would each publish the // existence of a database this caller is not allowed to know about. func (a *app) beadCrossLinks(r *http.Request, view View, repo *core.Repo, data any) *beadLinks { bv, ok := view.(*beadsView) if !ok { return nil } d, ok := data.(*beads.Data) if !ok || (d.Mode != beadsModeDetail && d.Mode != beadsModeEpic) { return nil } dbs, open, ok := a.linkableDatabases(r, repo) if !ok { return nil } index := beads.PrefixesAcross(r.Context(), dbs, open, &bv.prefixes, timeNow()) // A store that cannot be read is a fact about this deployment and belongs in // the log with its error. The page says nothing at all: the ids that database // owns simply stay text. for _, f := range index.Failed { slog.Warn("reading a database for the issue link index failed", "component", "web", "database", f.Database.Slug(), scribe.Err(f.Err)) } return &beadLinks{index: index} } // linkableDatabases is the set of databases a cross-database link index may be // built over for this request, with the opener that reaches them. Both the issue // links and the memory links ask for it, and they must ask the same way: the // index decides what a reader is shown, so the rule for what may enter it lives // in one place rather than in each caller. // // The rule is /ready's. Enumerate with ListReposForViewer (the listing rule), // then ask core.Allowed/OpBrowse per database (the access rule), and do both // before a single store is opened. // // The false return is a listing that failed, and it costs the page its links and // not the page: this is decoration on top of an answer, exactly as the freshness // line is, and it may never be the reason a reader gets a 500. func (a *app) linkableDatabases( r *http.Request, repo *core.Repo, ) ([]beads.ReadyDatabase, beads.ReadyOpener, bool) { _, caller := callerOf(r.Context()) repos, err := a.cfg.Repos.ListReposForViewer(r.Context(), caller) if err != nil { slog.Error("listing databases for the cross-database link index failed", "component", "web", scribe.Err(err)) return nil, nil, false } // The disk path never reaches beads: it is this service's arrangement of its // own storage. The opener closes over the map, so a database that was filtered // out here has no path to be opened by. paths := make(map[int]string, len(repos)) dbs := make([]beads.ReadyDatabase, 0, len(repos)) for _, cand := range repos { if !core.Allowed(caller, cand, a.effectiveACL(r, caller, cand), core.OpBrowse) { continue } paths[cand.ID] = cand.Path entry := beads.ReadyDatabase{ID: cand.ID, OwnerName: cand.OwnerName, Name: cand.Name} if cand.ID == repo.ID { // The database whose page this is goes first. It is the one the ceiling // may never drop — its own ids have always linked and must keep // linking — and it is what makes a [[slug]] this tracker holds resolve // here rather than in whichever other tracker also carries a copy. dbs = append([]beads.ReadyDatabase{entry}, dbs...) continue } dbs = append(dbs, entry) } open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) { path, ok := paths[d.ID] if !ok { return nil, fmt.Errorf("web: no store path for database %s", d.Slug()) } return a.cfg.Browse.Open(ctx, path) } return dbs, open, true }