snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 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/janpuc/koment/internal/application"
24 "github.com/janpuc/koment/internal/config"
25 "github.com/janpuc/koment/internal/listen"
26 "github.com/janpuc/koment/internal/metrics"
27 "github.com/janpuc/koment/internal/provenance"
28 "github.com/janpuc/koment/internal/repository"
29 "github.com/janpuc/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 capabilityBootstrap(next http.Handler, writeToken string) http.Handler {
266 if writeToken == "" {
267 return next
268 }
269 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
270 given := r.URL.Query().Get(capabilityQuery)
271 if r.Method == http.MethodGet && sameSecret(given, writeToken) {
272 //nolint:gosec
273 http.SetCookie(w, &http.Cookie{
274 Name: capabilityCookie, Value: writeToken, Path: "/", HttpOnly: true,
275 SameSite: http.SameSiteStrictMode,
276 })
277 http.Redirect(w, r, "/", http.StatusSeeOther)
278 return
279 }
280 next.ServeHTTP(w, r)
281 })
282 }
283
284 func hasCapability(request *http.Request, writeToken string) bool {
285 if writeToken == "" {
286 return false
287 }
288 cookie, err := request.Cookie(capabilityCookie)
289 return err == nil && sameSecret(cookie.Value, writeToken)
290 }
291
292 func sameSecret(left, right string) bool {
293 return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
294 }
295
296 func addFromBrowser(w http.ResponseWriter, request *http.Request, repositories *repository.Set, writeToken string) {
297 if !sameOrigin(request) || !hasCapability(request, writeToken) {
298 http.Error(w, "write capability or same-origin request missing", http.StatusForbidden)
299 return
300 }
301 request.Body = http.MaxBytesReader(w, request.Body, maxMutationBody)
302 if err := request.ParseForm(); err != nil {
303 http.Error(w, "invalid annotation form: "+err.Error(), http.StatusBadRequest)
304 return
305 }
306 if !sameSecret(request.Form.Get("capability"), writeToken) {
307 http.Error(w, "CSRF token mismatch", http.StatusForbidden)
308 return
309 }
310 entry, found := repositories.ByID(request.PathValue("repository"))
311 if !found {
312 http.Error(w, "repository not found", http.StatusNotFound)
313 return
314 }
315 kind, err := store.ParseKind(request.Form.Get("kind"))
316 if err != nil {
317 http.Error(w, err.Error(), http.StatusBadRequest)
318 return
319 }
320 author, err := provenance.IdentityFromGit(entry.Root)
321 if err != nil {
322 http.Error(w, "reading human identity: "+err.Error(), http.StatusBadRequest)
323 return
324 }
325 mutation, err := application.NewService(entry).Add(application.AddInput{
326 File: request.Form.Get("file"), Excerpt: request.Form.Get("excerpt"),
327 Kind: kind, Body: request.Form.Get("body"), Author: *author,
328 })
329 if err != nil {
330 http.Error(w, err.Error(), http.StatusBadRequest)
331 return
332 }
333 query := url.Values{"created": []string{mutation.Record.ID}}
334 if len(mutation.Warnings) > 0 {
335 query.Set("warning", strings.Join(mutation.Warnings, "; "))
336 }
337 target := repositoryPrefix + entry.ID + "/f/" + escapedFilePath(mutation.Record.File) + "?" + query.Encode()
338 http.Redirect(w, request, target, http.StatusSeeOther)
339 }
340
341 func sameOrigin(request *http.Request) bool {
342 origin := request.Header.Get("Origin")
343 if origin == "" {
344 return false
345 }
346 parsed, err := url.Parse(origin)
347 return err == nil && parsed.Scheme == "http" && parsed.Host == request.Host
348 }
349
350 func repositorySwitcher(repositories *repository.Set, current string) []repositoryLink {
351 if repositories.Len() < 2 {
352 return nil
353 }
354
355 links := make([]repositoryLink, 0, repositories.Len())
356 for _, entry := range repositories.All() {
357 links = append(links, repositoryLink{
358 ID: entry.ID,
359 Name: entry.Display(),
360 Href: repositoryPrefix + entry.ID + "/",
361 Current: entry.ID == current,
362 })
363 }
364 return links
365 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close