~bigbes/sr-ht-spec

ref: 90eb06ec6f16e09cc7d1ab7558c9464c3764aca8 sr-ht-spec/web/handlers.go -rw-r--r-- 18.0 KiB
90eb06ec — Eugene Blikh feat(cmd): agent tokens and host-side proposals get admin commands 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
package web

import (
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// searchLimit bounds one page of results.
const searchLimit = 25

// ---- format selectors -----------------------------------------------------

// format is which representation of a document was asked for. The extension in
// the URL selects it; the document's own address carries none.
type format int

const (
	formatHTML format = iota
	formatRaw
	formatJSON
)

// splitFormat peels a format selector off the tail of a document address.
//
// The design pins this: ".md" is the raw source and ".json" is metadata plus
// body, so neither is ever part of the address that identifies the document.
// The remainder is the document's address — its tree path minus core.DocExt.
func splitFormat(rest string) (string, format) {
	switch {
	case strings.HasSuffix(rest, ".json"):
		return strings.TrimSuffix(rest, ".json"), formatJSON
	case strings.HasSuffix(rest, core.DocExt):
		return strings.TrimSuffix(rest, core.DocExt), formatRaw
	default:
		return rest, formatHTML
	}
}

// ---- authorization --------------------------------------------------------

// mayRead reports whether a request carries authority to read content. The ACL
// itself — owner and its agents, nobody else — is authn.Principal.CanRead, the
// one spelling every read surface shares.
func mayRead(r *http.Request) bool {
	return authn.PrincipalFromContext(r.Context()).CanRead()
}

// denyRead answers a viewer with no read authority in the shape their client
// can act on: a browser is sent to meta's login, a machine asking for .md or
// .json gets a 401. Redirecting a bot to an HTML login page would hand it a
// 200 full of markup it cannot use.
func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, f format) {
	if f == formatHTML {
		s.loginRedirect(w, r)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	http.Error(w, "authentication required", http.StatusUnauthorized)
}

// ---- error mapping --------------------------------------------------------

// httpStatusFor maps a service error onto a status. service.ErrNotFound already
// folds "malformed revision" into "absent", so probing cannot tell them apart.
func httpStatusFor(err error) int {
	switch {
	case errors.Is(err, service.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, core.ErrInvalidName), errors.Is(err, core.ErrInvalidPath):
		return http.StatusBadRequest
	// The write-plane sentinels the review page's approve/reject can return. A
	// base that moved under a proposal, a proposal already resolved, and an
	// already-merged one are all 409; the owner-only refusal is 403.
	case errors.Is(err, service.ErrForbidden):
		return http.StatusForbidden
	case errors.Is(err, service.ErrStale),
		errors.Is(err, service.ErrAlreadyMerged),
		errors.Is(err, service.ErrProposalNotOpen):
		return http.StatusConflict
	case errors.Is(err, service.ErrInvalid):
		return http.StatusUnprocessableEntity
	default:
		return http.StatusInternalServerError
	}
}

// fail renders the chrome error page for err, logging 5xx causes and telling
// the viewer nothing about them.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		log.Printf("web: %s: %v", r.URL.Path, err)
		s.renderError(w, r, status, "an internal error occurred")
		return
	}
	s.renderError(w, r, status, err.Error())
}

// failFormat is fail for a request that asked for .md or .json: those callers
// are machines, so they get a status and a line of text rather than a page.
func (s *Server) failFormat(w http.ResponseWriter, r *http.Request, f format, err error) {
	if f == formatHTML {
		s.fail(w, r, err)
		return
	}
	status := httpStatusFor(err)
	if status >= 500 {
		log.Printf("web: %s: %v", r.URL.Path, err)
		http.Error(w, "internal server error", status)
		return
	}
	http.Error(w, err.Error(), status)
}

// ---- landing --------------------------------------------------------------

type spaceLink struct {
	Ref  string
	Href string
}

type indexData struct {
	LoggedIn bool
	Spaces   []spaceLink
}

// handleIndex is the landing page: the spaces you can read.
//
// It renders for an anonymous viewer too, because the chrome's login link has
// to live somewhere reachable — but it lists nothing, so no space name leaks.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	vd := s.chrome(r)
	vd.Title = s.siteName + " spec"

	data := indexData{LoggedIn: mayRead(r)}
	if data.LoggedIn {
		refs, err := s.reader.ListSpaces(r.Context())
		if err != nil {
			s.fail(w, r, err)
			return
		}
		for _, ref := range refs {
			data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
		}
	}
	vd.Data = data
	s.render(w, http.StatusOK, "index", vd)
}

