@yuuki824/kanshi 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.env.example +34 -0
  2. package/Dockerfile +53 -0
  3. package/LICENSE +661 -0
  4. package/README.md +219 -0
  5. package/bin/kanshi.js +63 -0
  6. package/docker-compose.yml +84 -0
  7. package/go.mod +3 -0
  8. package/internal/access/access.go +275 -0
  9. package/internal/access/listeners.go +229 -0
  10. package/internal/config/config.go +256 -0
  11. package/internal/config/defaults_other.go +5 -0
  12. package/internal/config/defaults_windows.go +4 -0
  13. package/internal/config/envfile.go +147 -0
  14. package/internal/dockerstats/dial.go +32 -0
  15. package/internal/dockerstats/dial_other.go +13 -0
  16. package/internal/dockerstats/dial_windows.go +62 -0
  17. package/internal/dockerstats/dockerstats.go +626 -0
  18. package/internal/roots/roots.go +175 -0
  19. package/internal/roots/roots_linux.go +132 -0
  20. package/internal/roots/roots_windows.go +74 -0
  21. package/internal/server/access.go +155 -0
  22. package/internal/server/server.go +523 -0
  23. package/internal/server/static.go +165 -0
  24. package/internal/storage/cache.go +283 -0
  25. package/internal/storage/dir_linux.go +177 -0
  26. package/internal/storage/dir_windows.go +244 -0
  27. package/internal/storage/statat_fast.go +21 -0
  28. package/internal/storage/statat_other.go +11 -0
  29. package/internal/storage/storage.go +598 -0
  30. package/internal/storage/sysnum_amd64.go +7 -0
  31. package/internal/storage/sysnum_generic.go +7 -0
  32. package/internal/storage/walk.go +342 -0
  33. package/internal/vitals/vitals.go +246 -0
  34. package/internal/vitals/vitals_linux.go +548 -0
  35. package/internal/vitals/vitals_windows.go +286 -0
  36. package/main.go +129 -0
  37. package/main_other.go +11 -0
  38. package/main_windows.go +29 -0
  39. package/package.json +49 -0
  40. package/web/app.js +835 -0
  41. package/web/apple-touch-icon.png +0 -0
  42. package/web/icon.svg +6 -0
  43. package/web/index.html +151 -0
  44. package/web/style.css +356 -0
  45. package/web/treemap.js +217 -0
