~bigbes/sr-ht-spec

ref: 4ad8525764dbfb0f7f03eae106b4436cc44040cc sr-ht-spec/web/server.go -rw-r--r-- 6.8 KiB
4ad85257 — bigbes feat(cmd): reindex a space when a push lands 27 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
// Package web is spec.sr.ht's read plane in a browser: the SourceHut chrome
// ported to Go html/templates, a space's document tree, a rendered document
// with its metadata and backlinks, and keyword search — all served from one chi
// router the daemon mounts.
//
// It is compare.sr.ht's web/ package with the diff views replaced by document
// views: the nav/service-switcher, login block, environment banner, error page,
// embedded-static pattern and hashed-asset resolution are the same code shaped
// the same way, because "no upstream SourceHut modification" means every
// service reimplements that chrome and two of them already agreed on how.
//
// # URL grammar
//
// The design pins this, so it is spelled out here rather than left to the
// router: a document's address carries no extension. The extension is a format
// selector and never part of the document's identity.
//
//	/~user/space/specs/0007-storage          rendered HTML
//	/~user/space/specs/0007-storage.md       raw source (frontmatter + body)
//	/~user/space/specs/0007-storage.json     metadata + body
//	…?rev=<sha>                              any of the three, pinned
//
// An absent ?rev= means the approved head — service.ApprovedRev — because
// serving drafts by default would poison every downstream agent context with
// unreviewed text.
//
// # Who may read
//
// The instance has one human. There are no visibility levels, so the read ACL
// is one line: the owner and its agents may read, and everyone else may not.
// An anonymous browser asking for a page is redirected to meta.sr.ht's login
// (there is no login flow of our own); an anonymous client asking for .md or
// .json gets a 401, because redirecting a bot to an HTML login page tells it
// nothing.
//
// # What the cmd layer must wire
//
// [Server.Handler] returns a router with everything this package needs already
// installed, so the daemon can mount it at "/". A caller that owns its own
// router and middleware stack uses [Server.Register] instead; it installs
// routes only, and assumes authn.Resolver.Middleware is already applied.
//
// # Assets are embedded
//
// static/ is compiled into the binary by //go:embed. `make css` rewrites
// web/static/main.min.<hash>.css on disk and a *running* daemon will not notice
// — the CSS is baked in at `go build` time, so the build order is css then
// build then restart. A binary built with no stylesheet present logs a loud
// warning at startup and renders unstyled rather than refusing to start:
// missing CSS degrades presentation, not correctness.
package web

import (
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"path"
	"regexp"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"

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

// hashedCSSRe matches the content-addressed stylesheet name so it can be served
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)

// Options is everything a Server needs. Every field is required; New says which
// one is missing rather than failing later inside a handler.
type Options struct {
	// Conf is the shared SourceHut config.ini. The nav switcher, the site name,
	// the environment banner and the meta.sr.ht login origin all come from it,
	// and it is read once at startup rather than per request.
	Conf ini.File

	// Reader is the read surface over spaces and documents. NewReader adapts a
	// *service.Service to it.
	Reader Reader

	// Searcher is the keyword index. *search.Index satisfies it directly.
	Searcher Searcher

	// Resolver turns the unified-login cookie or an agent bearer token into a
	// principal. Handler installs its middleware; Register does not.
	Resolver *authn.Resolver
}

// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {
	reader   Reader
	searcher Searcher
	resolver *authn.Resolver
	renderer *doc.Renderer

	siteName    string
	environment string
	metaOrigin  string
	origin      string
	hubOrigin   string
	cssHref     string

	nav              []navItem
	staticFileServer http.Handler
}

// New assembles a Server from the shared SourceHut config.
//
// [spec.sr.ht] origin and [meta.sr.ht] origin are required: without the first
// there is no return_to to hand meta, and without the second there is no login
// at all. A missing key is a clear error rather than a panic, so the daemon can
// fail startup loudly.
func New(opts Options) (*Server, error) {
	if opts.Conf == nil {
		return nil, fmt.Errorf("web: config is required")
	}
	if opts.Reader == nil {
		return nil, fmt.Errorf("web: Reader is required")
	}
	if opts.Searcher == nil {
		return nil, fmt.Errorf("web: Searcher is required")
	}
	if opts.Resolver == nil {
		return nil, fmt.Errorf("web: authn Resolver is required")
	}

	origin := config.GetOrigin(opts.Conf, authn.ConfigSection, true)
	if origin == "" {
		return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
	}
	metaOrigin := config.GetOrigin(opts.Conf, "meta.sr.ht", true)
	if metaOrigin == "" {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
	}

	cssHref, err := resolveCSSHref()
	if err != nil {
		return nil, err
	}
	if cssHref == "" {
		log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
			"render unstyled; run `make css` before `go build`")
	}

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, fmt.Errorf("web: sub static FS: %w", err)
	}

	return &Server{
		reader:           opts.Reader,
		searcher:         opts.Searcher,
		resolver:         opts.Resolver,
		renderer:         doc.NewRenderer(),
		siteName:         config.GetString(opts.Conf, "sr.ht", "site-name", "sourcehut"),
		environment:      config.GetString(opts.Conf, "sr.ht", "environment", "production"),
		metaOrigin:       metaOrigin,
		origin:           origin,
		hubOrigin:        config.GetOrigin(opts.Conf, "hub.sr.ht", true),
		cssHref:          cssHref,
		nav:              buildNav(opts.Conf),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

// resolveCSSHref globs the embedded static FS for the content-addressed
// stylesheet and returns its site-absolute URL, or "" when the binary was built
// without one.
//
// Absence is reported rather than substituted: there is no placeholder href to
// invent, and a link to a stylesheet that is not there would 404 on every page
// load instead of saying what is wrong once, at startup.
func resolveCSSHref() (string, error) {
	matches, err := fs.Glob(staticFS, "static/main.min.*.css")
	if err != nil {
		return "", fmt.Errorf("web: glob stylesheet: %w", err)
	}
	if len(matches) == 0 {
		return "", nil
	}
	return "/static/" + path.Base(matches[0]), nil
}