// ---- space ----------------------------------------------------------------

// treeItem is one row of the space's document tree, flattened to a depth so the
// template needs no recursion.
type treeItem struct {
	Depth   int
	Title   string
	Href    string
	ID      string
	DocID   string
	Status  string
	Summary string
	Section string
}

type spaceData struct {
	Ref      string
	Rev      string
	Pinned   bool
	RevQuery string
	Count    int
	Items    []treeItem
}

func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.denyRead(w, r, formatHTML)
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	rev := r.URL.Query().Get("rev")
	snap, err := s.reader.Snapshot(r.Context(), ref, rev)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.chrome(r)
	vd.Title = ref.String()
	vd.Data = spaceData{
		Ref:      ref.String(),
		Rev:      snap.Rev,
		Pinned:   rev != service.ApprovedRev,
		RevQuery: revQuery(rev),
		Count:    len(snap.Archive.All()),
		Items:    flattenTree(snap, revQuery(rev)),
	}
	s.render(w, http.StatusOK, "space", vd)
}

// flattenTree walks the archive's `parent:` hierarchy into an ordered, depth-
// tagged list. A space where nobody sets `parent:` degrades to a flat list in
// path order, which is the common case and reads fine.
func flattenTree(snap *Snapshot, rq string) []treeItem {
	var out []treeItem
	seen := make(map[string]bool, len(snap.Archive.All()))

	var walk func(pages []*doc.Page, depth int)
	walk = func(pages []*doc.Page, depth int) {
		for _, p := range pages {
			if seen[p.ID] {
				continue // a `parent:` cycle must not hang the page
			}
			seen[p.ID] = true
			out = append(out, treeItem{
				Depth:   depth,
				Title:   p.Title,
				Href:    snap.Archive.DocHref(p) + rq,
				ID:      p.ID,
				DocID:   p.DocID,
				Status:  string(p.Status),
				Summary: p.Summary,
				Section: p.Section,
			})
			walk(snap.Archive.Children(p.ID), depth+1)
		}
	}
	walk(snap.Archive.Roots(), 0)

	// Anything a cycle kept out of the walk is still a document of this space
	// and must still be listed; dropping it would make the tree quietly lie
	// about what the revision contains.
	for _, p := range snap.Archive.All() {
		if !seen[p.ID] {
			out = append(out, treeItem{
				Title:   p.Title,
				Href:    snap.Archive.DocHref(p) + rq,
				ID:      p.ID,
				DocID:   p.DocID,
				Status:  string(p.Status),
				Summary: p.Summary,
				Section: p.Section,
			})
		}
	}
	return out
}

// ---- document -------------------------------------------------------------

type docLink struct {
	Title string
	Href  string
}

type docData struct {
	SpaceRef  string
	SpaceHref string
	Path      string
	Address   string
	ID        string
	DocID     string
	Title     string
	Status    string
	Summary   string
	Type      string
	Tags      []string
	Owners    []string
	Props     []doc.DocProperty
	Rev       string
	Blob      string
	Pinned    bool
	RevQuery  string
	Body      template.HTML
	Headings  []doc.Heading
	Children  []docLink
	Backlinks []docLink
	Missing   []string
	WordCount int
	RawHref   string
	JSONHref  string
}

// docJSON is the .json representation: metadata plus body.
type docJSON struct {
	Space      string            `json:"space"`
	Path       string            `json:"path"`
	Address    string            `json:"address"`
	ID         string            `json:"id"`
	DocID      string            `json:"doc_id,omitempty"`
	Rev        string            `json:"rev"`
	Blob       string            `json:"blob"`
	Kind       string            `json:"kind,omitempty"`
	Title      string            `json:"title"`
	Status     string            `json:"status,omitempty"`
	Summary    string            `json:"summary,omitempty"`
	Type       string            `json:"type,omitempty"`
	Section    string            `json:"section,omitempty"`
	Tags       []string          `json:"tags,omitempty"`
	Owners     []string          `json:"owners,omitempty"`
	Supersedes string            `json:"supersedes,omitempty"`
	Props      []doc.DocProperty `json:"props,omitempty"`
	Body       string            `json:"body"`
}

