| 1 |
|
package web |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"encoding/json" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"html/template" |
| 8 |
|
"log/slog" |
| 9 |
|
"net/http" |
| 10 |
|
"strings" |
| 11 |
|
|
| 12 |
|
"github.com/go-chi/chi/v5" |
| 13 |
|
"go.bigb.es/auxilia/scribe" |
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome" |
| 15 |
|
|
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 17 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 18 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/doc" |
| 19 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/search" |
| 20 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 21 |
|
) |
| 22 |
|
|
| 23 |
|
// searchLimit bounds one page of results. |
| 24 |
|
const searchLimit = 25 |
| 25 |
|
|
| 26 |
|
// ---- format selectors ----------------------------------------------------- |
| 27 |
|
|
| 28 |
|
// format is which representation of a document was asked for. The extension in |
| 29 |
|
// the URL selects it; the document's own address carries none. |
| 30 |
|
type format int |
| 31 |
|
|
| 32 |
|
const ( |
| 33 |
|
formatHTML format = iota |
| 34 |
|
formatRaw |
| 35 |
|
formatJSON |
| 36 |
|
) |
| 37 |
|
|
| 38 |
|
// splitFormat peels a format selector off the tail of a document address. |
| 39 |
|
// |
| 40 |
|
// The design pins this: ".md" is the raw source and ".json" is metadata plus |
| 41 |
|
// body, so neither is ever part of the address that identifies the document. |
| 42 |
|
// The remainder is the document's address — its tree path minus core.DocExt. |
| 43 |
44 |
func splitFormat(rest string) (string, format) { |
| 44 |
44 |
switch { |
| 45 |
11 |
case strings.HasSuffix(rest, ".json"): |
| 46 |
11 |
return strings.TrimSuffix(rest, ".json"), formatJSON |
| 47 |
13 |
case strings.HasSuffix(rest, core.DocExt): |
| 48 |
13 |
return strings.TrimSuffix(rest, core.DocExt), formatRaw |
| 49 |
20 |
default: |
| 50 |
20 |
return rest, formatHTML |
| 51 |
|
} |
| 52 |
|
} |
| 53 |
|
|
| 54 |
|
// ---- authorization -------------------------------------------------------- |
| 55 |
|
|
| 56 |
|
// mayRead reports whether a request carries authority to read content. The ACL |
| 57 |
|
// itself — owner and its agents, nobody else — is authn.Principal.CanRead, the |
| 58 |
|
// one spelling every read surface shares. |
| 59 |
99 |
func mayRead(r *http.Request) bool { |
| 60 |
99 |
return authn.PrincipalFromContext(r.Context()).CanRead() |
| 61 |
99 |
} |
| 62 |
|
|
| 63 |
|
// readGrant reports whether the credential behind this request was minted for |
| 64 |
|
// reading. It is a no-op for the owner's cookie and for spec's own agent token, |
| 65 |
|
// neither of which carries grants; it refuses a tokens.sr.ht working token |
| 66 |
|
// without spec:read. |
| 67 |
|
// |
| 68 |
|
// Kept apart from mayRead because the two questions have different answers when |
| 69 |
|
// they fail: "who are you" ends in a login, "what may this token do" does not. |
| 70 |
84 |
func readGrant(r *http.Request) error { |
| 71 |
84 |
return authn.PrincipalFromContext(r.Context()).Authorize(authn.ActionRead) |
| 72 |
84 |
} |
| 73 |
|
|
| 74 |
|
// allowRead is the whole read gate: identity, then grant. It answers the request |
| 75 |
|
// itself when either refuses and reports whether the handler may go on. |
| 76 |
81 |
func (s *Server) allowRead(w http.ResponseWriter, r *http.Request, f format) bool { |
| 77 |
81 |
if !mayRead(r) { |
| 78 |
9 |
s.denyRead(w, r, f) |
| 79 |
9 |
return false |
| 80 |
9 |
} |
| 81 |
72 |
if err := readGrant(r); err != nil { |
| 82 |
6 |
s.denyGrant(w, r, f) |
| 83 |
6 |
return false |
| 84 |
6 |
} |
| 85 |
66 |
return true |
| 86 |
|
} |
| 87 |
|
|
| 88 |
|
// denyRead answers a viewer with no read authority in the shape their client |
| 89 |
|
// can act on: a browser is sent to meta's login, a machine asking for .md or |
| 90 |
|
// .json gets a 401. Redirecting a bot to an HTML login page would hand it a |
| 91 |
|
// 200 full of markup it cannot use. |
| 92 |
9 |
func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, f format) { |
| 93 |
9 |
if f == formatHTML { |
| 94 |
6 |
s.loginRedirect(w, r) |
| 95 |
6 |
return |
| 96 |
6 |
} |
| 97 |
3 |
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 98 |
3 |
// The challenge belongs on the machine shape and not on the browser one: |
| 99 |
3 |
// this is the branch a client asking for .md or .json takes, and RFC 9110 |
| 100 |
3 |
// asks a 401 to name the scheme it would accept. A browser never reaches |
| 101 |
3 |
// here — it gets the redirect above, because the session is a cookie |
| 102 |
3 |
// meta.sr.ht sets and there is no HTTP authentication scheme for it. |
| 103 |
3 |
w.Header().Set("WWW-Authenticate", authn.Challenge()) |
| 104 |
3 |
http.Error(w, "authentication required", http.StatusUnauthorized) |
| 105 |
|
} |
| 106 |
|
|
| 107 |
|
// denyGrant answers a caller whose credential is good but was not minted for |
| 108 |
|
// reading. 403 in both shapes, and pointedly no login redirect: the caller is |
| 109 |
|
// already authenticated, so sending them to meta would loop them back here with |
| 110 |
|
// the same token and the same answer. |
| 111 |
6 |
func (s *Server) denyGrant(w http.ResponseWriter, r *http.Request, f format) { |
| 112 |
6 |
msg := "this token does not grant " + authn.ActionRead |
| 113 |
6 |
if f == formatHTML { |
| 114 |
4 |
s.renderError(w, r, http.StatusForbidden, msg) |
| 115 |
4 |
return |
| 116 |
4 |
} |
| 117 |
2 |
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 118 |
2 |
http.Error(w, msg, http.StatusForbidden) |
| 119 |
|
} |
| 120 |
|
|
| 121 |
|
// ---- error mapping -------------------------------------------------------- |
| 122 |
|
|
| 123 |
|
// httpStatusFor maps a service error onto a status. service.ErrNotFound already |
| 124 |
|
// folds "malformed revision" into "absent", so probing cannot tell them apart. |
| 125 |
9 |
func httpStatusFor(err error) int { |
| 126 |
9 |
switch { |
| 127 |
6 |
case errors.Is(err, service.ErrNotFound): |
| 128 |
6 |
return http.StatusNotFound |
| 129 |
0 |
case errors.Is(err, core.ErrInvalidName), errors.Is(err, core.ErrInvalidPath): |
| 130 |
0 |
return http.StatusBadRequest |
| 131 |
|
// The write-plane sentinels the review page's approve/reject can return. A |
| 132 |
|
// base that moved under a proposal, a proposal already resolved, and an |
| 133 |
|
// already-merged one are all 409; the owner-only refusal is 403. |
| 134 |
2 |
case errors.Is(err, service.ErrForbidden): |
| 135 |
2 |
return http.StatusForbidden |
| 136 |
|
case errors.Is(err, service.ErrStale), |
| 137 |
|
errors.Is(err, service.ErrAlreadyMerged), |
| 138 |
1 |
errors.Is(err, service.ErrProposalNotOpen): |
| 139 |
1 |
return http.StatusConflict |
| 140 |
0 |
case errors.Is(err, service.ErrInvalid): |
| 141 |
0 |
return http.StatusUnprocessableEntity |
| 142 |
0 |
default: |
| 143 |
0 |
return http.StatusInternalServerError |
| 144 |
|
} |
| 145 |
|
} |
| 146 |
|
|
| 147 |
|
// fail renders the chrome error page for err, logging 5xx causes and telling |
| 148 |
|
// the viewer nothing about them. |
| 149 |
|
// |
| 150 |
|
// The 5xx message is left empty so the page takes ecore's shared sentence for |
| 151 |
|
// the status: a bug here and a panic recovered by the router are the same event |
| 152 |
|
// to a viewer, and two house phrases for it would only say that they came out |
| 153 |
|
// of different code. Below 500 the error's own text is the message — those name |
| 154 |
|
// a revision that does not parse or a document that is not there, which is what |
| 155 |
|
// the viewer needs. |
| 156 |
7 |
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) { |
| 157 |
7 |
status := httpStatusFor(err) |
| 158 |
7 |
if status >= 500 { |
| 159 |
0 |
slog.ErrorContext(r.Context(), "the read plane could not answer a request", |
| 160 |
0 |
"method", r.Method, "path", r.URL.Path, "status", status, scribe.Err(err)) |
| 161 |
0 |
s.renderError(w, r, status, "") |
| 162 |
0 |
return |
| 163 |
0 |
} |
| 164 |
7 |
s.renderError(w, r, status, err.Error()) |
| 165 |
|
} |
| 166 |
|
|
| 167 |
|
// failFormat is fail for a request that asked for .md or .json: those callers |
| 168 |
|
// are machines, so they get a status and a line of text rather than a page. |
| 169 |
5 |
func (s *Server) failFormat(w http.ResponseWriter, r *http.Request, f format, err error) { |
| 170 |
5 |
if f == formatHTML { |
| 171 |
3 |
s.fail(w, r, err) |
| 172 |
3 |
return |
| 173 |
3 |
} |
| 174 |
2 |
status := httpStatusFor(err) |
| 175 |
2 |
if status >= 500 { |
| 176 |
0 |
slog.ErrorContext(r.Context(), "the read plane could not answer a machine request", |
| 177 |
0 |
"method", r.Method, "path", r.URL.Path, "status", status, scribe.Err(err)) |
| 178 |
0 |
http.Error(w, "internal server error", status) |
| 179 |
0 |
return |
| 180 |
0 |
} |
| 181 |
2 |
http.Error(w, err.Error(), status) |
| 182 |
|
} |
| 183 |
|
|
| 184 |
|
// ---- landing -------------------------------------------------------------- |
| 185 |
|
|
| 186 |
|
// emptySpaces is what the landing page says when the owner has no spaces yet. |
| 187 |
|
// It is a sentence and not "nothing here" because the remedy is not obvious: |
| 188 |
|
// a space is created by pushing to it, not by a button on this page. |
| 189 |
|
const emptySpaces = "No spaces yet. A space is a bare git repository owned by this service; " + |
| 190 |
|
"it appears here once it has been created and pushed to." |
| 191 |
|
|
| 192 |
|
type indexData struct { |
| 193 |
|
LoggedIn bool |
| 194 |
|
|
| 195 |
|
// Spaces is rendered by ecore's "srht-repo-list" partial, which is the |
| 196 |
|
// listing markup every service on this instance converged on. A space has no |
| 197 |
|
// visibility to show — this service has exactly one reader — so only the |
| 198 |
|
// title and the href are filled in. |
| 199 |
|
Spaces chrome.RepoList |
| 200 |
|
} |
| 201 |
|
|
| 202 |
|
// handleIndex is the landing page: the spaces you can read. |
| 203 |
|
// |
| 204 |
|
// It renders for an anonymous viewer too, because the chrome's login link has |
| 205 |
|
// to live somewhere reachable — but it lists nothing, so no space name leaks. |
| 206 |
5 |
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { |
| 207 |
5 |
vd := s.view(r, "") |
| 208 |
5 |
// The title is built from the chrome's own fields rather than from a second |
| 209 |
5 |
// read of [sr.ht]site-name: the tab and the brand must name the same site. |
| 210 |
5 |
vd.Title = vd.SiteName + " " + vd.SiteLabel |
| 211 |
5 |
|
| 212 |
5 |
data := indexData{LoggedIn: mayRead(r), Spaces: chrome.RepoList{Empty: emptySpaces}} |
| 213 |
5 |
if data.LoggedIn { |
| 214 |
3 |
refs, err := s.reader.ListSpaces(r.Context()) |
| 215 |
3 |
if err != nil { |
| 216 |
0 |
s.fail(w, r, err) |
| 217 |
0 |
return |
| 218 |
0 |
} |
| 219 |
3 |
for _, ref := range refs { |
| 220 |
3 |
data.Spaces.Items = append(data.Spaces.Items, chrome.ListItem{ |
| 221 |
3 |
Href: "/" + ref.String(), |
| 222 |
3 |
Title: ref.String(), |
| 223 |
3 |
}) |
| 224 |
3 |
} |
| 225 |
|
} |
| 226 |
5 |
vd.Data = data |
| 227 |
5 |
if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil { |
| 228 |
0 |
slog.ErrorContext(r.Context(), "rendering a page failed after it was answered", |
| 229 |
0 |
"page", "index", "path", r.URL.Path, scribe.Err(err)) |
| 230 |
0 |
} |
| 231 |
|
} |
| 232 |
|
|
| 233 |
|
// ---- space ---------------------------------------------------------------- |
| 234 |
|
|
| 235 |
|
// treeItem is one row of the space's document tree, flattened to a depth so the |
| 236 |
|
// template needs no recursion. |
| 237 |
|
type treeItem struct { |
| 238 |
|
Depth int |
| 239 |
|
Title string |
| 240 |
|
Href string |
| 241 |
|
ID string |
| 242 |
|
DocID string |
| 243 |
|
Status string |
| 244 |
|
Summary string |
| 245 |
|
Section string |
| 246 |
|
} |
| 247 |
|
|
| 248 |
|
type spaceData struct { |
| 249 |
|
Ref string |
| 250 |
|
Rev string |
| 251 |
|
Pinned bool |
| 252 |
|
RevQuery string |
| 253 |
|
Count int |
| 254 |
|
Items []treeItem |
| 255 |
|
} |
| 256 |
|
|
| 257 |
10 |
func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) { |
| 258 |
10 |
if !s.allowRead(w, r, formatHTML) { |
| 259 |
2 |
return |
| 260 |
2 |
} |
| 261 |
8 |
ref, err := spaceRefFrom(r) |
| 262 |
8 |
if err != nil { |
| 263 |
0 |
s.fail(w, r, err) |
| 264 |
0 |
return |
| 265 |
0 |
} |
| 266 |
8 |
rev := r.URL.Query().Get("rev") |
| 267 |
8 |
snap, err := s.reader.Snapshot(r.Context(), ref, rev) |
| 268 |
8 |
if err != nil { |
| 269 |
1 |
s.fail(w, r, err) |
| 270 |
1 |
return |
| 271 |
1 |
} |
| 272 |
|
|
| 273 |
7 |
vd := s.view(r, ref.String()) |
| 274 |
7 |
vd.Data = spaceData{ |
| 275 |
7 |
Ref: ref.String(), |
| 276 |
7 |
Rev: snap.Rev, |
| 277 |
7 |
Pinned: rev != service.ApprovedRev, |
| 278 |
7 |
RevQuery: revQuery(rev), |
| 279 |
7 |
Count: len(snap.Archive.All()), |
| 280 |
7 |
Items: flattenTree(snap, revQuery(rev)), |
| 281 |
7 |
} |
| 282 |
7 |
if err := s.pages.Render(w, http.StatusOK, "space", vd); err != nil { |
| 283 |
0 |
slog.ErrorContext(r.Context(), "rendering a page failed after it was answered", |
| 284 |
0 |
"page", "space", "path", r.URL.Path, scribe.Err(err)) |
| 285 |
0 |
} |
| 286 |
|
} |
| 287 |
|
|
| 288 |
|
// flattenTree walks the archive's `parent:` hierarchy into an ordered, depth- |
| 289 |
|
// tagged list. A space where nobody sets `parent:` degrades to a flat list in |
| 290 |
|
// path order, which is the common case and reads fine. |
| 291 |
7 |
func flattenTree(snap *Snapshot, rq string) []treeItem { |
| 292 |
7 |
var out []treeItem |
| 293 |
7 |
seen := make(map[string]bool, len(snap.Archive.All())) |
| 294 |
7 |
|
| 295 |
7 |
var walk func(pages []*doc.Page, depth int) |
| 296 |
26 |
walk = func(pages []*doc.Page, depth int) { |
| 297 |
26 |
for _, p := range pages { |
| 298 |
19 |
if seen[p.ID] { |
| 299 |
0 |
continue // a `parent:` cycle must not hang the page |
| 300 |
|
} |
| 301 |
19 |
seen[p.ID] = true |
| 302 |
19 |
out = append(out, treeItem{ |
| 303 |
19 |
Depth: depth, |
| 304 |
19 |
Title: p.Title, |
| 305 |
19 |
Href: snap.Archive.DocHref(p) + rq, |
| 306 |
19 |
ID: p.ID, |
| 307 |
19 |
DocID: p.DocID, |
| 308 |
19 |
Status: string(p.Status), |
| 309 |
19 |
Summary: p.Summary, |
| 310 |
19 |
Section: p.Section, |
| 311 |
19 |
}) |
| 312 |
19 |
walk(snap.Archive.Children(p.ID), depth+1) |
| 313 |
|
} |
| 314 |
|
} |
| 315 |
7 |
walk(snap.Archive.Roots(), 0) |
| 316 |
7 |
|
| 317 |
7 |
// Anything a cycle kept out of the walk is still a document of this space |
| 318 |
7 |
// and must still be listed; dropping it would make the tree quietly lie |
| 319 |
7 |
// about what the revision contains. |
| 320 |
19 |
for _, p := range snap.Archive.All() { |
| 321 |
19 |
if !seen[p.ID] { |
| 322 |
0 |
out = append(out, treeItem{ |
| 323 |
0 |
Title: p.Title, |
| 324 |
0 |
Href: snap.Archive.DocHref(p) + rq, |
| 325 |
0 |
ID: p.ID, |
| 326 |
0 |
DocID: p.DocID, |
| 327 |
0 |
Status: string(p.Status), |
| 328 |
0 |
Summary: p.Summary, |
| 329 |
0 |
Section: p.Section, |
| 330 |
0 |
}) |
| 331 |
0 |
} |
| 332 |
|
} |
| 333 |
7 |
return out |
| 334 |
|
} |
| 335 |
|
|
| 336 |
|
// ---- document ------------------------------------------------------------- |
| 337 |
|
|
| 338 |
|
type docLink struct { |
| 339 |
|
Title string |
| 340 |
|
Href string |
| 341 |
|
} |
| 342 |
|
|
| 343 |
|
type docData struct { |
| 344 |
|
SpaceRef string |
| 345 |
|
SpaceHref string |
| 346 |
|
Path string |
| 347 |
|
Address string |
| 348 |
|
ID string |
| 349 |
|
DocID string |
| 350 |
|
Title string |
| 351 |
|
Status string |
| 352 |
|
Summary string |
| 353 |
|
Type string |
| 354 |
|
Tags []string |
| 355 |
|
Owners []string |
| 356 |
|
Props []doc.DocProperty |
| 357 |
|
Rev string |
| 358 |
|
Blob string |
| 359 |
|
Pinned bool |
| 360 |
|
RevQuery string |
| 361 |
|
Body template.HTML |
| 362 |
|
Headings []doc.Heading |
| 363 |
|
Children []docLink |
| 364 |
|
Backlinks []docLink |
| 365 |
|
Missing []string |
| 366 |
|
WordCount int |
| 367 |
|
RawHref string |
| 368 |
|
JSONHref string |
| 369 |
|
} |
| 370 |
|
|
| 371 |
|
// docJSON is the .json representation: metadata plus body. |
| 372 |
|
type docJSON struct { |
| 373 |
|
Space string `json:"space"` |
| 374 |
|
Path string `json:"path"` |
| 375 |
|
Address string `json:"address"` |
| 376 |
|
ID string `json:"id"` |
| 377 |
|
DocID string `json:"doc_id,omitempty"` |
| 378 |
|
Rev string `json:"rev"` |
| 379 |
|
Blob string `json:"blob"` |
| 380 |
|
Kind string `json:"kind,omitempty"` |
| 381 |
|
Title string `json:"title"` |
| 382 |
|
Status string `json:"status,omitempty"` |
| 383 |
|
Summary string `json:"summary,omitempty"` |
| 384 |
|
Type string `json:"type,omitempty"` |
| 385 |
|
Section string `json:"section,omitempty"` |
| 386 |
|
Tags []string `json:"tags,omitempty"` |
| 387 |
|
Owners []string `json:"owners,omitempty"` |
| 388 |
|
Supersedes string `json:"supersedes,omitempty"` |
| 389 |
|
Props []doc.DocProperty `json:"props,omitempty"` |
| 390 |
|
Body string `json:"body"` |
| 391 |
|
} |
| 392 |
|
|
| 393 |
|
// handleDocument serves all three representations of one document. |
| 394 |
|
// |
| 395 |
|
// The three share a single route because they are one resource: the extension |
| 396 |
|
// selects a format and the remainder is the address. Splitting them into three |
| 397 |
|
// routes would let the address grammar drift apart between them, which is the |
| 398 |
|
// exact confusion the pinned grammar exists to prevent. |
| 399 |
39 |
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { |
| 400 |
39 |
rest, ok := unescapePath(chi.URLParam(r, "*")) |
| 401 |
39 |
if !ok { |
| 402 |
0 |
s.renderError(w, r, http.StatusBadRequest, "malformed path") |
| 403 |
0 |
return |
| 404 |
0 |
} |
| 405 |
39 |
address, f := splitFormat(rest) |
| 406 |
39 |
|
| 407 |
39 |
if !s.allowRead(w, r, f) { |
| 408 |
8 |
return |
| 409 |
8 |
} |
| 410 |
31 |
ref, err := spaceRefFrom(r) |
| 411 |
31 |
if err != nil { |
| 412 |
0 |
s.failFormat(w, r, f, err) |
| 413 |
0 |
return |
| 414 |
0 |
} |
| 415 |
31 |
rev := r.URL.Query().Get("rev") |
| 416 |
31 |
rq := revQuery(rev) |
| 417 |
31 |
|
| 418 |
31 |
// "/~owner/space/" is the space, spelled with a trailing slash. |
| 419 |
31 |
if address == "" { |
| 420 |
1 |
http.Redirect(w, r, "/"+ref.String()+rq, http.StatusFound) |
| 421 |
1 |
return |
| 422 |
1 |
} |
| 423 |
|
|
| 424 |
30 |
docPath := address + core.DocExt |
| 425 |
30 |
if err := core.ValidateDocPath(docPath); err != nil { |
| 426 |
0 |
s.failFormat(w, r, f, err) |
| 427 |
0 |
return |
| 428 |
0 |
} |
| 429 |
|
|
| 430 |
|
// Raw source needs no archive: it is the bytes of one blob, and building a |
| 431 |
|
// whole-revision archive to hand them over would make the cheapest read the |
| 432 |
|
// most expensive one. |
| 433 |
30 |
if f == formatRaw { |
| 434 |
8 |
d, err := s.reader.ReadDocument(r.Context(), ref, rev, docPath) |
| 435 |
8 |
if err != nil { |
| 436 |
1 |
s.failFormat(w, r, f, err) |
| 437 |
1 |
return |
| 438 |
1 |
} |
| 439 |
7 |
w.Header().Set("Content-Type", "text/markdown; charset=utf-8") |
| 440 |
7 |
w.Header().Set("X-Spec-Rev", d.Rev) |
| 441 |
7 |
w.Header().Set("X-Spec-Blob", d.Blob) |
| 442 |
7 |
_, _ = w.Write(d.Data) |
| 443 |
7 |
return |
| 444 |
|
} |
| 445 |
|
|
| 446 |
22 |
snap, err := s.reader.Snapshot(r.Context(), ref, rev) |
| 447 |
22 |
if err != nil { |
| 448 |
1 |
s.failFormat(w, r, f, err) |
| 449 |
1 |
return |
| 450 |
1 |
} |
| 451 |
21 |
page, ok := snap.Archive.ByPath(docPath) |
| 452 |
21 |
if !ok { |
| 453 |
4 |
// The address may be a document id rather than a path. Redirecting |
| 454 |
4 |
// rather than serving keeps one document at one canonical URL — and a |
| 455 |
4 |
// duplicated id resolves to neither document, so this cannot silently |
| 456 |
4 |
// pick a winner. |
| 457 |
4 |
if p, found := snap.Archive.Page(address); found { |
| 458 |
1 |
http.Redirect(w, r, snap.Archive.DocHref(p)+rq, http.StatusFound) |
| 459 |
1 |
return |
| 460 |
1 |
} |
| 461 |
3 |
s.failFormat(w, r, f, fmt.Errorf("%w: %s in %s at %s", |
| 462 |
3 |
service.ErrNotFound, docPath, ref, snap.Rev)) |
| 463 |
3 |
return |
| 464 |
|
} |
| 465 |
|
|
| 466 |
17 |
body, ok := snap.Bodies[docPath] |
| 467 |
17 |
if !ok { |
| 468 |
0 |
s.failFormat(w, r, f, fmt.Errorf("web: %s is in the archive of %s at %s but has no body", |
| 469 |
0 |
docPath, ref, snap.Rev)) |
| 470 |
0 |
return |
| 471 |
0 |
} |
| 472 |
17 |
front, mdBody := doc.ParseFront(body) |
| 473 |
17 |
|
| 474 |
17 |
if f == formatJSON { |
| 475 |
7 |
payload := docJSON{ |
| 476 |
7 |
Space: ref.String(), |
| 477 |
7 |
Path: page.Path, |
| 478 |
7 |
Address: address, |
| 479 |
7 |
ID: page.ID, |
| 480 |
7 |
DocID: page.DocID, |
| 481 |
7 |
Rev: snap.Rev, |
| 482 |
7 |
Blob: page.Blob, |
| 483 |
7 |
Kind: string(page.Kind), |
| 484 |
7 |
Title: page.Title, |
| 485 |
7 |
Status: string(page.Status), |
| 486 |
7 |
Summary: page.Summary, |
| 487 |
7 |
Type: front.Type, |
| 488 |
7 |
Section: page.Section, |
| 489 |
7 |
Tags: page.Tags, |
| 490 |
7 |
Owners: front.Owners, |
| 491 |
7 |
Supersedes: front.Supersedes, |
| 492 |
7 |
Props: front.Props, |
| 493 |
7 |
Body: string(mdBody), |
| 494 |
7 |
} |
| 495 |
7 |
w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 496 |
7 |
enc := json.NewEncoder(w) |
| 497 |
7 |
enc.SetIndent("", " ") |
| 498 |
7 |
if err := enc.Encode(payload); err != nil { |
| 499 |
0 |
slog.ErrorContext(r.Context(), "encoding a document as JSON failed mid-response", |
| 500 |
0 |
"doc", docPath, scribe.Err(err)) |
| 501 |
0 |
} |
| 502 |
7 |
return |
| 503 |
|
} |
| 504 |
|
|
| 505 |
|
// The link graph backlinks are read from is filled in by the reader, at the |
| 506 |
|
// same revision as the archive itself. This handler used to render every |
| 507 |
|
// document of the space here to build it, once per page view. |
| 508 |
10 |
res := s.renderer.Render(mdBody, doc.DirOf(docPath), pinned{inner: snap.Archive, rq: rq}) |
| 509 |
10 |
|
| 510 |
10 |
data := docData{ |
| 511 |
10 |
SpaceRef: ref.String(), |
| 512 |
10 |
SpaceHref: "/" + ref.String() + rq, |
| 513 |
10 |
Path: page.Path, |
| 514 |
10 |
Address: address, |
| 515 |
10 |
ID: page.ID, |
| 516 |
10 |
DocID: page.DocID, |
| 517 |
10 |
Title: page.Title, |
| 518 |
10 |
Status: string(page.Status), |
| 519 |
10 |
Summary: page.Summary, |
| 520 |
10 |
Type: front.Type, |
| 521 |
10 |
Tags: page.Tags, |
| 522 |
10 |
Owners: front.Owners, |
| 523 |
10 |
Props: front.Props, |
| 524 |
10 |
Rev: snap.Rev, |
| 525 |
10 |
Blob: page.Blob, |
| 526 |
10 |
Pinned: rev != service.ApprovedRev, |
| 527 |
10 |
RevQuery: rq, |
| 528 |
10 |
Body: template.HTML(res.HTML), |
| 529 |
10 |
Headings: res.Headings, |
| 530 |
10 |
Missing: res.MissingWikilinks, |
| 531 |
10 |
WordCount: res.WordCount, |
| 532 |
10 |
RawHref: snap.Archive.DocHref(page) + core.DocExt + rq, |
| 533 |
10 |
JSONHref: snap.Archive.DocHref(page) + ".json" + rq, |
| 534 |
10 |
} |
| 535 |
10 |
for _, c := range snap.Archive.Children(page.ID) { |
| 536 |
0 |
data.Children = append(data.Children, docLink{Title: c.Title, Href: snap.Archive.DocHref(c) + rq}) |
| 537 |
0 |
} |
| 538 |
10 |
for _, b := range snap.Archive.Backlinks(page.ID) { |
| 539 |
1 |
data.Backlinks = append(data.Backlinks, docLink{Title: b.Title, Href: snap.Archive.DocHref(b) + rq}) |
| 540 |
1 |
} |
| 541 |
|
|
| 542 |
10 |
vd := s.view(r, page.Title+" — "+ref.String()) |
| 543 |
10 |
vd.Data = data |
| 544 |
10 |
if err := s.pages.Render(w, http.StatusOK, "document", vd); err != nil { |
| 545 |
0 |
slog.ErrorContext(r.Context(), "rendering a page failed after it was answered", |
| 546 |
0 |
"page", "document", "path", r.URL.Path, scribe.Err(err)) |
| 547 |
0 |
} |
| 548 |
|
} |
| 549 |
|
|
| 550 |
|
// pinned wraps the archive's resolver so that every site-internal link a |
| 551 |
|
// rendered document emits keeps the revision the reader is on. |
| 552 |
|
// |
| 553 |
|
// Without it, following a wikilink out of a page opened at ?rev=<sha> lands on |
| 554 |
|
// the approved head — silently, and in the middle of reading a pinned |
| 555 |
|
// revision. The pin is the read contract's whole point, so it has to survive |
| 556 |
|
// one hop. External and unresolved destinations are handed back untouched: the |
| 557 |
|
// first is not ours to rewrite, and the second is deliberately the text the |
| 558 |
|
// author typed. |
| 559 |
|
type pinned struct { |
| 560 |
|
inner doc.Resolver |
| 561 |
|
rq string |
| 562 |
|
} |
| 563 |
|
|
| 564 |
8 |
func (p pinned) Resolve(fromDir, dest string) doc.Target { |
| 565 |
8 |
t := p.inner.Resolve(fromDir, dest) |
| 566 |
8 |
if p.rq == "" || t.IsExternal || t.Missing || !strings.HasPrefix(t.Href, "/") { |
| 567 |
7 |
return t |
| 568 |
7 |
} |
| 569 |
1 |
href, frag, hasFrag := strings.Cut(t.Href, "#") |
| 570 |
1 |
t.Href = href + p.rq |
| 571 |
1 |
if hasFrag { |
| 572 |
0 |
t.Href += "#" + frag |
| 573 |
0 |
} |
| 574 |
1 |
return t |
| 575 |
|
} |
| 576 |
|
|
| 577 |
|
// ---- search --------------------------------------------------------------- |
| 578 |
|
|
| 579 |
|
type searchHit struct { |
| 580 |
|
Title string |
| 581 |
|
Href string |
| 582 |
|
Space string |
| 583 |
|
Section string |
| 584 |
|
Score float64 |
| 585 |
|
Snippet template.HTML |
| 586 |
|
} |
| 587 |
|
|
| 588 |
|
// spaceLink is one option of the search form's space filter. It is not a |
| 589 |
|
// chrome.ListItem: a filter option is a <option>, not a card, and the listing |
| 590 |
|
// on the landing page is the only thing on this service shaped like the |
| 591 |
|
// family's event list. |
| 592 |
|
type spaceLink struct { |
| 593 |
|
Ref string |
| 594 |
|
Href string |
| 595 |
|
} |
| 596 |
|
|
| 597 |
|
type searchData struct { |
| 598 |
|
Query string |
| 599 |
|
Space string |
| 600 |
|
Total uint64 |
| 601 |
|
Took string |
| 602 |
|
Hits []searchHit |
| 603 |
|
Spaces []spaceLink |
| 604 |
|
} |
| 605 |
|
|
| 606 |
8 |
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { |
| 607 |
8 |
if !s.allowRead(w, r, formatHTML) { |
| 608 |
2 |
return |
| 609 |
2 |
} |
| 610 |
6 |
q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 611 |
6 |
spaceParam := strings.TrimSpace(r.URL.Query().Get("space")) |
| 612 |
6 |
|
| 613 |
6 |
data := searchData{Query: q, Space: spaceParam} |
| 614 |
6 |
refs, err := s.reader.ListSpaces(r.Context()) |
| 615 |
6 |
if err != nil { |
| 616 |
0 |
s.fail(w, r, err) |
| 617 |
0 |
return |
| 618 |
0 |
} |
| 619 |
6 |
for _, ref := range refs { |
| 620 |
6 |
data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()}) |
| 621 |
6 |
} |
| 622 |
|
|
| 623 |
|
// No space parameter is a search of every space, said in as many words: |
| 624 |
|
// the filter carries its polarity, so "the viewer named no space" and "the |
| 625 |
|
// viewer named a project with no spaces" cannot collapse into each other. |
| 626 |
6 |
query := search.Query{Text: q, Limit: searchLimit, Spaces: core.EverythingFilter()} |
| 627 |
6 |
if spaceParam != "" { |
| 628 |
1 |
ref, err := core.ParseSpaceRef(spaceParam) |
| 629 |
1 |
if err != nil { |
| 630 |
0 |
s.fail(w, r, err) |
| 631 |
0 |
return |
| 632 |
0 |
} |
| 633 |
1 |
query.Spaces = core.SpacesFilter([]core.SpaceRef{ref}, nil) |
| 634 |
|
} |
| 635 |
|
|
| 636 |
6 |
if q != "" { |
| 637 |
5 |
res, err := s.searcher.Search(r.Context(), query) |
| 638 |
5 |
if err != nil { |
| 639 |
0 |
s.fail(w, r, err) |
| 640 |
0 |
return |
| 641 |
0 |
} |
| 642 |
5 |
data.Total = res.Total |
| 643 |
5 |
data.Took = res.Took.Round(100000).String() |
| 644 |
5 |
for _, h := range res.Hits { |
| 645 |
5 |
data.Hits = append(data.Hits, searchHit{ |
| 646 |
5 |
Title: h.Title, |
| 647 |
5 |
Href: hitHref(h), |
| 648 |
5 |
Space: h.Space.String(), |
| 649 |
5 |
Section: h.Section, |
| 650 |
5 |
Score: h.Score, |
| 651 |
5 |
// bleve's formatter escapes everything around the <mark> tags |
| 652 |
5 |
// it inserts, so this fragment is HTML and must be rendered as |
| 653 |
5 |
// HTML — as text the marks show up literally. |
| 654 |
5 |
Snippet: template.HTML(h.Snippet), |
| 655 |
5 |
}) |
| 656 |
5 |
} |
| 657 |
|
} |
| 658 |
|
|
| 659 |
6 |
vd := s.view(r, "") |
| 660 |
6 |
vd.Title = "search — " + vd.SiteName + " " + vd.SiteLabel |
| 661 |
6 |
vd.Data = data |
| 662 |
6 |
if err := s.pages.Render(w, http.StatusOK, "search", vd); err != nil { |
| 663 |
0 |
slog.ErrorContext(r.Context(), "rendering a page failed after it was answered", |
| 664 |
0 |
"page", "search", "path", r.URL.Path, scribe.Err(err)) |
| 665 |
0 |
} |
| 666 |
|
} |
| 667 |
|
|
| 668 |
|
// hitHref turns a hit into the pinned URL the design specifies for it: |
| 669 |
|
// /~owner/space/<address>?rev=<sha>#<anchor>, where the address is the tree |
| 670 |
|
// path minus its extension because that is what a document's address is. |
| 671 |
5 |
func hitHref(h search.Hit) string { |
| 672 |
5 |
href := "/" + h.Space.String() + "/" + strings.TrimSuffix(h.Path, core.DocExt) |
| 673 |
5 |
if h.Rev != "" { |
| 674 |
5 |
href += "?rev=" + h.Rev |
| 675 |
5 |
} |
| 676 |
5 |
if h.Anchor != "" { |
| 677 |
0 |
href += "#" + h.Anchor |
| 678 |
0 |
} |
| 679 |
5 |
return href |
| 680 |
|
} |
| 681 |
|
|
| 682 |
|
// ---- helpers -------------------------------------------------------------- |
| 683 |
|
|
| 684 |
|
// spaceRefFrom builds the space reference out of the route parameters. The '~' |
| 685 |
|
// is routing decoration and is never part of the stored owner. |
| 686 |
64 |
func spaceRefFrom(r *http.Request) (core.SpaceRef, error) { |
| 687 |
64 |
owner := chi.URLParam(r, "owner") |
| 688 |
64 |
name := chi.URLParam(r, "space") |
| 689 |
64 |
ref := core.SpaceRef{Owner: owner, Name: name} |
| 690 |
64 |
if err := core.ValidateOwner(ref.Owner); err != nil { |
| 691 |
0 |
return core.SpaceRef{}, err |
| 692 |
0 |
} |
| 693 |
64 |
if err := core.ValidateSpaceName(ref.Name); err != nil { |
| 694 |
0 |
return core.SpaceRef{}, err |
| 695 |
0 |
} |
| 696 |
64 |
return ref, nil |
| 697 |
|
} |
| 698 |
|
|
| 699 |
|
// revQuery renders the pin a page's own links must carry so that following one |
| 700 |
|
// stays on the revision the reader is looking at. An unpinned read produces no |
| 701 |
|
// query at all, which is what makes the approved head the default everywhere. |
| 702 |
45 |
func revQuery(rev string) string { |
| 703 |
45 |
if rev == service.ApprovedRev { |
| 704 |
36 |
return "" |
| 705 |
36 |
} |
| 706 |
9 |
return "?rev=" + rev |
| 707 |
|
} |