internal/ui/ui.go
1
// Package ui serves a local view where code and its annotations converge on
2
// one screen.
3
package ui
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"
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
)
32
//go:embed assets
33
var assets embed.FS
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
)
46
const usage = `koment ui serves a local view of annotated code.
48
koment ui [--listen <addr>] [--repository <id>] [--write]
50
Every configured repository is served, each under /r/<id>/, with a switcher on
51
the page. Pass --repository to serve only one.
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
`
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
}
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
}
81
repositories, err := selectedRepositories(*named)
82
if err != nil {
83
return err
84
}
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
}
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
}
111
ctx := context.Background()
112
recorder := startMetrics(ctx, repositories, *metricsAddress, stderr)
114
return serve(ctx, repositories, listener, stderr, recorder, writeToken)
115
}
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
}
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
}
138
func startMetrics(ctx context.Context, repositories *repository.Set, address string, stderr io.Writer) metrics.Recorder {
139
if address == "" {
140
return metrics.Discard{}
141
}
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
}
153
func sweepPeriodically(ctx context.Context, repositories *repository.Set, recorder metrics.Recorder, stderr io.Writer) {
154
ticker := time.NewTicker(sweepInterval)
155
defer ticker.Stop()
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
}
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
}
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
}()
186
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
187
return err
188
}
189
return nil
190
}
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
}
199
func handler(repositories *repository.Set, writeToken string) http.Handler {
200
templates := template.Must(template.ParseFS(assets, "assets/*.html"))
202
mux := http.NewServeMux()
203
mux.Handle("GET /assets/", http.FileServerFS(assets))
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
}
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
}
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")
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
}
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
}
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
}
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
}
292
func sameSecret(left, right string) bool {
293
return len(left) == len(right) && subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
294
}
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
}
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
}
350
func repositorySwitcher(repositories *repository.Set, current string) []repositoryLink {
351
if repositories.Len() < 2 {
352
return nil
353
}
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
}