snapshot of 8b9e9fcfc8b13c2d29024f6f756b28bc6851512b Annotations about the code that implements koment.

internal/ui/ui.go

1 // Package ui serves a local view where code and its annotations converge on
2 // one screen.
3 package ui
4
5 import (
6 "context"
7 "crypto/rand"
8 "crypto/subtle"
9 "embed"
10 "encoding/hex"
11 "errors"
12 "flag"
13 "fmt"
14 "html/template"
15 "io"
16 "net"
17 "net/http"
18 "net/url"
19 "os"
20 "strings"
21 "time"
22
23 "github.com/koment-dev/koment/internal/application"
24 "github.com/koment-dev/koment/internal/config"
25 "github.com/koment-dev/koment/internal/listen"
26 "github.com/koment-dev/koment/internal/metrics"
27 "github.com/koment-dev/koment/internal/provenance"
28 "github.com/koment-dev/koment/internal/repository"
29 "github.com/koment-dev/koment/internal/store"
30 )
31
32 //go:embed assets
33 var assets embed.FS
34
35 const (
36 defaultAddress = "127.0.0.1:0"
37 shutdownGrace = 5 * time.Second
38 sweepInterval = 30 * time.Second
39 headerTimeout = 10 * time.Second
40 repositoryPrefix = "/r/"
41 capabilityQuery = "koment-capability"
42 capabilityCookie = "koment_capability"
43 maxMutationBody = 1 << 20
44 )
45
46 const usage = `koment ui serves a local view of annotated code.
47
48 koment ui [--listen <addr>] [--repository <id>] [--write]
49
50 Every configured repository is served, each under /r/<id>/, with a switcher on
51 the page. Pass --repository to serve only one.
52
53 <addr> may be a bare port. A host is added if omitted, and it is the loopback
54 interface: the view has no authentication, so anything that can reach the port
55 can read every annotation in every repository served.
56 `
57
58 // Serve parses the ui subcommand's own flags and runs the view until the
59 // process is interrupted.
60 func Serve(args []string, stderr io.Writer) error {
61 flags := flag.NewFlagSet("ui", flag.ContinueOnError)
62 flags.SetOutput(stderr)
63 flags.Usage = func() {
64 fmt.Fprint(stderr, usage, "\nFlags (each also settable from the environment):\n", config.Usage(flags))
65 }
66
67 address := flags.String("listen", defaultAddress, "address to serve on; a bare port is bound on loopback")
68 metricsAddress := flags.String("metrics", "", "serve Prometheus metrics on this separate address; off unless given")
69 named := flags.String("repository", "", "serve only this repository; all configured ones are served otherwise")
70 writes := flags.Bool("write", false, "enable local annotation writes; valid only on loopback")
71 if err := flags.Parse(args); err != nil {
72 return err
73 }
74 if err := config.FromEnvironment(flags); err != nil {
75 return err
76 }
77 if flags.NArg() > 0 {
78 return fmt.Errorf("ui takes no arguments, got %s", flags.Arg(0))
79 }
80
81 repositories, err := selectedRepositories(*named)
82 if err != nil {
83 return err
84 }
85
86 resolved, err := listen.Address(*address)
87 if err != nil {
88 return err
89 }
90 listen.WarnIfPublic(resolved, stderr)
91 if *writes && !listen.IsLoopback(resolved) {
92 return fmt.Errorf("--write requires a loopback listen address")
93 }
94 writeToken := ""
95 if *writes {
96 if writeToken, err = newCapability(); err != nil {
97 return err
98 }
99 }
100
101 listener, err := net.Listen("tcp", resolved)
102 if err != nil {
103 return fmt.Errorf("listening on %s: %w", resolved, err)
104 }
105 if writeToken == "" {
106 fmt.Fprintf(stderr, "koment: http://%s\n", listener.Addr())
107 } else {
108 fmt.Fprintf(stderr, "koment: http://%s/?%s=%s\n", listener.Addr(), capabilityQuery, writeToken)
109 }
110
111 ctx := context.Background()
112 recorder := startMetrics(ctx, repositories, *metricsAddress, stderr)
113
114 return serve(ctx, repositories, listener, stderr, recorder, writeToken)
115 }
116
117 func selectedRepositories(named string) (*repository.Set, error) {
118 workingDirectory, err := os.Getwd()
119 if err != nil {
120 return nil, fmt.Errorf("finding the working directory: %w", err)
121 }
122 repositories, err := repository.Load(workingDirectory)
123 if err != nil {
124 return nil, err
125 }
126 if named == "" {
127 return repositories, nil
128 }
129
130 chosen, found := repositories.Resolve(named)
131 if !found {
132 return nil, fmt.Errorf("no repository %q; configured: %s",
133 named, strings.Join(repositories.IDs(), ", "))
134 }
135 return repository.Of(chosen), nil
136 }
137
138 func startMetrics(ctx context.Context, repositories *repository.Set, address string, stderr io.Writer) metrics.Recorder {
139 if address == "" {
140 return metrics.Discard{}
141 }
142
143 recorder := metrics.New()
144 go func() {
145 if err := recorder.Serve(ctx, address, stderr); err != nil {
146 fmt.Fprintf(stderr, "koment: metrics: %v\n", err)
147 }
148 }()
149 go sweepPeriodically(ctx, repositories, recorder, stderr)
150 return recorder
151 }
152
153 func sweepPeriodically(ctx context.Context, repositories *repository.Set, recorder metrics.Recorder, stderr io.Writer) {
154 ticker := time.NewTicker(sweepInterval)
155 defer ticker.Stop()
156
157 for {
158 for _, entry := range repositories.All() {
159 if err := metrics.Sweep(entry.Store(), recorder); err != nil {
160 fmt.Fprintf(stderr, "koment: metrics sweep: %s: %v\n", entry.ID, err)
161 }
162 }
163 select {
164 case <-ctx.Done():
165 return
166 case <-ticker.C:
167 }
168 }
169 }
170
171 func serve(ctx context.Context, repositories *repository.Set, listener net.Listener, stderr io.Writer, recorder metrics.Recorder, writeToken string) error {
172 server := &http.Server{
173 Handler: metrics.Instrument(recorder, "ui", handler(repositories, writeToken)),
174 ReadHeaderTimeout: headerTimeout,
175 }
176
177 go func() {
178 <-ctx.Done()
179 timeout, cancel := context.WithTimeout(context.Background(), shutdownGrace)
180 defer cancel()
181 if err := server.Shutdown(timeout); err != nil {
182 fmt.Fprintf(stderr, "koment: shutting down: %v\n", err)
183 }
184 }()
185
186 if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
187 return err
188 }
189 return nil
190 }
191
192 // Handler routes the view. Every request re-reads the working tree, so what is
193 // rendered is what is on disk rather than what was on disk at startup. Paths
194 // are /r/<repository>/f/<file>.
195 func Handler(repositories *repository.Set) http.Handler {
196 return handler(repositories, "")
197 }
198
199 func handler(repositories *repository.Set, writeToken string) http.Handler {
200 templates := template.Must(template.ParseFS(assets, "assets/*.html"))
201
202 mux := http.NewServeMux()
203 mux.Handle("GET /assets/", http.FileServerFS(assets))
204
205 mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
206 http.Redirect(w, r, repositoryPrefix+repositories.All()[0].ID+"/", http.StatusFound)
207 })
208 mux.HandleFunc("GET "+repositoryPrefix+"{repository}/{$}", func(w http.ResponseWriter, r *http.Request) {
209 render(w, templates, repositories, r, r.PathValue("repository"), "", writeToken)
210 })
211 mux.HandleFunc("GET "+repositoryPrefix+"{repository}/f/{path...}", func(w http.ResponseWriter, r *http.Request) {
212 render(w, templates, repositories, r, r.PathValue("repository"), r.PathValue("path"), writeToken)
213 })
214 if writeToken != "" {
215 mux.HandleFunc("POST "+repositoryPrefix+"{repository}/annotations", func(w http.ResponseWriter, r *http.Request) {
216 addFromBrowser(w, r, repositories, writeToken)
217 })
218 }
219 return capabilityBootstrap(mux, writeToken)
220 }
221
222 func render(w http.ResponseWriter, templates *template.Template,
223 repositories *repository.Set, request *http.Request, named, requested, writeToken string,
224 ) {
225 chosen, found := repositories.ByID(named)
226 if !found {
227 http.Error(w, fmt.Sprintf("no repository %q; serving: %s",
228 named, strings.Join(repositories.IDs(), ", ")), http.StatusNotFound)
229 return
230 }
231
232 repositorySnapshot, err := application.BuildSnapshot(chosen)
233 if err != nil {
234 http.Error(w, err.Error(), http.StatusInternalServerError)
235 return
236 }
237 view, err := build(repositorySnapshot, requested, servedLinks(chosen.ID))
238 if err != nil {
239 http.Error(w, err.Error(), http.StatusInternalServerError)
240 return
241 }
242 view.Repository = chosen.Display()
243 view.Repositories = repositorySwitcher(repositories, chosen.ID)
244 if hasCapability(request, writeToken) {
245 view.WriteToken = writeToken
246 view.CanWrite = true
247 }
248 view.CreatedID = request.URL.Query().Get("created")
249 view.WriteWarning = request.URL.Query().Get("warning")
250
251 w.Header().Set("Content-Type", "text/html; charset=utf-8")
252 if err := templates.ExecuteTemplate(w, "page.html", view); err != nil {
253 http.Error(w, err.Error(), http.StatusInternalServerError)
254 }
255 }
256
257 func newCapability() (string, error) {
258 var entropy [32]byte
259 if _, err := rand.Read(entropy[:]); err != nil {
260 return "", fmt.Errorf("creating UI write capability: %w", err)
261 }
262 return hex.EncodeToString(entropy[:]), nil
263 }
264
265 func servedOverTLS(r *http.Request) bool {
266 return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
267 }
268
269 func capabilityBootstrap(next http.Handler, writeToken string) http.Handler {
270 if writeToken == "" {
271 return next
272 }
273 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
274 given := r.URL.Query().Get(capabilityQuery)
275 if r.Method == http.MethodGet && sameSecret(given, writeToken) {
276 //nolint:gosec
277 http.SetCookie(w, &http.Cookie{
278 Name: capabilityCookie, Value: writeToken, Path: "/", HttpOnly: true,
279 SameSite: http.SameSiteStrictMode, Secure: servedOverTLS(r),
280 })
281 http.Redirect(w, r, "/", http.StatusSeeOther)
282 return
283 }
284 next.ServeHTTP(w, r)
285 })
286 }
287
288 func hasCapability(request *http.Request, writeToken string) bool {
289 if writeToken == "" {
290 return false
291 }
292 cookie, err := request.Cookie(capabilityCookie)
293 return err == nil && sameSecret(cookie.Value, writeToken)
294 }
295
296 func sameSecret(left, right string) bool {
297 return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
298 }
299
300 func addFromBrowser(w http.ResponseWriter, request *http.Request, repositories *repository.Set, writeToken string) {
301 if !sameOrigin(request) || !hasCapability(request, writeToken) {
302 http.Error(w, "write capability or same-origin request missing", http.StatusForbidden)
303 return
304 }
305 request.Body = http.MaxBytesReader(w, request.Body, maxMutationBody)
306 if err := request.ParseForm(); err != nil {
307 http.Error(w, "invalid annotation form: "+err.Error(), http.StatusBadRequest)
308 return
309 }
310 if !sameSecret(request.Form.Get("capability"), writeToken) {
311 http.Error(w, "CSRF token mismatch", http.StatusForbidden)
312 return
313 }
314 entry, found := repositories.ByID(request.PathValue("repository"))
315 if !found {
316 http.Error(w, "repository not found", http.StatusNotFound)
317 return
318 }
319 kind, err := store.ParseType(request.Form.Get("kind"))
320 if err != nil {
321 http.Error(w, err.Error(), http.StatusBadRequest)
322 return
323 }
324 author, err := provenance.IdentityFromGit(entry.Root)
325 if err != nil {
326 http.Error(w, "reading human identity: "+err.Error(), http.StatusBadRequest)
327 return
328 }
329 mutation, err := application.NewService(entry).Add(application.AddInput{
330 File: request.Form.Get("file"), Excerpt: request.Form.Get("excerpt"),
331 Kind: kind, Body: request.Form.Get("body"), Author: *author,
332 })
333 if err != nil {
334 http.Error(w, err.Error(), http.StatusBadRequest)
335 return
336 }
337 query := url.Values{"created": []string{mutation.Record.Metadata.ID}}
338 if len(mutation.Warnings) > 0 {
339 query.Set("warning", strings.Join(mutation.Warnings, "; "))
340 }
341 target := repositoryPrefix + entry.ID + "/f/" + escapedFilePath(mutation.Record.Spec.Target.File) + "?" + query.Encode()
342 http.Redirect(w, request, target, http.StatusSeeOther)
343 }
344
345 func sameOrigin(request *http.Request) bool {
346 origin := request.Header.Get("Origin")
347 if origin == "" {
348 return false
349 }
350 parsed, err := url.Parse(origin)
351 return err == nil && parsed.Scheme == "http" && parsed.Host == request.Host
352 }
353
354 func repositorySwitcher(repositories *repository.Set, current string) []repositoryLink {
355 if repositories.Len() < 2 {
356 return nil
357 }
358
359 links := make([]repositoryLink, 0, repositories.Len())
360 for _, entry := range repositories.All() {
361 links = append(links, repositoryLink{
362 ID: entry.ID,
363 Name: entry.Display(),
364 Href: repositoryPrefix + entry.ID + "/",
365 Current: entry.ID == current,
366 })
367 }
368 return links
369 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close