// handleDocument serves all three representations of one document.
//
// The three share a single route because they are one resource: the extension
// selects a format and the remainder is the address. Splitting them into three
// routes would let the address grammar drift apart between them, which is the
// exact confusion the pinned grammar exists to prevent.
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
	rest, ok := unescapePath(chi.URLParam(r, "*"))
	if !ok {
		s.renderError(w, r, http.StatusBadRequest, "malformed path")
		return
	}
	address, f := splitFormat(rest)

	if !mayRead(r) {
		s.denyRead(w, r, f)
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.failFormat(w, r, f, err)
		return
	}
	rev := r.URL.Query().Get("rev")
	rq := revQuery(rev)

	// "/~owner/space/" is the space, spelled with a trailing slash.
	if address == "" {
		http.Redirect(w, r, "/"+ref.String()+rq, http.StatusFound)
		return
	}

	docPath := address + core.DocExt
	if err := core.ValidateDocPath(docPath); err != nil {
		s.failFormat(w, r, f, err)
		return
	}

	// Raw source needs no archive: it is the bytes of one blob, and building a
	// whole-revision archive to hand them over would make the cheapest read the
	// most expensive one.
	if f == formatRaw {
		d, err := s.reader.ReadDocument(r.Context(), ref, rev, docPath)
		if err != nil {
			s.failFormat(w, r, f, err)
			return
		}
		w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
		w.Header().Set("X-Spec-Rev", d.Rev)
		w.Header().Set("X-Spec-Blob", d.Blob)
		_, _ = w.Write(d.Data)
		return
	}

	snap, err := s.reader.Snapshot(r.Context(), ref, rev)
	if err != nil {
		s.failFormat(w, r, f, err)
		return
	}
	page, ok := snap.Archive.ByPath(docPath)
	if !ok {
		// The address may be a document id rather than a path. Redirecting
		// rather than serving keeps one document at one canonical URL — and a
		// duplicated id resolves to neither document, so this cannot silently
		// pick a winner.
		if p, found := snap.Archive.Page(address); found {
			http.Redirect(w, r, snap.Archive.DocHref(p)+rq, http.StatusFound)
			return
		}
		s.failFormat(w, r, f, fmt.Errorf("%w: %s in %s at %s",
			service.ErrNotFound, docPath, ref, snap.Rev))
		return
	}

	body, ok := snap.Bodies[docPath]
	if !ok {
		s.failFormat(w, r, f, fmt.Errorf("web: %s is in the archive of %s at %s but has no body",
			docPath, ref, snap.Rev))
		return
	}
	front, mdBody := doc.ParseFront(body)

	if f == formatJSON {
		payload := docJSON{
			Space:      ref.String(),
			Path:       page.Path,
			Address:    address,
			ID:         page.ID,
			DocID:      page.DocID,
			Rev:        snap.Rev,
			Blob:       page.Blob,
			Kind:       string(page.Kind),
			Title:      page.Title,
			Status:     string(page.Status),
			Summary:    page.Summary,
			Type:       front.Type,
			Section:    page.Section,
			Tags:       page.Tags,
			Owners:     front.Owners,
			Supersedes: front.Supersedes,
			Props:      front.Props,
			Body:       string(mdBody),
		}
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		enc := json.NewEncoder(w)
		enc.SetIndent("", "  ")
		if err := enc.Encode(payload); err != nil {
			log.Printf("web: encoding %s.json: %v", docPath, err)
		}
		return
	}

	// The link graph backlinks are read from is filled in by the reader, at the
	// same revision as the archive itself. This handler used to render every
	// document of the space here to build it, once per page view.
	res := s.renderer.Render(mdBody, doc.DirOf(docPath), pinned{inner: snap.Archive, rq: rq})

	data := docData{
		SpaceRef:  ref.String(),
		SpaceHref: "/" + ref.String() + rq,
		Path:      page.Path,
		Address:   address,
		ID:        page.ID,
		DocID:     page.DocID,
		Title:     page.Title,
		Status:    string(page.Status),
		Summary:   page.Summary,
		Type:      front.Type,
		Tags:      page.Tags,
		Owners:    front.Owners,
		Props:     front.Props,
		Rev:       snap.Rev,
		Blob:      page.Blob,
		Pinned:    rev != service.ApprovedRev,
		RevQuery:  rq,
		Body:      template.HTML(res.HTML),
		Headings:  res.Headings,
		Missing:   res.MissingWikilinks,
		WordCount: res.WordCount,
		RawHref:   snap.Archive.DocHref(page) + core.DocExt + rq,
		JSONHref:  snap.Archive.DocHref(page) + ".json" + rq,
	}
	for _, c := range snap.Archive.Children(page.ID) {
		data.Children = append(data.Children, docLink{Title: c.Title, Href: snap.Archive.DocHref(c) + rq})
	}
	for _, b := range snap.Archive.Backlinks(page.ID) {
		data.Backlinks = append(data.Backlinks, docLink{Title: b.Title, Href: snap.Archive.DocHref(b) + rq})
	}

	vd := s.chrome(r)
	vd.Title = page.Title + " — " + ref.String()
	vd.Data = data
	s.render(w, http.StatusOK, "document", vd)
}

