~bigbes/sr-ht-spec

ref: be33cce12a517c4c129905c733bbc098f5c9c62f sr-ht-spec/web/router.go -rw-r--r-- 5.2 KiB
be33cce1 — Eugene Blikh instconf: one reading of this instance's origins 9 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
package web

import (
	"net/http"
	"net/url"
	"strings"

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

	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/middleware"
)

// Handler returns a router with everything this package needs already
// installed: panic recovery, the private cache policy, the authn principal
// middleware and the same-origin guard, then the routes. The daemon mounts it
// at "/".
//
// The order is the one the shared packages ask for. RecoverPanics is outermost
// so it covers the later middleware as well as the handlers, and it is
// sr-ht-ecore's rather than chi's or our own for one behaviour: a panic that
// arrives *after* the response has started aborts the connection instead of
// appending an error page to a truncated one. PrivateCache sits inside it so
// that every answer — including the two refusals below — carries the same
// private, no-store a page rendered behind a login cookie needs.
//
// csrf.Require goes last of the four, and on the router rather than on the
// routes, which is the whole point of the change: the guard used to be a
// predicate that three handlers remembered to call, so a form added later went
// out unprotected by default. Here it covers the routes that are not written
// yet, and it runs before routing — a mutation aimed at an address this surface
// does not serve is refused rather than 404'd, which is the right way round,
// since an unrouted POST answering differently from a routed one would be a way
// to enumerate them without ever passing the check. It sits after the resolver
// so the refusal page names the viewer the way every other page does.
//
// A caller that owns its own middleware stack — and has already applied
// authn.Resolver.Middleware to it — uses Register instead, and owes its router
// this guard: Register installs no middleware of its own.
func (s *Server) Handler() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
		s.renderError(w, r, http.StatusInternalServerError, "")
	}))
	r.Use(middleware.PrivateCache)
	r.Use(s.resolver.Middleware())
	r.Use(csrf.Require(s.chromeSvc.SelfOrigin(), func(w http.ResponseWriter, r *http.Request) {
		s.renderError(w, r, http.StatusForbidden, csrf.Message)
	}))
	s.Register(r)
	return r
}

// Register mounts every spec.sr.ht read-plane route onto r. It installs no
// middleware of its own; the router it is handed must already resolve a
// principal into the request context (authn.Resolver.Middleware) and must
// already carry csrf.Require, or every viewer looks anonymous and every form is
// forgeable. Handler does both.
//
// The document route is a single wildcard because the format selector lives in
// the *extension* and the document's address does not have one: ".md" and
// ".json" are stripped from the tail by the handler, never routed on, so a
// document called "notes/2026.json.md" is still reachable and a request for
// "notes/2026.json" still means "the JSON of notes/2026".
func (s *Server) Register(r chi.Router) {
	r.Get("/", s.handleIndex)
	r.Get("/healthz", s.handleHealthz)
	r.Mount(assets.DefaultPrefix, s.static)
	r.Get("/search", s.handleSearch)
	r.Get("/inbox", s.handleInbox)
	r.Post("/inbox/seen", s.handleInboxSeen)

	// /tokens redirects to tokens.sr.ht, which issues every agent credential on
	// the instance. The POST routes that minted and revoked here went with the
	// table behind them; the GET stays so that a bookmark, the dashboard button
	// and every doc that ever said "see /tokens" still land somewhere useful.
	r.Get("/tokens", s.handleTokens)

	// The proposal routes are registered before the document wildcard. chi gives
	// the static "p" segment priority over the "*" catch-all regardless, but
	// keeping them adjacent makes the "/p/ is the proposal namespace" decision
	// visible in one place.
	r.Get("/~{owner}/{space}/p/{id}", s.handleProposal)
	r.Post("/~{owner}/{space}/p/{id}/approve", s.handleProposalApprove)
	r.Post("/~{owner}/{space}/p/{id}/reject", s.handleProposalReject)
	r.Post("/~{owner}/{space}/p/{id}/comment", s.handleProposalComment)
	r.Post("/~{owner}/{space}/p/{id}/reply", s.handleProposalReply)
	r.Post("/~{owner}/{space}/p/{id}/resolve", s.handleProposalResolve)

	r.Get("/~{owner}/{space}", s.handleSpace)
	r.Get("/~{owner}/{space}/*", s.handleDocument)
}

// handleHealthz is a dependency-free liveness probe.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	_, _ = w.Write([]byte("ok\n"))
}

// unescapePath decodes a chi wildcard back into a tree path.
//
// chi routes on r.URL.RawPath when the request had one, so the wildcard arrives
// percent-encoded — which it must, since doc.Archive escapes every href segment
// so spaces and Cyrillic survive. Decoding is per segment on purpose: a %2F
// inside a segment is not a path separator and must not become one.
func unescapePath(raw string) (string, bool) {
	if raw == "" {
		return "", true
	}
	segs := strings.Split(raw, "/")
	for i, seg := range segs {
		dec, err := url.PathUnescape(seg)
		if err != nil {
			return "", false
		}
		segs[i] = dec
	}
	return strings.Join(segs, "/"), true
}