| 1 |
|
package web |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"fmt" |
| 6 |
|
"html/template" |
| 7 |
|
"log/slog" |
| 8 |
|
"net/http" |
| 9 |
|
"net/url" |
| 10 |
|
"strings" |
| 11 |
|
|
| 12 |
|
"go.bigb.es/auxilia/scribe" |
| 13 |
|
|
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/beads" |
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/browse" |
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/core" |
| 17 |
|
) |
| 18 |
|
|
| 19 |
|
// beadsView renders a "beads" (bd) issue database as a Mardi Gras parade board: |
| 20 |
|
// four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a |
| 21 |
|
// per-issue detail pane reachable via ?issue=<id>. ?layout=stream draws the same |
| 22 |
|
// filtered set as one column of sections instead — a layout of this view and not |
| 23 |
|
// a second one, which is why it is a query parameter and not another tab. All |
| 24 |
|
// data is read through the BrowseSession surface (Rows/Tables) — there is no SQL |
| 25 |
|
// engine behind it. |
| 26 |
|
// |
| 27 |
|
// The reading itself is not here: the fingerprint, the lane bucketing, the ready |
| 28 |
|
// rule and the whole view model live in the beads package, which the MCP surface |
| 29 |
|
// shares. This type is only the View adapter — slug, label, template, and the |
| 30 |
|
// hand-off of the request's ref and query. |
| 31 |
|
type beadsView struct { |
| 32 |
|
// prefixes is the cross-database link index's cache: one issue prefix per |
| 33 |
|
// database, gated on that database's head hash and expiring on |
| 34 |
|
// beads.ReadyCacheTTL — /ready's bounds, shared rather than restated (see |
| 35 |
|
// beads/cache.go). |
| 36 |
|
// |
| 37 |
|
// It lives on the view because the view is the one piece of per-process state |
| 38 |
|
// this rendering has, and because a zero value is a working cache: views are |
| 39 |
|
// registered as a bare &beadsView{}, here and in every test. |
| 40 |
|
// |
| 41 |
|
// The cache is keyed on the database and never on the caller, which is safe |
| 42 |
|
// precisely because what it holds is a database's own prefix — a fact about |
| 43 |
|
// the store, not about who may see it. Who may see it is decided per request, |
| 44 |
|
// before a store is opened, and the index built from it belongs to that |
| 45 |
|
// request alone. |
| 46 |
|
prefixes beads.PrefixCache |
| 47 |
|
} |
| 48 |
|
|
| 49 |
|
// The two Data.Mode values that render one issue rather than a board. They are |
| 50 |
|
// what decides whether this request pays for the cross-database index at all. |
| 51 |
|
const ( |
| 52 |
|
beadsModeDetail = "detail" |
| 53 |
|
beadsModeEpic = "epic" |
| 54 |
|
) |
| 55 |
|
|
| 56 |
177 |
func (*beadsView) Name() string { return "beads" } |
| 57 |
101 |
func (*beadsView) Label() string { return "Beads" } |
| 58 |
43 |
func (*beadsView) Template() string { return "beads.html" } |
| 59 |
|
|
| 60 |
|
// Applies fingerprints a beads DB; see beads.Applies for the rule. |
| 61 |
106 |
func (*beadsView) Applies(tables []browse.TableInfo) bool { return beads.Applies(tables) } |
| 62 |
|
|
| 63 |
|
// Build reads the issue graph and produces either the board or, when ?issue= |
| 64 |
|
// names an issue, that issue's detail pane. The result is a *beads.Data, |
| 65 |
|
// handed to beads.html as its .Data. |
| 66 |
43 |
func (*beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) { |
| 67 |
43 |
data, err := beads.Build(ctx, sess, ref, query) |
| 68 |
43 |
if err != nil { |
| 69 |
0 |
return nil, err |
| 70 |
0 |
} |
| 71 |
43 |
return data, nil |
| 72 |
|
} |
| 73 |
|
|
| 74 |
|
// --- cross-database issue links ----------------------------------------------- |
| 75 |
|
|
| 76 |
|
// beadLinks renders one stored text with the issue ids in it linked to the |
| 77 |
|
// databases that own them. It is the envelope's .Links on the issue detail pane |
| 78 |
|
// and nil everywhere else, and a nil one renders the text with no links at all — |
| 79 |
|
// which is a whole answer, not a degraded one: an id nobody here owns is text. |
| 80 |
|
type beadLinks struct { |
| 81 |
|
index *beads.PrefixIndex |
| 82 |
|
} |
| 83 |
|
|
| 84 |
|
// Text renders a stored text — a description, a comment body, an event summary — |
| 85 |
|
// as HTML with every recognised issue id wrapped in a link. |
| 86 |
|
// |
| 87 |
|
// It escapes first and wraps second. The result is built as a sequence of |
| 88 |
|
// escaped segments and anchors this function generated itself, and only the |
| 89 |
|
// finished whole is marked template.HTML: marking user-stored text as HTML and |
| 90 |
|
// then running a regexp over it is how a stored payload becomes a rendered one. |
| 91 |
|
// Nothing that came out of the database is ever handed to the browser unescaped, |
| 92 |
|
// including the id inside the anchor and the href built from it. |
| 93 |
111 |
func (l *beadLinks) Text(s string) template.HTML { |
| 94 |
111 |
var refs []beads.Reference |
| 95 |
111 |
if l != nil { |
| 96 |
103 |
refs = l.index.Scan(s) |
| 97 |
103 |
} |
| 98 |
111 |
var b strings.Builder |
| 99 |
111 |
b.Grow(len(s)) |
| 100 |
111 |
last := 0 |
| 101 |
111 |
for _, ref := range refs { |
| 102 |
57 |
b.WriteString(template.HTMLEscapeString(s[last:ref.Start])) |
| 103 |
57 |
b.WriteString(`<a href="`) |
| 104 |
57 |
b.WriteString(template.HTMLEscapeString( |
| 105 |
57 |
beadIssueHref(ref.Database.OwnerName, ref.Database.Name, ref.ID))) |
| 106 |
57 |
b.WriteString(`">`) |
| 107 |
57 |
b.WriteString(template.HTMLEscapeString(ref.ID)) |
| 108 |
57 |
b.WriteString(`</a>`) |
| 109 |
57 |
last = ref.End |
| 110 |
57 |
} |
| 111 |
111 |
b.WriteString(template.HTMLEscapeString(s[last:])) |
| 112 |
111 |
return template.HTML(b.String()) |
| 113 |
|
} |
| 114 |
|
|
| 115 |
|
// beadIssueHref is the detail-pane URL of one issue in one database: the same |
| 116 |
|
// address beads.html writes for a dependency edge, built here because this one |
| 117 |
|
// is assembled in Go rather than by the template. |
| 118 |
65 |
func beadIssueHref(owner, name, id string) string { |
| 119 |
65 |
return "/~" + url.PathEscape(owner) + "/" + url.PathEscape(name) + |
| 120 |
65 |
"/view/beads?issue=" + url.QueryEscape(id) |
| 121 |
65 |
} |
| 122 |
|
|
| 123 |
|
// beadCrossLinks builds the link index for one render, and returns nil for every |
| 124 |
|
// page that is not an issue detail pane: the board carries ids in card headers |
| 125 |
|
// that are already links, and nothing else in this service renders stored prose. |
| 126 |
|
// Building the index opens a store per database, so it is asked for by the one |
| 127 |
|
// rendering that needs it and never as part of the envelope. |
| 128 |
|
// |
| 129 |
|
// Which databases may enter the index is linkableDatabases' answer and not this |
| 130 |
|
// function's. What that leaves here: a database this caller may not browse never |
| 131 |
|
// enters it, so an id belonging to it renders as plain text — indistinguishable |
| 132 |
|
// from one whose prefix matches nothing. That indistinguishability is the point: |
| 133 |
|
// a tooltip, a class or an "unknown tracker" marker would each publish the |
| 134 |
|
// existence of a database this caller is not allowed to know about. |
| 135 |
65 |
func (a *app) beadCrossLinks(r *http.Request, view View, repo *core.Repo, data any) *beadLinks { |
| 136 |
65 |
bv, ok := view.(*beadsView) |
| 137 |
65 |
if !ok { |
| 138 |
22 |
return nil |
| 139 |
22 |
} |
| 140 |
43 |
d, ok := data.(*beads.Data) |
| 141 |
43 |
if !ok || (d.Mode != beadsModeDetail && d.Mode != beadsModeEpic) { |
| 142 |
17 |
return nil |
| 143 |
17 |
} |
| 144 |
|
|
| 145 |
26 |
dbs, open, ok := a.linkableDatabases(r, repo) |
| 146 |
26 |
if !ok { |
| 147 |
1 |
return nil |
| 148 |
1 |
} |
| 149 |
|
|
| 150 |
25 |
index := beads.PrefixesAcross(r.Context(), dbs, open, &bv.prefixes, timeNow()) |
| 151 |
25 |
|
| 152 |
25 |
// A store that cannot be read is a fact about this deployment and belongs in |
| 153 |
25 |
// the log with its error. The page says nothing at all: the ids that database |
| 154 |
25 |
// owns simply stay text. |
| 155 |
25 |
for _, f := range index.Failed { |
| 156 |
0 |
slog.Warn("reading a database for the issue link index failed", |
| 157 |
0 |
"component", "web", "database", f.Database.Slug(), scribe.Err(f.Err)) |
| 158 |
0 |
} |
| 159 |
25 |
return &beadLinks{index: index} |
| 160 |
|
} |
| 161 |
|
|
| 162 |
|
// linkableDatabases is the set of databases a cross-database link index may be |
| 163 |
|
// built over for this request, with the opener that reaches them. Both the issue |
| 164 |
|
// links and the memory links ask for it, and they must ask the same way: the |
| 165 |
|
// index decides what a reader is shown, so the rule for what may enter it lives |
| 166 |
|
// in one place rather than in each caller. |
| 167 |
|
// |
| 168 |
|
// The rule is /ready's. Enumerate with ListReposForViewer (the listing rule), |
| 169 |
|
// then ask core.Allowed/OpBrowse per database (the access rule), and do both |
| 170 |
|
// before a single store is opened. |
| 171 |
|
// |
| 172 |
|
// The false return is a listing that failed, and it costs the page its links and |
| 173 |
|
// not the page: this is decoration on top of an answer, exactly as the freshness |
| 174 |
|
// line is, and it may never be the reason a reader gets a 500. |
| 175 |
|
func (a *app) linkableDatabases( |
| 176 |
|
r *http.Request, |
| 177 |
|
repo *core.Repo, |
| 178 |
41 |
) ([]beads.ReadyDatabase, beads.ReadyOpener, bool) { |
| 179 |
41 |
_, caller := callerOf(r.Context()) |
| 180 |
41 |
repos, err := a.cfg.Repos.ListReposForViewer(r.Context(), caller) |
| 181 |
41 |
if err != nil { |
| 182 |
2 |
slog.Error("listing databases for the cross-database link index failed", |
| 183 |
2 |
"component", "web", scribe.Err(err)) |
| 184 |
2 |
return nil, nil, false |
| 185 |
2 |
} |
| 186 |
|
|
| 187 |
|
// The disk path never reaches beads: it is this service's arrangement of its |
| 188 |
|
// own storage. The opener closes over the map, so a database that was filtered |
| 189 |
|
// out here has no path to be opened by. |
| 190 |
39 |
paths := make(map[int]string, len(repos)) |
| 191 |
39 |
dbs := make([]beads.ReadyDatabase, 0, len(repos)) |
| 192 |
52 |
for _, cand := range repos { |
| 193 |
52 |
if !core.Allowed(caller, cand, a.effectiveACL(r, caller, cand), core.OpBrowse) { |
| 194 |
0 |
continue |
| 195 |
|
} |
| 196 |
52 |
paths[cand.ID] = cand.Path |
| 197 |
52 |
entry := beads.ReadyDatabase{ID: cand.ID, OwnerName: cand.OwnerName, Name: cand.Name} |
| 198 |
52 |
if cand.ID == repo.ID { |
| 199 |
39 |
// The database whose page this is goes first. It is the one the ceiling |
| 200 |
39 |
// may never drop — its own ids have always linked and must keep |
| 201 |
39 |
// linking — and it is what makes a [[slug]] this tracker holds resolve |
| 202 |
39 |
// here rather than in whichever other tracker also carries a copy. |
| 203 |
39 |
dbs = append([]beads.ReadyDatabase{entry}, dbs...) |
| 204 |
39 |
continue |
| 205 |
|
} |
| 206 |
13 |
dbs = append(dbs, entry) |
| 207 |
|
} |
| 208 |
|
|
| 209 |
52 |
open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) { |
| 210 |
52 |
path, ok := paths[d.ID] |
| 211 |
52 |
if !ok { |
| 212 |
0 |
return nil, fmt.Errorf("web: no store path for database %s", d.Slug()) |
| 213 |
0 |
} |
| 214 |
52 |
return a.cfg.Browse.Open(ctx, path) |
| 215 |
|
} |
| 216 |
39 |
return dbs, open, true |
| 217 |
|
} |