// pinned wraps the archive's resolver so that every site-internal link a
// rendered document emits keeps the revision the reader is on.
//
// Without it, following a wikilink out of a page opened at ?rev=<sha> lands on
// the approved head — silently, and in the middle of reading a pinned
// revision. The pin is the read contract's whole point, so it has to survive
// one hop. External and unresolved destinations are handed back untouched: the
// first is not ours to rewrite, and the second is deliberately the text the
// author typed.
type pinned struct {
	inner doc.Resolver
	rq    string
}

func (p pinned) Resolve(fromDir, dest string) doc.Target {
	t := p.inner.Resolve(fromDir, dest)
	if p.rq == "" || t.IsExternal || t.Missing || !strings.HasPrefix(t.Href, "/") {
		return t
	}
	href, frag, hasFrag := strings.Cut(t.Href, "#")
	t.Href = href + p.rq
	if hasFrag {
		t.Href += "#" + frag
	}
	return t
}

// ---- search ---------------------------------------------------------------

type searchHit struct {
	Title   string
	Href    string
	Space   string
	Section string
	Score   float64
	Snippet template.HTML
}

type searchData struct {
	Query  string
	Space  string
	Total  uint64
	Took   string
	Hits   []searchHit
	Spaces []spaceLink
}

func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.denyRead(w, r, formatHTML)
		return
	}
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	spaceParam := strings.TrimSpace(r.URL.Query().Get("space"))

	data := searchData{Query: q, Space: spaceParam}
	refs, err := s.reader.ListSpaces(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}
	for _, ref := range refs {
		data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
	}

	// No space parameter is a search of every space, said in as many words:
	// the filter carries its polarity, so "the viewer named no space" and "the
	// viewer named a project with no spaces" cannot collapse into each other.
	query := search.Query{Text: q, Limit: searchLimit, Spaces: core.EverythingFilter()}
	if spaceParam != "" {
		ref, err := core.ParseSpaceRef(spaceParam)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		query.Spaces = core.SpacesFilter([]core.SpaceRef{ref}, nil)
	}

	if q != "" {
		res, err := s.searcher.Search(r.Context(), query)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		data.Total = res.Total
		data.Took = res.Took.Round(100000).String()
		for _, h := range res.Hits {
			data.Hits = append(data.Hits, searchHit{
				Title:   h.Title,
				Href:    hitHref(h),
				Space:   h.Space.String(),
				Section: h.Section,
				Score:   h.Score,
				// bleve's formatter escapes everything around the <mark> tags
				// it inserts, so this fragment is HTML and must be rendered as
				// HTML — as text the marks show up literally.
				Snippet: template.HTML(h.Snippet),
			})
		}
	}

	vd := s.chrome(r)
	vd.Title = "search — " + s.siteName + " spec"
	vd.Data = data
	s.render(w, http.StatusOK, "search", vd)
}

// hitHref turns a hit into the pinned URL the design specifies for it:
// /~owner/space/<address>?rev=<sha>#<anchor>, where the address is the tree
// path minus its extension because that is what a document's address is.
func hitHref(h search.Hit) string {
	href := "/" + h.Space.String() + "/" + strings.TrimSuffix(h.Path, core.DocExt)
	if h.Rev != "" {
		href += "?rev=" + h.Rev
	}
	if h.Anchor != "" {
		href += "#" + h.Anchor
	}
	return href
}

// ---- helpers --------------------------------------------------------------

// spaceRefFrom builds the space reference out of the route parameters. The '~'
// is routing decoration and is never part of the stored owner.
func spaceRefFrom(r *http.Request) (core.SpaceRef, error) {
	owner := chi.URLParam(r, "owner")
	name := chi.URLParam(r, "space")
	ref := core.SpaceRef{Owner: owner, Name: name}
	if err := core.ValidateOwner(ref.Owner); err != nil {
		return core.SpaceRef{}, err
	}
	if err := core.ValidateSpaceName(ref.Name); err != nil {
		return core.SpaceRef{}, err
	}
	return ref, nil
}

// revQuery renders the pin a page's own links must carry so that following one
// stays on the revision the reader is looking at. An unpinned read produces no
// query at all, which is what makes the approved head the default everywhere.
func revQuery(rev string) string {
	if rev == service.ApprovedRev {
		return ""
	}
	return "?rev=" + rev
}