Skip to content

Commit 18a9b92

Browse files
committed
Give the dashboard a sidebar with four screens and its own fonts
The page is now a shell: a sidebar with the logo, Overview, Rewards (with the blocks-won count), Network and Settings, and a node card at the bottom. Hash routes switch screens without reloading. app.js is the shared core (requests, polling, formatting, cards, router, sidebar) and each screen builds its own cards in web/static/screens/, showing the same data and controls as before. Instrument Sans and IBM Plex Mono (latin, with their OFL licences) are served from /static/fonts/ behind the dashboard key, and the CSP allows fonts from the dashboard itself. The page makes no outside requests and stacks the sidebar above the content at phone width.
1 parent ce719f1 commit 18a9b92

19 files changed

Lines changed: 1698 additions & 669 deletions

‎internal/server/controls_test.go‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,16 +432,15 @@ func TestControlsRefused(t *testing.T) {
432432

433433
func TestPageHasControls(t *testing.T) {
434434
h := newHandler(t, nil)
435-
page := do(h, http.MethodGet, "/", goodHost, withSession).Body.String()
436-
for _, want := range []string{`id="cores-input"`, `type="range"`, `id="mining-pause"`, `id="mining-resume"`, "mining-paused", "restart resumes mining"} {
437-
if !strings.Contains(page, want) {
438-
t.Errorf("page lacks %q", want)
435+
js := screenJS(t, h)
436+
for _, want := range []string{`id: "cores-input"`, `type: "range"`, `id: "mining-pause"`, `id: "mining-resume"`, "mining-paused", "restart resumes mining"} {
437+
if !strings.Contains(js, want) {
438+
t.Errorf("screen files lack %q", want)
439439
}
440440
}
441-
js := do(h, http.MethodGet, "/static/app.js", goodHost, withSession).Body.String()
442441
for _, api := range []string{"/api/mining/cores", "/api/mining/pause", "/api/mining/resume"} {
443442
if !strings.Contains(js, `"`+api+`"`) {
444-
t.Errorf("app.js does not post to %s", api)
443+
t.Errorf("screen files do not post to %s", api)
445444
}
446445
}
447446
}

‎internal/server/server.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func sessionValue(key string) string {
164164

165165
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
166166
hdr := w.Header()
167-
hdr.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
167+
hdr.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
168168
hdr.Set("X-Content-Type-Options", "nosniff")
169169
hdr.Set("X-Frame-Options", "DENY")
170170
hdr.Set("Referrer-Policy", "no-referrer")

‎internal/server/server_test.go‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,16 +371,24 @@ func TestPageBranding(t *testing.T) {
371371
t.Errorf("style.css lacks %q", want)
372372
}
373373
}
374-
if strings.Contains(css, "@font-face") || strings.Contains(css, "url(") {
375-
t.Error("style.css downloads fonts or other files")
374+
// The only files the stylesheet loads are the dashboard's own fonts.
375+
urls := strings.Split(css, "url(")
376+
if len(urls) < 2 {
377+
t.Error("style.css loads no fonts")
378+
}
379+
for _, rest := range urls[1:] {
380+
if !strings.HasPrefix(strings.TrimLeft(rest, `"'`), "/static/fonts/") {
381+
t.Errorf("style.css loads a file outside /static/fonts/: url(%.40s", rest)
382+
}
376383
}
377-
js := do(h, http.MethodGet, "/static/app.js", goodHost, withSession).Body.String()
378-
if !strings.Contains(js, "5000") {
384+
core := do(h, http.MethodGet, "/static/app.js", goodHost, withSession).Body.String()
385+
if !strings.Contains(core, "5000") {
379386
t.Error("app.js does not refresh every 5 seconds")
380387
}
388+
js := core + screenJS(t, h)
381389
for _, api := range []string{"/api/node", "/api/mining", "/api/network"} {
382390
if !strings.Contains(js, `"`+api+`"`) {
383-
t.Errorf("app.js does not poll %s", api)
391+
t.Errorf("app.js and the screen files do not poll %s", api)
384392
}
385393
}
386394
}

‎internal/server/settings_test.go‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -377,16 +377,15 @@ func TestServiceControlsRefused(t *testing.T) {
377377

378378
func TestPageHasSettings(t *testing.T) {
379379
h := newHandler(t, nil)
380-
page := do(h, http.MethodGet, "/", goodHost, withSession).Body.String()
381-
for _, want := range []string{`id="payout-input"`, `id="node-restart"`, `id="updates-run"`, `id="updates-on"`, `id="updates-off"`, `id="updates-output"`} {
382-
if !strings.Contains(page, want) {
383-
t.Errorf("page lacks %q", want)
380+
js := screenJS(t, h)
381+
for _, id := range []string{"payout-input", "node-restart", "updates-run", "updates-on", "updates-off", "updates-output"} {
382+
if !strings.Contains(js, `"`+id+`"`) {
383+
t.Errorf("screen files lack the element %q", id)
384384
}
385385
}
386-
js := do(h, http.MethodGet, "/static/app.js", goodHost, withSession).Body.String()
387386
for _, api := range []string{"/api/mining/payout", "/api/node/restart", "/api/updates", "/api/updates/run", "/api/updates/on", "/api/updates/off"} {
388387
if !strings.Contains(js, `"`+api+`"`) {
389-
t.Errorf("app.js does not use %s", api)
388+
t.Errorf("screen files do not use %s", api)
390389
}
391390
}
392391
}

‎internal/server/shell_test.go‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package server
2+
3+
import (
4+
"net/http"
5+
"regexp"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// screenFiles are the per-screen scripts the page loads after app.js.
11+
var screenFiles = []string{
12+
"/static/screens/overview.js",
13+
"/static/screens/rewards.js",
14+
"/static/screens/network.js",
15+
"/static/screens/settings.js",
16+
}
17+
18+
var fontFiles = []string{
19+
"/static/fonts/instrument-sans-latin-400-normal.woff2",
20+
"/static/fonts/instrument-sans-latin-500-normal.woff2",
21+
"/static/fonts/instrument-sans-latin-600-normal.woff2",
22+
"/static/fonts/ibm-plex-mono-latin-400-normal.woff2",
23+
"/static/fonts/ibm-plex-mono-latin-500-normal.woff2",
24+
"/static/fonts/instrument-sans-OFL.txt",
25+
"/static/fonts/ibm-plex-mono-OFL.txt",
26+
}
27+
28+
// getBody fetches target with the key cookie and fails unless it is served.
29+
func getBody(t *testing.T, h http.Handler, target string) string {
30+
t.Helper()
31+
rec := do(h, http.MethodGet, target, goodHost, withSession)
32+
if rec.Code != http.StatusOK {
33+
t.Fatalf("%s: status %d, want 200", target, rec.Code)
34+
}
35+
return rec.Body.String()
36+
}
37+
38+
// screenJS is every screen file, one after the other.
39+
func screenJS(t *testing.T, h http.Handler) string {
40+
t.Helper()
41+
var b strings.Builder
42+
for _, f := range screenFiles {
43+
b.WriteString(getBody(t, h, f))
44+
b.WriteString("\n")
45+
}
46+
return b.String()
47+
}
48+
49+
func TestShellPage(t *testing.T) {
50+
h := newHandler(t, nil)
51+
page := getBody(t, h, "/")
52+
for _, want := range []string{
53+
`href="#/overview"`,
54+
`href="#/rewards"`,
55+
`href="#/network"`,
56+
`href="#/settings"`,
57+
`src="/static/app.js"`,
58+
`name="viewport"`,
59+
} {
60+
if !strings.Contains(page, want) {
61+
t.Errorf("page lacks %q", want)
62+
}
63+
}
64+
for _, f := range screenFiles {
65+
if !strings.Contains(page, `src="`+f+`"`) {
66+
t.Errorf("page does not load %s", f)
67+
}
68+
}
69+
// app.js comes first, so the screens can register with GhostDash.
70+
core := strings.Index(page, `src="/static/app.js"`)
71+
for _, f := range screenFiles {
72+
if i := strings.Index(page, `src="`+f+`"`); i >= 0 && i < core {
73+
t.Errorf("%s is loaded before app.js", f)
74+
}
75+
}
76+
// The CSP refuses inline scripts and styles: every script has a src and
77+
// an empty body, and there is no <style> or style attribute.
78+
scripts := regexp.MustCompile(`(?is)<script([^>]*)>(.*?)</script>`).FindAllStringSubmatch(page, -1)
79+
if len(scripts) != 1+len(screenFiles) {
80+
t.Errorf("page has %d scripts, want %d", len(scripts), 1+len(screenFiles))
81+
}
82+
for _, s := range scripts {
83+
if !strings.Contains(s[1], "src=") || !strings.Contains(s[1], "defer") {
84+
t.Errorf("script without src or defer: %q", s[0])
85+
}
86+
if strings.TrimSpace(s[2]) != "" {
87+
t.Errorf("inline script body: %q", s[2])
88+
}
89+
}
90+
lower := strings.ToLower(page)
91+
if strings.Contains(lower, "<style") {
92+
t.Error("page has a <style> element")
93+
}
94+
if strings.Contains(lower, " style=") {
95+
t.Error("page has a style attribute")
96+
}
97+
if !strings.Contains(page, `<main`) {
98+
t.Error("page has no <main> for the screens")
99+
}
100+
101+
css := getBody(t, h, "/static/style.css")
102+
for _, want := range []string{
103+
"--ink: #0E0D1A",
104+
"--violet: #6B4CFF",
105+
"--violet-tint: #EEEAFF",
106+
"--mist: #F5F5F8",
107+
"--line: #E4E3EC",
108+
"--body: #4A4860",
109+
"--quiet: #5F5D73",
110+
"--quiet-2: #6E6C82",
111+
"--mint: #4FF0C8",
112+
"--white: #FFFFFF",
113+
"@media (max-width",
114+
"min-height: 44px",
115+
"240px",
116+
} {
117+
if !strings.Contains(css, want) {
118+
t.Errorf("style.css lacks %q", want)
119+
}
120+
}
121+
122+
// Each screen registers itself with the core for its own route.
123+
js := getBody(t, h, "/static/app.js")
124+
if !strings.Contains(js, "GhostDash") {
125+
t.Error("app.js does not define GhostDash")
126+
}
127+
for _, name := range []string{"overview", "rewards", "network", "settings"} {
128+
body := getBody(t, h, "/static/screens/"+name+".js")
129+
if !strings.Contains(body, `GhostDash`) || !strings.Contains(body, `.screen("`+name+`"`) {
130+
t.Errorf("%s.js does not register the %s screen", name, name)
131+
}
132+
}
133+
}
134+
135+
func TestScreenFilesServed(t *testing.T) {
136+
h := newHandler(t, nil)
137+
for _, f := range append([]string{"/static/app.js"}, screenFiles...) {
138+
rec := do(h, http.MethodGet, f, goodHost, withSession)
139+
if rec.Code != http.StatusOK {
140+
t.Errorf("%s with cookie: status %d, want 200", f, rec.Code)
141+
} else if rec.Body.Len() == 0 {
142+
t.Errorf("%s is empty", f)
143+
}
144+
rec = do(h, http.MethodGet, f, goodHost, nil)
145+
if rec.Code != http.StatusUnauthorized {
146+
t.Errorf("%s without cookie: status %d, want 401", f, rec.Code)
147+
}
148+
}
149+
}
150+
151+
func TestFontsServedBehindKey(t *testing.T) {
152+
h := newHandler(t, nil)
153+
for _, f := range fontFiles {
154+
rec := do(h, http.MethodGet, f, goodHost, withSession)
155+
if rec.Code != http.StatusOK {
156+
t.Errorf("%s with cookie: status %d, want 200", f, rec.Code)
157+
continue
158+
}
159+
body := rec.Body.Bytes()
160+
if strings.HasSuffix(f, ".woff2") && !strings.HasPrefix(string(body), "wOF2") {
161+
t.Errorf("%s does not start with wOF2", f)
162+
}
163+
if strings.HasSuffix(f, "-OFL.txt") && !strings.Contains(string(body), "SIL Open Font License") {
164+
t.Errorf("%s is not the SIL Open Font License", f)
165+
}
166+
rec = do(h, http.MethodGet, f, goodHost, nil)
167+
if rec.Code != http.StatusUnauthorized {
168+
t.Errorf("%s without cookie: status %d, want 401", f, rec.Code)
169+
}
170+
rec = do(h, http.MethodGet, f, "evil.example:8580", withSession)
171+
if rec.Code != http.StatusForbidden {
172+
t.Errorf("%s for a wrong Host: status %d, want 403", f, rec.Code)
173+
}
174+
}
175+
// Every font the stylesheet names is one of the served files.
176+
css := getBody(t, h, "/static/style.css")
177+
for _, m := range regexp.MustCompile(`url\("([^"]+)"\)`).FindAllStringSubmatch(css, -1) {
178+
found := false
179+
for _, f := range fontFiles {
180+
if m[1] == f {
181+
found = true
182+
}
183+
}
184+
if !found {
185+
t.Errorf("style.css names %s, which is not served", m[1])
186+
}
187+
}
188+
}
189+
190+
func TestNoExternalURLs(t *testing.T) {
191+
h := newHandler(t, nil)
192+
files := append([]string{"/", "/static/style.css", "/static/app.js"}, screenFiles...)
193+
scheme := regexp.MustCompile(`(?i)https?://`)
194+
for _, f := range files {
195+
body := strings.ReplaceAll(getBody(t, h, f), "http://www.w3.org/2000/svg", "")
196+
if loc := scheme.FindStringIndex(body); loc != nil {
197+
end := loc[1] + 40
198+
if end > len(body) {
199+
end = len(body)
200+
}
201+
t.Errorf("%s has an outside URL: %q", f, body[loc[0]:end])
202+
}
203+
if strings.Contains(body, "//fonts.") || strings.Contains(body, `src="//`) || strings.Contains(body, `href="//`) {
204+
t.Errorf("%s has a scheme-relative outside URL", f)
205+
}
206+
}
207+
}
208+
209+
func TestFontSrcCSP(t *testing.T) {
210+
h := newHandler(t, nil)
211+
for _, target := range []string{"/", "/static/style.css"} {
212+
rec := do(h, http.MethodGet, target, goodHost, withSession)
213+
csp := rec.Header().Get("Content-Security-Policy")
214+
for _, want := range []string{"font-src 'self'", "default-src 'none'", "script-src 'self'", "style-src 'self'"} {
215+
if !strings.Contains(csp, want) {
216+
t.Errorf("%s: CSP %q lacks %q", target, csp, want)
217+
}
218+
}
219+
if strings.Contains(csp, "unsafe-inline") {
220+
t.Errorf("%s: CSP allows unsafe-inline", target)
221+
}
222+
}
223+
}

0 commit comments

Comments
 (0)