repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

commit
affd3a6
parent
8c20044
author
Eric Bower
date
2024-07-19 00:27:17 -0400 EDT
feat: static assets folder

Create a static folder that will be served as-is with the ability for
users to bring-their-own static folder.

If we detect `data_dir/static/` we will serve that instead of the
embedded one we provide by default.
7 files changed,  +889, -4
M ssh.go
M web.go
M Makefile
+5, -0
1@@ -26,3 +26,8 @@ bp: bp-setup
2 	$(DOCKER_BUILDX_BUILD) -t "ghcr.io/picosh/pico/git-ssh:$(DOCKER_TAG)" --target release-ssh .
3 	$(DOCKER_BUILDX_BUILD) -t "ghcr.io/picosh/pico/git-web:$(DOCKER_TAG)" --target release-web .
4 .PHONY: bp
5+
6+smol:
7+	curl https://pico.sh/smol.css -o ./static/smol.css
8+	cat patches/smol.diff | git apply
9+.PHONY: smol
A patches/smol.diff
+53, -0
 1@@ -0,0 +1,53 @@
 2+diff --git a/static/smol.css b/static/smol.css
 3+index e9b59ec..9e9d925 100644
 4+--- a/static/smol.css
 5++++ b/static/smol.css
 6+@@ -15,48 +15,6 @@
 7+   box-shadow: none;
 8+ }
 9+ 
