coverage~bigbes/sr-ht-spec3cb1c03dweb/handlers.go

Coverage
81.5% 172/211 statements
Δ
Blob
9a8bc62
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 }