@@ -0,0 +1,523 @@
1
+ // Package server wires the poller, the storage scanner and the HTTP surface
2
+ // together.
3
+ package server
4
+
5
+ import (
6
+ "compress/gzip"
7
+ "context"
8
+ "encoding/json"
9
+ "fmt"
10
+ "io"
11
+ "io/fs"
12
+ "net/http"
13
+ "runtime"
14
+ "strconv"
15
+ "strings"
16
+ "sync"
17
+ "time"
18
+
19
+ "github.com/rene-roid/kanshi/internal/access"
20
+ "github.com/rene-roid/kanshi/internal/config"
21
+ "github.com/rene-roid/kanshi/internal/dockerstats"
22
+ "github.com/rene-roid/kanshi/internal/roots"
23
+ "github.com/rene-roid/kanshi/internal/storage"
24
+ "github.com/rene-roid/kanshi/internal/vitals"
25
+ )
26
+
27
+ // Frame is one push to the browser. Vitals and Docker are explicit nulls
28
+ // rather than omitted, because app.js tests each for truthiness. Storage is
29
+ // only the walk queue's status: the page refetches the folder it shows when
30
+ // a walk finishes, so nothing polls for it.
31
+ type Frame struct {
32
+ Vitals *vitals.Sample `json:"vitals"`
33
+ Docker *dockerstats.Result `json:"docker"`
34
+ Storage *storage.Status `json:"storage"`
35
+ Error string `json:"error,omitempty"`
36
+ }
37
+
38
+ // Server holds the shared state the poller writes and the handlers read.
39
+ type Server struct {
40
+ cfg config.Config
41
+ version string
42
+ logf func(format string, args ...any)
43
+ roots *roots.Resolver
44
+ vitals *vitals.Reader
45
+ docker *dockerstats.Client
46
+ storage *storage.Scanner
47
+ assets *assets
48
+
49
+ // base outlives any single request. Work that mutates shared state, such
50
+ // as a sample, is started under it rather than the request context, so a
51
+ // browser navigating away cannot abort it for everyone else.
52
+ base context.Context
53
+
54
+ mu sync.RWMutex
55
+ latest Frame
56
+ payload []byte // latest, already marshalled, for newly connected streams
57
+ subs map[chan []byte]struct{}
58
+ lastSee time.Time
59
+
60
+ // wake releases the poller from its idle sleep. Buffered by one so a
61
+ // signal is never lost and no sender ever blocks.
62
+ wake chan struct{}
63
+
64
+ // access is set once Run starts listening. accessSource is where the
65
+ // current mode came from, which decides whether the page may change it.
66
+ access *access.Manager
67
+ accessMu sync.Mutex
68
+ accessSource config.Source
69
+ saveOnce sync.Once
70
+ saveOK bool
71
+
72
+ // OnReady, when set, runs once the dashboard is listening.
73
+ OnReady func()
74
+ }
75
+
76
+ func New(cfg config.Config, web fs.FS, version string, logf func(string, ...any)) (*Server, error) {
77
+ a, err := loadAssets(web, cfg.WebDir != "")
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ r := roots.NewResolver(cfg.StorageRoots, cfg.HostRoot)
82
+ return &Server{
83
+ base: context.Background(),
84
+ cfg: cfg,
85
+ version: version,
86
+ logf: logf,
87
+ roots: r,
88
+ vitals: vitals.New(r),
89
+ docker: dockerstats.New(cfg),
90
+ storage: storage.New(cfg, r, logf),
91
+ assets: a,
92
+ subs: make(map[chan []byte]struct{}),
93
+ wake: make(chan struct{}, 1),
94
+ accessSource: cfg.AccessSource,
95
+ }, nil
96
+ }
97
+
98
+ /* ── polling ────────────────────────────────────────────────────────────── */
99
+
100
+ // sampleOnce takes one reading of both halves at the same moment. The vitals
101
+ // read is pure /proc work, so it runs alongside the Docker round trips rather
102
+ // than adding its latency to them.
103
+ func (s *Server) sampleOnce(ctx context.Context) Frame {
104
+ var host vitals.Sample
105
+ var containers dockerstats.Result
106
+
107
+ var wg sync.WaitGroup
108
+ wg.Add(1)
109
+ go func() {
110
+ defer wg.Done()
111
+ host = s.vitals.Sample()
112
+ }()
113
+ containers = s.docker.Sample(ctx)
114
+ wg.Wait()
115
+
116
+ scan := s.storage.Status()
117
+ frame := Frame{Vitals: &host, Docker: &containers, Storage: &scan}
118
+ payload, err := json.Marshal(frame)
119
+ if err != nil {
120
+ payload, _ = json.Marshal(Frame{Error: err.Error()})
121
+ }
122
+ s.mu.Lock()
123
+ s.latest, s.payload = frame, payload
124
+ s.mu.Unlock()
125
+ return frame
126
+ }
127
+
128
+ // seed fills the delta baselines so the first frame shows real numbers.
129
+ //
130
+ // Both CPU readings are differences against a previous sample, so a cold poller
131
+ // would otherwise publish a screen of zeros. One throwaway pass plus a short
132
+ // gap costs ~1s and makes the first frame the user sees correct.
133
+ func (s *Server) seed(ctx context.Context) {
134
+ s.vitals.Prime()
135
+ s.docker.Sample(ctx)
136
+ select {
137
+ case <-ctx.Done():
138
+ return
139
+ case <-time.After(time.Second):
140
+ }
141
+ s.vitals.Prime()
142
+ }
143
+
144
+ // Poll is the live loop. Once nobody has been connected for IdleTimeout it
145
+ // stops touching /proc and the Docker daemon entirely, and sleeps until a
146
+ // browser comes back.
147
+ func (s *Server) Poll(ctx context.Context) {
148
+ timer := time.NewTimer(time.Hour)
149
+ timer.Stop()
150
+ defer timer.Stop()
151
+
152
+ s.seed(ctx)
153
+ for ctx.Err() == nil {
154
+ if s.idle() {
155
+ // Nobody is watching. The timer is only a safety net: waking
156
+ // here does nothing but check again, and the baselines are only
157
+ // re-seeded once a browser has actually come back.
158
+ timer.Reset(time.Minute)
159
+ select {
160
+ case <-ctx.Done():
161
+ return
162
+ case <-timer.C:
163
+ case <-s.wake:
164
+ }
165
+ timer.Stop()
166
+ if !s.idle() {
167
+ s.seed(ctx)
168
+ }
169
+ continue
170
+ }
171
+
172
+ s.sampleOnce(ctx)
173
+ if ctx.Err() != nil {
174
+ return
175
+ }
176
+ s.mu.RLock()
177
+ payload := s.payload
178
+ s.mu.RUnlock()
179
+ s.broadcast(payload)
180
+
181
+ timer.Reset(s.cfg.PollInterval)
182
+ select {
183
+ case <-ctx.Done():
184
+ return
185
+ case <-timer.C:
186
+ }
187
+ }
188
+ }
189
+
190
+ func (s *Server) idle() bool {
191
+ s.mu.RLock()
192
+ defer s.mu.RUnlock()
193
+ return len(s.subs) == 0 && time.Since(s.lastSee) > s.cfg.IdleTimeout
194
+ }
195
+
196
+ // touch records browser activity and wakes an idle poller.
197
+ func (s *Server) touch() {
198
+ s.mu.Lock()
199
+ s.lastSee = time.Now()
200
+ s.mu.Unlock()
201
+ signal(s.wake)
202
+ }
203
+
204
+ func signal(ch chan struct{}) {
205
+ select {
206
+ case ch <- struct{}{}:
207
+ default: // already pending
208
+ }
209
+ }
210
+
211
+ func (s *Server) broadcast(payload []byte) {
212
+ s.mu.RLock()
213
+ defer s.mu.RUnlock()
214
+ for ch := range s.subs {
215
+ select {
216
+ case ch <- payload:
217
+ default:
218
+ // The subscriber is behind. Drop its stale frame and hand it the
219
+ // fresh one — a slow phone must never stall the poller.
220
+ select {
221
+ case <-ch:
222
+ default:
223
+ }
224
+ select {
225
+ case ch <- payload:
226
+ default:
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ /* ── handlers ───────────────────────────────────────────────────────────── */
233
+
234
+ func (s *Server) Handler() http.Handler {
235
+ mux := http.NewServeMux()
236
+ mux.HandleFunc("GET /api/vitals", s.handleVitals)
237
+ mux.HandleFunc("GET /api/containers", s.handleContainers)
238
+ mux.HandleFunc("GET /api/storage", s.handleStorage)
239
+ mux.HandleFunc("POST /api/storage/rescan", s.handleRescan)
240
+ mux.HandleFunc("GET /api/storage/dir", s.handleStorageDir)
241
+ mux.HandleFunc("GET /api/config", s.handleConfig)
242
+ mux.HandleFunc("GET /api/access", s.handleAccess)
243
+ mux.HandleFunc("POST /api/access", s.handleSetAccess)
244
+ mux.HandleFunc("GET /api/stream", s.handleStream)
245
+ mux.HandleFunc("GET /healthz", s.handleHealth)
246
+ mux.HandleFunc("GET /static/", s.assets.serveStatic)
247
+ mux.HandleFunc("GET /{$}", s.assets.serveIndex)
248
+ return mux
249
+ }
250
+
251
+ // Responses larger than this are gzipped for clients that accept it. Below
252
+ // it the header overhead eats the saving.
253
+ const gzipMin = 1024
254
+
255
+ var gzipPool = sync.Pool{New: func() any {
256
+ w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
257
+ return w
258
+ }}
259
+
260
+ func acceptsGzip(r *http.Request) bool {
261
+ return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
262
+ }
263
+
264
+ func writeJSON(w http.ResponseWriter, r *http.Request, v any) {
265
+ body, err := json.Marshal(v)
266
+ if err != nil {
267
+ http.Error(w, err.Error(), http.StatusInternalServerError)
268
+ return
269
+ }
270
+ h := w.Header()
271
+ h.Set("Content-Type", "application/json")
272
+ h.Set("Cache-Control", "no-cache")
273
+ h.Set("Vary", "Accept-Encoding")
274
+ if len(body) < gzipMin || !acceptsGzip(r) {
275
+ _, _ = w.Write(body)
276
+ return
277
+ }
278
+ h.Set("Content-Encoding", "gzip")
279
+ gz := gzipPool.Get().(*gzip.Writer)
280
+ gz.Reset(w)
281
+ _, _ = gz.Write(body)
282
+ _ = gz.Close()
283
+ gzipPool.Put(gz)
284
+ }
285
+
286
+ func (s *Server) handleVitals(w http.ResponseWriter, r *http.Request) {
287
+ s.touch()
288
+ s.mu.RLock()
289
+ latest := s.latest
290
+ s.mu.RUnlock()
291
+ if latest.Vitals == nil {
292
+ latest = s.sampleOnce(s.base)
293
+ }
294
+ writeJSON(w, r, latest.Vitals)
295
+ }
296
+
297
+ func (s *Server) handleContainers(w http.ResponseWriter, r *http.Request) {
298
+ s.touch()
299
+ s.mu.RLock()
300
+ latest := s.latest
301
+ s.mu.RUnlock()
302
+ if latest.Docker == nil {
303
+ latest = s.sampleOnce(s.base)
304
+ }
305
+ writeJSON(w, r, latest.Docker)
306
+ }
307
+
308
+ func (s *Server) handleStorage(w http.ResponseWriter, r *http.Request) {
309
+ writeJSON(w, r, s.storage.Snapshot())
310
+ }
311
+
312
+ // handleStorageDir reads one directory and sizes its subfolders from the
313
+ // cache: /api/storage/dir?root=0&path=home/yuuki. Subfolders the cache is
314
+ // missing come back pending, and are walked in the background.
315
+ func (s *Server) handleStorageDir(w http.ResponseWriter, r *http.Request) {
316
+ q := r.URL.Query()
317
+ root, err := strconv.Atoi(q.Get("root"))
318
+ if err != nil {
319
+ http.Error(w, "bad root", http.StatusBadRequest)
320
+ return
321
+ }
322
+ listing, ok := s.storage.List(root, q.Get("path"))
323
+ if !ok {
324
+ http.Error(w, "no such root", http.StatusNotFound)
325
+ return
326
+ }
327
+ writeJSON(w, r, listing)
328
+ }
329
+
330
+ // handleRescan re-walks the subfolders of one directory, named the same way
331
+ // as for handleStorageDir. It has to come from the dashboard itself: the
332
+ // custom header cannot be set by a plain form or an <img> on some other site,
333
+ // so a page you happen to visit cannot keep your disks busy.
334
+ func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
335
+ if !sameOrigin(r) {
336
+ http.Error(w, "forbidden", http.StatusForbidden)
337
+ return
338
+ }
339
+ q := r.URL.Query()
340
+ root, err := strconv.Atoi(q.Get("root"))
341
+ if err != nil {
342
+ http.Error(w, "bad root", http.StatusBadRequest)
343
+ return
344
+ }
345
+ status, ok := s.storage.Rescan(root, q.Get("path"))
346
+ if !ok {
347
+ http.Error(w, "no such root", http.StatusNotFound)
348
+ return
349
+ }
350
+ writeJSON(w, r, status)
351
+ }
352
+
353
+ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
354
+ writeJSON(w, r, map[string]any{
355
+ "version": s.version,
356
+ "os": runtime.GOOS,
357
+ "poll_interval": s.cfg.PollInterval.Seconds(),
358
+ "storage_interval": s.cfg.StorageInterval.Seconds(),
359
+ "tree_depth": s.cfg.TreeDepth,
360
+ })
361
+ }
362
+
363
+ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
364
+ writeJSON(w, r, map[string]bool{"ok": true})
365
+ }
366
+
367
+ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
368
+ flusher, ok := w.(http.Flusher)
369
+ if !ok {
370
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
371
+ return
372
+ }
373
+
374
+ ch := make(chan []byte, 1)
375
+ s.mu.Lock()
376
+ s.subs[ch] = struct{}{}
377
+ s.mu.Unlock()
378
+ s.touch()
379
+ defer func() {
380
+ s.mu.Lock()
381
+ delete(s.subs, ch)
382
+ s.mu.Unlock()
383
+ }()
384
+
385
+ h := w.Header()
386
+ h.Set("Content-Type", "text/event-stream")
387
+ h.Set("Cache-Control", "no-cache, no-transform")
388
+ h.Set("X-Accel-Buffering", "no") // tell any reverse proxy not to buffer us
389
+ h.Set("Connection", "keep-alive")
390
+
391
+ // One gzip stream for the life of the connection. Consecutive frames are
392
+ // nearly identical, and deflate's window spans several of them, so each
393
+ // frame after the first shrinks to a few hundred bytes — a fraction of
394
+ // what a phone on mobile data would otherwise pull every tick.
395
+ var out io.Writer = w
396
+ var gz *gzip.Writer
397
+ if acceptsGzip(r) {
398
+ h.Set("Content-Encoding", "gzip")
399
+ h.Set("Vary", "Accept-Encoding")
400
+ gz, _ = gzip.NewWriterLevel(w, gzip.BestSpeed)
401
+ defer gz.Close()
402
+ out = gz
403
+ }
404
+ w.WriteHeader(http.StatusOK)
405
+ send := func(chunks ...string) {
406
+ for _, c := range chunks {
407
+ _, _ = io.WriteString(out, c)
408
+ }
409
+ if gz != nil {
410
+ _ = gz.Flush()
411
+ }
412
+ flusher.Flush()
413
+ }
414
+
415
+ // Hand the newcomer the last frame immediately, so a reconnecting phone
416
+ // paints real numbers instead of waiting out a poll interval.
417
+ s.mu.RLock()
418
+ payload := s.payload
419
+ s.mu.RUnlock()
420
+ if payload != nil {
421
+ send("data: ", string(payload), "\n\n")
422
+ } else {
423
+ send(": hello\n\n") // gets the headers and the gzip header out now
424
+ }
425
+
426
+ keepalive := time.NewTicker(25 * time.Second)
427
+ defer keepalive.Stop()
428
+
429
+ for {
430
+ select {
431
+ case <-r.Context().Done():
432
+ return
433
+ case <-s.base.Done():
434
+ // A stream never ends on its own, so without this every shutdown
435
+ // would sit out the full Shutdown grace period waiting on it.
436
+ return
437
+ case payload := <-ch:
438
+ s.touch()
439
+ send("data: ", string(payload), "\n\n")
440
+ case <-keepalive.C:
441
+ // Keeps mobile proxies from closing an idle stream.
442
+ send(": keepalive\n\n")
443
+ }
444
+ }
445
+ }
446
+
447
+ /* ── lifecycle ──────────────────────────────────────────────────────────── */
448
+
449
+ // Run starts the background workers, listens according to mode, and serves
450
+ // until ctx is cancelled.
451
+ func (s *Server) Run(ctx context.Context, mode access.Mode) error {
452
+ // Assigned before the listener accepts anything, so no handler can observe
453
+ // the placeholder set in New.
454
+ s.base = ctx
455
+
456
+ srv := &http.Server{
457
+ Handler: s.Handler(),
458
+ // No write deadline: an SSE stream is meant to stay open indefinitely.
459
+ ReadHeaderTimeout: 10 * time.Second,
460
+ IdleTimeout: 65 * time.Second,
461
+ }
462
+ mgr := access.NewManager(s.cfg.Port, srv, s.logf)
463
+ if err := mgr.Apply(mode); err != nil {
464
+ return err
465
+ }
466
+ s.accessMu.Lock()
467
+ s.access = mgr
468
+ s.accessMu.Unlock()
469
+ s.banner(mode)
470
+ if s.OnReady != nil {
471
+ go s.OnReady()
472
+ }
473
+
474
+ var wg sync.WaitGroup
475
+ wg.Add(3)
476
+ go func() { defer wg.Done(); s.Poll(ctx) }()
477
+ go func() { defer wg.Done(); s.storage.Loop(ctx) }()
478
+ go func() { defer wg.Done(); mgr.Watch(ctx) }()
479
+
480
+ <-ctx.Done()
481
+
482
+ shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
483
+ defer cancel()
484
+ _ = srv.Shutdown(shutdown)
485
+ mgr.Close()
486
+ s.docker.Close()
487
+ wg.Wait()
488
+ s.storage.Close()
489
+ return nil
490
+ }
491
+
492
+ func (s *Server) banner(mode access.Mode) {
493
+ from := ""
494
+ switch s.accessSource {
495
+ case config.SourceFlag:
496
+ from = " (from --access)"
497
+ case config.SourceEnv:
498
+ from = " (from the environment)"
499
+ case config.SourceFile:
500
+ from = " (from " + s.cfg.File + ")"
501
+ }
502
+ fmt.Printf("kanshi %s — access: %s%s\n", s.version, mode, from)
503
+ for _, l := range s.access.Links() {
504
+ fmt.Printf(" %-10s %s\n", kindLabel(l.Kind)+":", l.URL)
505
+ }
506
+ if mode.All {
507
+ s.logf("warning: access \"all\" listens on every interface, and kanshi has no login")
508
+ }
509
+ }
510
+
511
+ func kindLabel(k access.Kind) string {
512
+ switch k {
513
+ case access.KindLocal:
514
+ return "Local"
515
+ case access.KindLAN:
516
+ return "LAN"
517
+ case access.KindTailscale:
518
+ return "Tailscale"
519
+ case access.KindCustom:
520
+ return "Address"
521
+ }
522
+ return "Other"
523
+ }
@@ -0,0 +1,165 @@
1
+ package server
2
+
3
+ import (
4
+ "bytes"
5
+ "compress/gzip"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "io/fs"
9
+ "net/http"
10
+ "path"
11
+ "strconv"
12
+ "strings"
13
+ )
14
+
15
+ // asset is one embedded file, prepared once at startup: hashed for its ETag
16
+ // and cache-busting URL, and gzipped ahead of time so no request pays for it.
17
+ type asset struct {
18
+ body []byte
19
+ gz []byte // nil when compression would not help (PNGs, tiny files)
20
+ etag string
21
+ ctype string
22
+ }
23
+
24
+ // assets serves the frontend.
25
+ //
26
+ // index.html refers to every other file as /static/<name>?v=<hash>. The hash
27
+ // changes whenever the file does, so those URLs can be cached forever and a
28
+ // returning phone downloads nothing but the page itself — which it only
29
+ // revalidates, with a 304 when nothing changed.
30
+ type assets struct {
31
+ dev bool // KANSHI_WEB_DIR: read from disk on every request instead
32
+ web fs.FS
33
+ index *asset
34
+ files map[string]*asset
35
+ }
36
+
37
+ func loadAssets(web fs.FS, dev bool) (*assets, error) {
38
+ a := &assets{dev: dev, web: web, files: make(map[string]*asset)}
39
+ if dev {
40
+ return a, nil
41
+ }
42
+ entries, err := fs.ReadDir(web, ".")
43
+ if err != nil {
44
+ return nil, err
45
+ }
46
+ for _, e := range entries {
47
+ if e.IsDir() || e.Name() == "index.html" {
48
+ continue
49
+ }
50
+ body, err := fs.ReadFile(web, e.Name())
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ a.files[e.Name()] = newAsset(e.Name(), body)
55
+ }
56
+
57
+ index, err := fs.ReadFile(web, "index.html")
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ for name, f := range a.files {
62
+ index = bytes.ReplaceAll(index, []byte(`"/static/`+name+`"`), []byte(`"/static/`+name+`?v=`+f.etag+`"`))
63
+ }
64
+ a.index = newAsset("index.html", index)
65
+ return a, nil
66
+ }
67
+
68
+ func newAsset(name string, body []byte) *asset {
69
+ sum := sha256.Sum256(body)
70
+ a := &asset{body: body, etag: hex.EncodeToString(sum[:8]), ctype: contentType(name)}
71
+ if strings.HasPrefix(a.ctype, "text/") || strings.HasPrefix(a.ctype, "image/svg") {
72
+ var buf bytes.Buffer
73
+ gz, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
74
+ _, _ = gz.Write(body)
75
+ _ = gz.Close()
76
+ if buf.Len() < len(body)*9/10 {
77
+ a.gz = buf.Bytes()
78
+ }
79
+ }
80
+ return a
81
+ }
82
+
83
+ // contentType is a fixed table rather than mime.TypeByExtension, which on
84
+ // Windows reads the registry — where a stray install can map .js to
85
+ // text/plain and stop the browser from running the script.
86
+ func contentType(name string) string {
87
+ switch path.Ext(name) {
88
+ case ".html":
89
+ return "text/html; charset=utf-8"
90
+ case ".css":
91
+ return "text/css; charset=utf-8"
92
+ case ".js":
93
+ return "text/javascript; charset=utf-8"
94
+ case ".svg":
95
+ return "image/svg+xml"
96
+ case ".png":
97
+ return "image/png"
98
+ case ".ico":
99
+ return "image/x-icon"
100
+ case ".json", ".webmanifest":
101
+ return "application/json"
102
+ }
103
+ return "application/octet-stream"
104
+ }
105
+
106
+ func (a *assets) serveIndex(w http.ResponseWriter, r *http.Request) {
107
+ if a.dev {
108
+ a.serveDev(w, r, "index.html")
109
+ return
110
+ }
111
+ a.write(w, r, a.index, false)
112
+ }
113
+
114
+ func (a *assets) serveStatic(w http.ResponseWriter, r *http.Request) {
115
+ name := strings.TrimPrefix(r.URL.Path, "/static/")
116
+ if a.dev {
117
+ a.serveDev(w, r, name)
118
+ return
119
+ }
120
+ f := a.files[name]
121
+ if f == nil {
122
+ http.NotFound(w, r)
123
+ return
124
+ }
125
+ a.write(w, r, f, r.URL.Query().Get("v") == f.etag)
126
+ }
127
+
128
+ func (a *assets) write(w http.ResponseWriter, r *http.Request, f *asset, immutable bool) {
129
+ h := w.Header()
130
+ h.Set("Content-Type", f.ctype)
131
+ h.Set("Vary", "Accept-Encoding")
132
+ if immutable {
133
+ h.Set("Cache-Control", "public, max-age=31536000, immutable")
134
+ } else {
135
+ h.Set("Cache-Control", "no-cache")
136
+ }
137
+ // Weak, because the gzipped and plain bodies share it.
138
+ h.Set("ETag", `W/"`+f.etag+`"`)
139
+ if strings.Contains(r.Header.Get("If-None-Match"), `"`+f.etag+`"`) {
140
+ w.WriteHeader(http.StatusNotModified)
141
+ return
142
+ }
143
+ body := f.body
144
+ if f.gz != nil && acceptsGzip(r) {
145
+ h.Set("Content-Encoding", "gzip")
146
+ body = f.gz
147
+ }
148
+ h.Set("Content-Length", strconv.Itoa(len(body)))
149
+ if r.Method != http.MethodHead {
150
+ _, _ = w.Write(body)
151
+ }
152
+ }
153
+
154
+ // serveDev reads straight from KANSHI_WEB_DIR, uncached, so an edit shows up
155
+ // on the next reload.
156
+ func (a *assets) serveDev(w http.ResponseWriter, r *http.Request, name string) {
157
+ body, err := fs.ReadFile(a.web, name)
158
+ if err != nil {
159
+ http.NotFound(w, r)
160
+ return
161
+ }
162
+ w.Header().Set("Content-Type", contentType(name))
163
+ w.Header().Set("Cache-Control", "no-store")
164
+ _, _ = w.Write(body)
165
+ }