10+-@media (prefers-color-scheme: light) {
11+-  :root {
12+-    --main-hue: 250;
13+-    --white: #2e3f53;
14+-    --white-light: #cfe0f4;
15+-    --white-dark: #6c6a6a;
16+-    --code: #52576f;
17+-    --pre: #e1e7ee;
18+-    --bg-color: #f4f4f4;
19+-    --text-color: #24292f;
20+-    --link-color: #005cc5;
21+-    --visited: #6f42c1;
22+-    --blockquote: #005cc5;
23+-    --blockquote-bg: #cfe0f4;
24+-    --hover: #c11e7a;
25+-    --grey: #ccc;
26+-    --grey-light: #6a708e;
27+-    --shadow: #e8e8e8;
28+-  }
29+-}
30+-
31+-@media (prefers-color-scheme: dark) {
32+-  :root {
33+-    --main-hue: 250;
34+-    --white: #f2f2f2;
35+-    --white-light: #f2f2f2;
36+-    --white-dark: #e8e8e8;
37+-    --code: #414558;
38+-    --pre: #252525;
39+-    --bg-color: #282a36;
40+-    --text-color: #f2f2f2;
41+-    --link-color: #8be9fd;
42+-    --visited: #bd93f9;
43+-    --blockquote: #bd93f9;
44+-    --blockquote-bg: #353548;
45+-    --hover: #ff80bf;
46+-    --grey: #414558;
47+-    --grey-light: #6a708e;
48+-    --shadow: #252525;
49+-  }
50+-}
51+-
52+ html {
53+   background-color: var(--bg-color);
54+   color: var(--text-color);
M ssh.go
+1, -1
1@@ -35,7 +35,7 @@ func GitSshServer(cfg *GitCfg) {
2 	dbpath := filepath.Join(cfg.DataDir, "pr.db")
3 	dbh, err := Open(dbpath, cfg.Logger)
4 	if err != nil {
5-		panic(fmt.Sprintf("cannot find database file, check folder and perms: %s", dbpath))
6+		panic(fmt.Sprintf("cannot find database file, check folder and perms: %s: %s", dbpath, err))
7 	}
8 
9 	be := &Backend{
A static/smol.css
+726, -0
  1@@ -0,0 +1,726 @@
  2+*,
  3+::before,
  4+::after {
  5+  box-sizing: border-box;
  6+}
  7+
  8+::-moz-focus-inner {
  9+  border-style: none;
 10+  padding: 0;
 11+}
 12+:-moz-focusring {
 13+  outline: 1px dotted ButtonText;
 14+}
 15+:-moz-ui-invalid {
 16+  box-shadow: none;
 17+}
 18+
 19+html {
 20+  background-color: var(--bg-color);
 21+  color: var(--text-color);
 22+  font-size: 18px;
 23+  line-height: 1.5;
 24+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
 25+    Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Arial,
 26+    sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
 27+  -webkit-text-size-adjust: 100%;
 28+  -moz-tab-size: 4;
 29+  -o-tab-size: 4;
 30+  tab-size: 4;
 31+}
 32+
 33+body {
 34+  margin: 0 auto;
 35+}
 36+
 37+img {
 38+  max-width: 100%;
 39+  height: auto;
 40+}
 41+
 42+b,
 43+strong {
 44+  font-weight: bold;
 45+}
 46+
 47+code,
 48+kbd,
 49+samp,
 50+pre {
 51+  font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
 52+    monospace;
 53+}
 54+
 55+code,
 56+kbd,
 57+samp {
 58+  border: 2px solid var(--code);
 59+}
 60+
 61+pre > code {
 62+  background-color: inherit;
 63+  padding: 0;
 64+  border: none;
 65+}
 66+
 67+code {
 68+  font-size: 90%;
 69+  border-radius: 0.3rem;
 70+  padding: 0.1rem 0.3rem;
 71+}
 72+
 73+pre {
 74+  font-size: 14px;
 75+  border-radius: 5px;
 76+  padding: 1rem;
 77+  margin: 1rem 0;
 78+  overflow-x: auto;
 79+  background-color: var(--pre) !important;
 80+}
 81+
 82+small {
 83+  font-size: 0.8rem;
 84+}
 85+
 86+summary {
 87+  display: list-item;
 88+  cursor: pointer;
 89+}
 90+
 91+h1,
 92+h2,
 93+h3,
 94+h4 {
 95+  margin: 0;
 96+  padding: 0.5rem 0 0 0;
 97+  border: 0;
 98+  font-style: normal;
 99+  font-weight: inherit;
100+  font-size: inherit;
101+}
102+
103+path {
104+  fill: var(--text-color);
105+  stroke: var(--text-color);
106+}
107+
108+hr {
109+  color: inherit;
110+  border: 0;
111+  margin: 0;
112+  height: 2px;
113+  background: var(--grey);
114+  margin: 1rem auto;
115+  text-align: center;
116+}
117+
118+a {
119+  text-decoration: none;
120+  color: var(--link-color);
121+}
122+
123+a:hover,
124+a:visited:hover {
125+  text-decoration: underline;
126+  color: var(--hover);
127+}
128+
129+a:visited {
130+  color: var(--visited);
131+}
132+
133+a.link-grey {
134+  text-decoration: underline;
135+  color: var(--white);
136+}
137+
138+a.link-grey:visited {
139+  color: var(--white);
140+}
141+
142+section {
143+  margin-bottom: 1.4rem;
144+}
145+
146+section:last-child {
147+  margin-bottom: 0;
148+}
149+
150+header {
151+  margin: 1rem auto;
152+}
153+
154+p {
155+  margin: 0.5rem 0;
156+}
157+
158+article {
159+  overflow-wrap: break-word;
160+}
161+
162+blockquote {
163+  border-left: 5px solid var(--blockquote);
164+  background-color: var(--blockquote-bg);
165+  padding: 0.5rem 0.75rem;
166+  margin: 0.5rem 0;
167+}
168+
169+blockquote > p {
170+  margin: 0;
171+}
172+
173+blockquote code {
174+  border: 1px solid var(--blockquote);
175+}
176+
177+ul,
178+ol {
179+  padding: 0 0 0 1rem;
180+  list-style-position: outside;
181+}
182+
183+ul[style*="list-style-type: none;"] {
184+  padding: 0;
185+}
186+
187+li {
188+  margin: 0.5rem 0;
189+}
190+
191+li > pre {
192+  padding: 0;
193+}
194+
195+footer {
196+  text-align: center;
197+  margin-bottom: 4rem;
198+}
199+
200+dt {
201+  font-weight: bold;
202+}
203+
204+dd {
205+  margin-left: 0;
206+}
207+
208+dd:not(:last-child) {
209+  margin-bottom: 0.5rem;
210+}
211+
212+figure {
213+  margin: 0;
214+}
215+
216+.container {
217+  max-width: 50em;
218+  width: 100%;
219+}
220+
221+.container-sm {
222+  max-width: 40em;
223+  width: 100%;
224+}
225+
226+.container-center {
227+  width: 100%;
228+  height: 100%;
229+  display: flex;
230+  justify-content: center;
231+}
232+
233+.mono {
234+  font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
235+    monospace;
236+}
237+
238+.link-alt-adj,
239+.link-alt-adj:visited,
240+.link-alt-adj:visited:hover,
241+.link-alt-adj:hover {
242+  color: var(--link-color);
243+  text-decoration: none;
244+}
245+
246+.link-alt-adj:visited:hover,
247+.link-alt-adj:hover {
248+  text-decoration: underline;
249+}
250+
251+.link-alt-hover,
252+.link-alt-hover:visited,
253+.link-alt-hover:visited:hover,
254+.link-alt-hover:hover {
255+  color: var(--hover);
256+  text-decoration: none;
257+}
258+
259+.link-alt-hover:visited:hover,
260+.link-alt-hover:hover {
261+  text-decoration: underline;
262+}
263+
264+.link-alt,
265+.link-alt:visited,
266+.link-alt:visited:hover,
267+.link-alt:hover {
268+  color: var(--white);
269+  text-decoration: none;
270+}
271+
272+.link-alt:visited:hover,
273+.link-alt:hover {
274+  text-decoration: underline;
275+}
276+
277+.text-3xl {
278+  font-size: 2.5rem;
279+}
280+
281+.text-2xl {
282+  font-size: 1.9rem;
283+  line-height: 1.15;
284+}
285+
286+.text-xl {
287+  font-size: 1.55rem;
288+  line-height: 1.15;
289+}
290+
291+.text-lg {
292+  font-size: 1.35rem;
293+  line-height: 1.15;
294+}
295+
296+.text-md {
297+  font-size: 1.15rem;
298+  line-height: 1.15;
299+}
300+
301+.text-sm {
302+  font-size: 0.875rem;
303+}
304+
305+.text-xs {
306+  font-size: 0.775rem;
307+}
308+
309+.cursor-pointer {
310+  cursor: pointer;
311+}
312+
313+.w-full {
314+  width: 100%;
315+}
316+
317+.h-full {
318+  height: 100%;
319+}
320+
321+.border {
322+  border: 2px solid var(--grey-light);
323+}
324+
325+.text-left {
326+  text-align: left;
327+}
328+
329+.text-center {
330+  text-align: center;
331+}
332+
333+.text-underline {
334+  border-bottom: 3px solid var(--text-color);
335+  padding-bottom: 3px;
336+}
337+
338+.text-hdr {
339+  color: var(--hover);
340+}
341+
342+.text-underline-hdr {
343+  border-bottom: 3px solid var(--hover);
344+  padding-bottom: 3px;
345+}
346+
347+.font-bold {
348+  font-weight: bold;
349+}
350+
351+.font-italic {
352+  font-style: italic;
353+}
354+
355+.inline {
356+  display: inline;
357+}
358+
359+.inline-block {
360+  display: inline-block;
361+}
362+
363+.max-w-half {
364+  max-width: 50%;
365+}
366+
367+.h-screen {
368+  height: 100vh;
369+}
370+
371+.w-screen {
372+  width: 100vw;
373+}
374+
375+.flex {
376+  display: flex;
377+}
378+
379+.flex-col {
380+  flex-direction: column;
381+}
382+
383+.items-center {
384+  align-items: center;
385+}
386+
387+.m-0 {
388+  margin: 0;
389+}
390+
391+.mt {
392+  margin-top: 0.5rem;
393+}
394+
395+.mt-2 {
396+  margin-top: 1rem;
397+}
398+
399+.mt-4 {
400+  margin-top: 2rem;
401+}
402+
403+.mt-8 {
404+  margin-top: 4rem;
405+}
406+
407+.mb {
408+  margin-bottom: 0.5rem;
409+}
410+
411+.mb-2 {
412+  margin-bottom: 1rem;
413+}
414+
415+.mb-4 {
416+  margin-bottom: 2rem;
417+}
418+
419+.mb-8 {
420+  margin-bottom: 4rem;
421+}
422+
423+.mb-16 {
424+  margin-bottom: 8rem;
425+}
426+
427+.mr {
428+  margin-right: 0.5rem;
429+}
430+
431+.ml-sm {
432+  margin-left: 0.25rem;
433+}
434+
435+.ml {
436+  margin-left: 0.5rem;
437+}
438+
439+.pt-0 {
440+  padding-top: 0;
441+}
442+
443+.my {
444+  margin-top: 0.5rem;
445+  margin-bottom: 0.5rem;
446+}
447+
448+.my-2 {
449+  margin-top: 1rem;
450+  margin-bottom: 1rem;
451+}
452+
453+.my-4 {
454+  margin-top: 2rem;
455+  margin-bottom: 2rem;
456+}
457+
458+.my-8 {
459+  margin-top: 4rem;
460+  margin-bottom: 4rem;
461+}
462+
463+.mx {
464+  margin-left: 0.5rem;
465+  margin-right: 0.5rem;
466+}
467+
468+.mx-2 {
469+  margin-left: 1rem;
470+  margin-right: 1rem;
471+}
472+
473+.m-1 {
474+  margin: 0.5rem;
475+}
476+
477+.p-1 {
478+  padding: 0.5rem;
479+}
480+
481+.p-0 {
482+  padding: 0;
483+}
484+
485+.px-2 {
486+  padding-left: 1rem;
487+  padding-right: 1rem;
488+}
489+
490+.px-4 {
491+  padding-left: 2rem;
492+  padding-right: 2rem;
493+}
494+
495+.py {
496+  padding-top: 0.5rem;
497+  padding-bottom: 0.5rem;
498+}
499+
500+.py-2 {
501+  padding-top: 1rem;
502+  padding-bottom: 1rem;
503+}
504+
505+.py-4 {
506+  padding-top: 2rem;
507+  padding-bottom: 2rem;
508+}
509+
510+.py-8 {
511+  padding-top: 4rem;
512+  padding-bottom: 4rem;
513+}
514+
515+.justify-between {
516+  justify-content: space-between;
517+}
518+
519+.justify-center {
520+  justify-content: center;
521+}
522+
523+.gap {
524+  gap: 0.5rem;
525+}
526+
527+.gap-2 {
528+  gap: 1rem;
529+}
530+
531+.group {
532+  display: flex;
533+  flex-direction: column;
534+  gap: 0.5rem;
535+}
536+
537+.group-2 {
538+  display: flex;
539+  flex-direction: column;
540+  gap: 1rem;
541+}
542+
543+.group-h {
544+  display: flex;
545+  gap: 0.5rem;
546+  align-items: center;
547+}
548+
549+.flex-1 {
550+  flex: 1;
551+}
552+
553+.items-end {
554+  align-items: end;
555+}
556+
557+.items-start {
558+  align-items: start;
559+}
560+
561+.justify-end {
562+  justify-content: end;
563+}
564+
565+.font-grey-light {
566+  color: var(--grey-light);
567+}
568+
569+.hidden {
570+  display: none;
571+}
572+
573+.align-right {
574+  text-align: right;
575+}
576+
577+/* ==== MARKDOWN ==== */
578+
579+.md h1,
580+.md h2,
581+.md h3,
582+.md h4 {
583+  padding: 0;
584+  margin: 1.5rem 0 0.9rem 0;
585+  font-weight: bold;
586+}
587+
588+.md h1 a,
589+.md h2 a,
590+.md h3 a,
591+.md h4 a {
592+  color: var(--grey-light);
593+  text-decoration: none;
594+}
595+
596+.md h1 {
597+  font-size: 1.6rem;
598+  line-height: 1.15;
599+  border-bottom: 2px solid var(--grey);
600+  padding-bottom: 0.7rem;
601+}
602+
603+.md h2 {
604+  font-size: 1.3rem;
605+  line-height: 1.15;
606+  color: var(--white-dark);
607+}
608+
609+.md h3 {
610+  font-size: 1.2rem;
611+  color: var(--white-dark);
612+}
613+
614+.md h4 {
615+  font-size: 1rem;
616+  color: var(--white-dark);
617+}
618+
619+/* ==== HELPERS ==== */
620+
621+.logo-header {
622+  line-height: 1;
623+  display: inline-block;
624+  background-color: #FF79C6;
625+  background-image: linear-gradient(to right, #FF5555, #FF79C6, #F8F859);
626+  color: transparent;
627+  background-clip: text;
628+  border: 3px solid #FF79C6;
629+  padding: 8px 10px 10px 10px;
630+  border-radius: 10px;
631+  box-shadow: 0px 5px 0px 0px var(--shadow);
632+  background-size: 100%;
633+  -webkit-background-clip: text;
634+  -moz-background-clip: text;
635+  -webkit-text-fill-color: transparent;
636+  -moz-text-fill-color: transparent;
637+}
638+
639+.btn {
640+  border: 2px solid var(--link-color);
641+  color: var(--link-color);
642+  padding: 0.4rem 1rem;
643+  font-weight: bold;
644+  display: inline-block;
645+}
646+
647+.btn-link,
648+.btn-link:visited {
649+  border: 2px solid var(--link-color);
650+  color: var(--link-color);
651+  padding: 0.4rem 1rem;
652+  text-decoration: none;
653+  font-weight: bold;
654+  display: inline-block;
655+}
656+
657+.btn-link:visited:hover,
658+.btn-link:hover {
659+  border: 2px solid var(--hover);
660+}
661+
662+.btn-link-alt,
663+.btn-link-alt:visited {
664+  border: 2px solid var(--white);
665+  color: var(--white);
666+}
667+
668+.box {
669+  border: 2px solid var(--grey-light);
670+  padding: 0.5rem 0.75rem;
671+}
672+
673+.box-sm {
674+  border: 2px solid var(--grey-light);
675+  padding: 0.15rem 0.35rem;
676+}
677+
678+.box-alert {
679+  border: 2px solid var(--hover);
680+  padding: 0.5rem 0.75rem;
681+}
682+
683+.box-sm-alert {
684+  border: 2px solid var(--hover);
685+  padding: 0.15rem 0.35rem;
686+}
687+
688+.list-none {
689+  list-style-type: none;
690+}
691+
692+.list-disc {
693+  list-style-type: disc;
694+}
695+
696+.list-decimal {
697+  list-style-type: decimal;
698+}
699+
700+.pill {
701+  border: 1px solid var(--link-color);
702+  color: var(--link-color);
703+}
704+
705+.pill-alert {
706+  border: 1px solid var(--hover);
707+  color: var(--hover);
708+}
709+
710+.pill-info {
711+  border: 1px solid var(--visited);
712+  color: var(--visited);
713+}
714+
715+@media only screen and (max-width: 40em) {
716+  body {
717+    padding: 0 1rem;
718+  }
719+
720+  header {
721+    margin: 0;
722+  }
723+
724+  .flex-collapse {
725+    flex-direction: column;
726+  }
727+}
A static/vars.css
+18, -0
 1@@ -0,0 +1,18 @@
 2+:root {
 3+  --main-hue: 250;
 4+  --white: #f2f2f2;
 5+  --white-light: #f2f2f2;
 6+  --white-dark: #e8e8e8;
 7+  --code: #414558;
 8+  --pre: #252525;
 9+  --bg-color: #282a36;
10+  --text-color: #f2f2f2;
11+  --link-color: #8be9fd;
12+  --visited: #bd93f9;
13+  --blockquote: #bd93f9;
14+  --blockquote-bg: #353548;
15+  --hover: #ff80bf;
16+  --grey: #414558;
17+  --grey-light: #6a708e;
18+  --shadow: #252525;
19+}
M tmpl/base.html
+3, -2
 1@@ -9,8 +9,9 @@
 2     <meta name="keywords" content="git, collaboration, patch, requests" />
 3     {{template "meta" .}}
 4 
 5-    <link rel="stylesheet" href="https://pico.sh/smol.css" />
 6-    <link rel="stylesheet" href="https://pico.sh/syntax.css" />
 7+    <link rel="stylesheet" href="/static/smol.css" />
 8+    <link rel="stylesheet" href="/static/vars.css" />
 9+    <link rel="stylesheet" href="/syntax.css" />
10   </head>
11   <body class="container">{{template "body" .}}</body>
12 </html>
M web.go
+83, -1
  1@@ -6,8 +6,12 @@ import (
  2 	"embed"
  3 	"fmt"
  4 	"html/template"
  5+	"io"
  6+	"io/fs"
  7 	"log/slog"
  8+	"mime"
  9 	"net/http"
 10+	"os"
 11 	"path/filepath"
 12 	"slices"
 13 	"strconv"
 14@@ -23,6 +27,9 @@ import (
 15 //go:embed tmpl/*
 16 var tmplFS embed.FS
 17 
 18+//go:embed static/*
 19+var embedStaticFS embed.FS
 20+
 21 type WebCtx struct {
 22 	Pr        *PrCmd
 23 	Backend   *Backend
 24@@ -618,13 +625,81 @@ func chromaStyleHandler(w http.ResponseWriter, r *http.Request) {
 25 	}
 26 }
 27 
 28+func serveFile(userfs fs.FS, embedfs fs.FS) func(w http.ResponseWriter, r *http.Request) {
 29+	return func(w http.ResponseWriter, r *http.Request) {
 30+		web, err := getWebCtx(r)
 31+		if err != nil {
 32+			w.WriteHeader(http.StatusUnprocessableEntity)
 33+			return
 34+		}
 35+		logger := web.Logger
 36+
 37+		file := r.PathValue("file")
 38+
 39+		logger.Info("serving file", "file", file)
 40+		// merging both embedded fs and whatever user provides
 41+		var reader fs.File
 42+		if userfs == nil {
 43+			reader, err = embedfs.Open(file)
 44+		} else {
 45+			reader, err = userfs.Open(file)
 46+			if err != nil {
 47+				// serve embeded static folder
 48+				reader, err = embedfs.Open(file)
 49+			}
 50+		}
 51+
 52+		if err != nil {
 53+			logger.Error(err.Error())
 54+			http.Error(w, "file not found", 404)
 55+			return
 56+		}
 57+
 58+		contents, err := io.ReadAll(reader)
 59+		if err != nil {
 60+			logger.Error(err.Error())
 61+			http.Error(w, "file not found", 404)
 62+			return
 63+		}
 64+		contentType := mime.TypeByExtension(filepath.Ext(file))
 65+		if contentType == "" {
 66+			contentType = http.DetectContentType(contents)
 67+		}
 68+		w.Header().Add("Content-Type", contentType)
 69+
 70+		_, err = w.Write(contents)
 71+		if err != nil {
 72+			logger.Error(err.Error())
 73+			http.Error(w, "server error", 500)
 74+			return
 75+		}
 76+	}
 77+}
 78+
 79+func getUserDefinedFS(datadir, dirName string) fs.FS {
 80+	dir := filepath.Join(datadir, dirName)
 81+	_, err := os.Stat(dir)
 82+	if err != nil {
 83+		return nil
 84+	}
 85+	return os.DirFS(dir)
 86+}
 87+
 88+func getEmbedFS(ffs embed.FS, dirName string) (fs.FS, error) {
 89+	fsys, err := fs.Sub(ffs, dirName)
 90+	if err != nil {
 91+		return nil, err
 92+	}
 93+	return fsys, nil
 94+}
 95+
 96 func StartWebServer(cfg *GitCfg) {
 97 	addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
 98 
 99 	dbpath := filepath.Join(cfg.DataDir, "pr.db")
100 	dbh, err := Open(dbpath, cfg.Logger)
101 	if err != nil {
102-		panic(fmt.Sprintf("cannot find database file, check folder and perms: %s", dbpath))
103+		panic(fmt.Sprintf("cannot find database file, check folder and perms: %s: %s", dbpath, err))
104 	}
105 
106 	be := &Backend{
107@@ -659,6 +734,13 @@ func StartWebServer(cfg *GitCfg) {
108 	http.HandleFunc("GET /", ctxMdw(ctx, repoListHandler))
109 	http.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
110 	http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
111+	embedFS, err := getEmbedFS(embedStaticFS, "static")
112+	if err != nil {
113+		panic(err)
114+	}
115+	userFS := getUserDefinedFS(cfg.DataDir, "static")
116+
117+	http.HandleFunc("GET /static/{file}", ctxMdw(ctx, serveFile(userFS, embedFS)))
118 
119 	cfg.Logger.Info("starting web server", "addr", addr)
120 	err = http.ListenAndServe(addr, nil)