From 4e07a24e25b0450a6cdccbaf2dc0a2e289109183 Mon Sep 17 00:00:00 2001 From: githubflyideas <36397539+githubflyideas@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:46:59 +0900 Subject: [PATCH] Fix WCAG 2.1 AA issues; hide instance settings from anonymous /api/version --- assets_test.go | 52 +++++++++++- i18n/strings.json | 192 +++++++++++++++++++++++++++++++++++++++++++ static/index.html | 123 ++++++++++++++++++++------- static/login.html | 32 ++++---- static/settings.html | 28 ++++--- static/setup.html | 11 ++- static/sponsor.js | 12 ++- web.go | 8 ++ 8 files changed, 392 insertions(+), 66 deletions(-) diff --git a/assets_test.go b/assets_test.go index 1b6e758..c61b49d 100644 --- a/assets_test.go +++ b/assets_test.go @@ -187,8 +187,11 @@ func TestVersionReportsHotDays(t *testing.T) { } defer s.Close() h := newMux(&Config{HotDays: 3, RetentionDays: 300}, s, NewRunner(ProbeCfg{}, s, NewDetector(s))) + cookie := versionTestSession(t, h) + req := httptest.NewRequest("GET", "/api/version", nil) + req.Header.Set("Cookie", sessionCookie+"="+cookie) rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/version", nil)) + h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("GET /api/version: status %d", rec.Code) } @@ -203,3 +206,50 @@ func TestVersionReportsHotDays(t *testing.T) { t.Errorf("hot_days=%d retention_days=%d, want 3 and 300", v.HotDays, v.RetentionDays) } } + +// Before sign-in /api/version says which version this is and nothing about the +// instance: retention, hot days, logo and setup state are for signed-in users. +func TestVersionAnonymousShowsOnlyVersion(t *testing.T) { + s, err := NewStore(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + defer s.Close() + h := newMux(&Config{HotDays: 3, RetentionDays: 300}, s, NewRunner(ProbeCfg{}, s, NewDetector(s))) + check := func(label string) { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/version", nil)) + if rec.Code != 200 { + t.Fatalf("%s: GET /api/version: status %d", label, rec.Code) + } + var v map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + if _, ok := v["version"]; !ok || len(v) != 1 { + t.Errorf("%s: anonymous /api/version = %s, want only the version", label, rec.Body) + } + } + check("before setup") + versionTestSession(t, h) + check("after setup") +} + +// versionTestSession creates the first admin through /api/setup and returns its +// session cookie value. +func versionTestSession(t *testing.T, h http.Handler) string { + t.Helper() + req := httptest.NewRequest("POST", "http://st.local/api/setup", strings.NewReader(`{"User":"admin","Pass":"correct horse battery"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "http://st.local") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookie { + return c.Value + } + } + t.Fatalf("setup gave no session: %d %s", rec.Code, rec.Body) + return "" +} diff --git a/i18n/strings.json b/i18n/strings.json index 78560ce..0aa81b9 100644 --- a/i18n/strings.json +++ b/i18n/strings.json @@ -1607,6 +1607,198 @@ "de": "Lauscht derzeit auf {now}; {next} gilt nach einem Neustart.", "ja": "現在は {now} で待ち受けています。{next} は再起動後に有効になります。", "ko": "현재 {now}에서 수신 중입니다. {next}는 재시작 후 적용됩니다." + }, + "console.skip": { + "en": "Skip to chart", + "zh": "跳到图表", + "es": "Saltar al gráfico", + "fr": "Aller au graphique", + "pt": "Pular para o gráfico", + "ru": "Перейти к графику", + "id": "Lewati ke grafik", + "de": "Zum Diagramm springen", + "ja": "グラフへ移動", + "ko": "차트로 건너뛰기" + }, + "console.hTargets": { + "en": "Targets", + "zh": "目标", + "es": "Destinos", + "fr": "Cibles", + "pt": "Alvos", + "ru": "Цели", + "id": "Target", + "de": "Ziele", + "ja": "ターゲット", + "ko": "대상" + }, + "console.hChart": { + "en": "Latency and packet loss", + "zh": "延迟与丢包", + "es": "Latencia y pérdida de paquetes", + "fr": "Latence et perte de paquets", + "pt": "Latência e perda de pacotes", + "ru": "Задержка и потеря пакетов", + "id": "Latensi dan kehilangan paket", + "de": "Latenz und Paketverlust", + "ja": "遅延とパケットロス", + "ko": "지연 시간과 패킷 손실" + }, + "console.fType": { + "en": "Type", + "zh": "类型", + "es": "Tipo", + "fr": "Type", + "pt": "Tipo", + "ru": "Тип", + "id": "Jenis", + "de": "Typ", + "ja": "種類", + "ko": "유형" + }, + "console.fHost": { + "en": "Host or IP", + "zh": "主机或 IP", + "es": "Host o IP", + "fr": "Hôte ou IP", + "pt": "Host ou IP", + "ru": "Хост или IP", + "id": "Host atau IP", + "de": "Host oder IP", + "ja": "ホストまたは IP", + "ko": "호스트 또는 IP" + }, + "console.fPort": { + "en": "Port", + "zh": "端口", + "es": "Puerto", + "fr": "Port", + "pt": "Porta", + "ru": "Порт", + "id": "Port", + "de": "Port", + "ja": "ポート", + "ko": "포트" + }, + "console.fName": { + "en": "Name (optional)", + "zh": "名称(可选)", + "es": "Nombre (opcional)", + "fr": "Nom (facultatif)", + "pt": "Nome (opcional)", + "ru": "Имя (необязательно)", + "id": "Nama (opsional)", + "de": "Name (optional)", + "ja": "名前(任意)", + "ko": "이름 (선택)" + }, + "console.fPace": { + "en": "Pace", + "zh": "节奏", + "es": "Ritmo", + "fr": "Rythme", + "pt": "Ritmo", + "ru": "Темп", + "id": "Tempo", + "de": "Takt", + "ja": "ペース", + "ko": "주기" + }, + "console.fInterval": { + "en": "Interval in seconds (overrides pace)", + "zh": "间隔秒数(覆盖节奏)", + "es": "Intervalo en segundos (anula el ritmo)", + "fr": "Intervalle en secondes (remplace le rythme)", + "pt": "Intervalo em segundos (substitui o ritmo)", + "ru": "Интервал в секундах (вместо темпа)", + "id": "Interval dalam detik (menggantikan tempo)", + "de": "Intervall in Sekunden (ersetzt den Takt)", + "ja": "間隔(秒、ペースより優先)", + "ko": "간격(초, 주기보다 우선)" + }, + "console.from": { + "en": "From", + "zh": "开始", + "es": "Desde", + "fr": "Du", + "pt": "De", + "ru": "С", + "id": "Dari", + "de": "Von", + "ja": "開始", + "ko": "시작" + }, + "console.to": { + "en": "To", + "zh": "结束", + "es": "Hasta", + "fr": "Au", + "pt": "Até", + "ru": "По", + "id": "Sampai", + "de": "Bis", + "ja": "終了", + "ko": "끝" + }, + "console.chartCaption": { + "en": "Chart data, oldest first", + "zh": "图表数据(从旧到新)", + "es": "Datos del gráfico, del más antiguo al más reciente", + "fr": "Données du graphique, des plus anciennes aux plus récentes", + "pt": "Dados do gráfico, do mais antigo ao mais recente", + "ru": "Данные графика, от старых к новым", + "id": "Data grafik, dari yang terlama", + "de": "Diagrammdaten, älteste zuerst", + "ja": "グラフのデータ(古い順)", + "ko": "차트 데이터(오래된 순)" + }, + "console.colTime": { + "en": "Time", + "zh": "时间", + "es": "Hora", + "fr": "Heure", + "pt": "Hora", + "ru": "Время", + "id": "Waktu", + "de": "Zeit", + "ja": "時刻", + "ko": "시간" + }, + "console.colMedian": { + "en": "Median RTT (ms)", + "zh": "RTT 中位数(ms)", + "es": "RTT mediano (ms)", + "fr": "RTT médian (ms)", + "pt": "RTT mediano (ms)", + "ru": "Медиана RTT (мс)", + "id": "RTT median (ms)", + "de": "Median-RTT (ms)", + "ja": "RTT 中央値 (ms)", + "ko": "RTT 중앙값 (ms)" + }, + "console.colWorstLoss": { + "en": "Worst loss (%)", + "zh": "最高丢包(%)", + "es": "Pérdida máxima (%)", + "fr": "Perte maximale (%)", + "pt": "Perda máxima (%)", + "ru": "Макс. потери (%)", + "id": "Kehilangan terburuk (%)", + "de": "Höchster Verlust (%)", + "ja": "最大ロス (%)", + "ko": "최대 손실 (%)" + }, + "console.colBursts": { + "en": "Bursts", + "zh": "突发", + "es": "Ráfagas", + "fr": "Rafales", + "pt": "Rajadas", + "ru": "Всплески", + "id": "Lonjakan", + "de": "Bursts", + "ja": "バースト", + "ko": "버스트" } } } diff --git a/static/index.html b/static/index.html index a44c1fc..788e187 100644 --- a/static/index.html +++ b/static/index.html @@ -18,8 +18,8 @@ /* Signal — cyan trace over a cloud that burns from white-gold to cold ember. */ :root { - --bg:#14161a; --panel:#1c2027; --screen:#0d0f13; --line:#2b323c; - --phos:#22d3ee; --amber:#fbbf24; --alert:#fb3b53; --ink:#c0c6ce; --dim:#78848f; + --bg:#14161a; --panel:#1c2027; --screen:#0d0f13; --line:#2b323c; --ctl:#676d76; + --phos:#22d3ee; --amber:#fbbf24; --alert:#fb3b53; --ink:#c0c6ce; --dim:#7e8a94; --grid-axis:#333b47; --grid-split:#1c222a; /* 对比虚线。深色主题直接用 --dim(对 --screen ≈5:1);浅色主题的 --dim 只有 ≈3.5:1, 各自给一个更深的同色系灰,保证 >=4.5:1 */ @@ -39,8 +39,8 @@ /* Daylight — ink on lab paper. Core burns terracotta, edges cool to slate. */ html[data-theme="daylight"] { - --bg:#f2efe6; --panel:#fbf9f3; --screen:#fffdf7; --line:#ddd6c4; - --phos:#1d4ed8; --amber:#b45309; --alert:#be123c; --ink:#3e4346; --dim:#8a8677; + --bg:#f2efe6; --panel:#fbf9f3; --screen:#fffdf7; --line:#ddd6c4; --ctl:#878780; + --phos:#1d4ed8; --amber:#954407; --alert:#be123c; --ink:#3e4346; --dim:#6f6b5f; --grid-axis:#cdc6b2; --grid-split:#e9e3d3; --cmp:#6b6656; --smoke:rgba(180,83,9,.22); --lossbar:rgba(190,18,60,.6); --glow:0; --smoke-hot:#e07a3a; --smoke-mid:#e8a878; --smoke-cool:#cfc8b8; @@ -49,8 +49,8 @@ /* Arctic — near-black trace. Cloud runs from deep sea to pale frost. */ html[data-theme="arctic"] { - --bg:#eaeff4; --panel:#f7fafc; --screen:#fdfeff; --line:#d3dce5; - --phos:#0f172a; --amber:#0369a1; --alert:#dc2626; --ink:#3a4353; --dim:#7d8b99; + --bg:#eaeff4; --panel:#f7fafc; --screen:#fdfeff; --line:#d3dce5; --ctl:#7d8693; + --phos:#0f172a; --amber:#036093; --alert:#d02222; --ink:#3a4353; --dim:#5f6c79; --grid-axis:#c2cdd8; --grid-split:#e2e9f0; --cmp:#5b6878; --smoke:rgba(56,132,199,.22); --lossbar:rgba(220,38,38,.55); --glow:0; --smoke-hot:#4a9fd4; --smoke-mid:#8dc0e0; --smoke-cool:#cfe0ec; @@ -59,8 +59,8 @@ /* Budapest — Anderson's plum and rose. Core is deep raspberry, edges go powder. */ html[data-theme="budapest"] { - --bg:#f7ebe8; --panel:#fdf5f2; --screen:#fffaf8; --line:#e6cfc8; - --phos:#5b2a4f; --amber:#c9754f; --alert:#c1362f; --ink:#4c3d41; --dim:#9c8087; + --bg:#f7ebe8; --panel:#fdf5f2; --screen:#fffaf8; --line:#e6cfc8; --ctl:#948280; + --phos:#5b2a4f; --amber:#8d492c; --alert:#c1362f; --ink:#4c3d41; --dim:#7f636a; --grid-axis:#dcc0b8; --grid-split:#f0e0da; --cmp:#7d5f66; --smoke:rgba(216,133,140,.26); --lossbar:rgba(193,54,47,.55); --glow:0; --smoke-hot:#e0748c; --smoke-mid:#eaa8ae; --smoke-cool:#f0dbd2; @@ -71,8 +71,8 @@ white-hot core, amber body, dying green at the fringe, exactly how a long persistence tube fades. The trace stays a cooler gold so it sits above it. */ html[data-theme="amber"] { - --bg:#12100a; --panel:#1c1810; --screen:#0a0806; --line:#38301c; - --phos:#ffd75e; --amber:#ffd98a; --alert:#ff5f2e; --ink:#cebd95; --dim:#8f7b4e; + --bg:#12100a; --panel:#1c1810; --screen:#0a0806; --line:#38301c; --ctl:#71664a; + --phos:#ffd75e; --amber:#ffd98a; --alert:#ff5f2e; --ink:#cebd95; --dim:#958051; --grid-axis:#443a20; --grid-split:#1e1a10; --cmp:#8f7b4e; --smoke:rgba(255,176,0,.20); --lossbar:rgba(255,95,46,.65); --glow:7; --smoke-hot:#a86a10; --smoke-mid:#7d4508; --smoke-cool:#2f4429; @@ -194,7 +194,7 @@ .tgt .lnk.del { color:var(--alert); } .tgt form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:12px; } .tgt input, .tgt select { font-family:var(--mono); font-size:var(--fs-small); color:var(--ink); - background:var(--screen); border:1px solid var(--line); border-radius:4px; padding:6px 8px; } + background:var(--screen); border:1px solid var(--ctl); border-radius:4px; padding:6px 8px; } .tgt input:focus, .tgt select:focus { outline:none; border-color:var(--phos); } #tgtErr { color:var(--alert); font-size:var(--fs-small); } /* Export / import of the target list (netping-targets file). */ @@ -217,37 +217,53 @@ .ie-prev td.why { color:var(--dim); font-size:var(--fs-small); } .ie-prev .btns { display:flex; gap:8px; } .ie-prev button:disabled { opacity:.5; cursor:default; } + /* Accessibility. --ctl is the border of a form control: --line is a hairline + between panels (decoration), but a text field is only recognisable by its edge, + which WCAG 1.4.11 wants at 3:1. Focus rings use the accent for the same reason. */ + :focus-visible { outline:2px solid var(--phos); outline-offset:2px; } + .skip { position:absolute; left:12px; top:-60px; z-index:20; background:var(--panel); color:var(--phos); + border:2px solid var(--phos); border-radius:6px; padding:8px 14px; font-weight:600; text-decoration:none; } + .skip:focus { top:12px; } + .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; + clip:rect(0 0 0 0); white-space:nowrap; border:0; } + h1.mark { margin:0; } + #main:focus { outline:none; } + .rng { flex-wrap:wrap; max-width:100%; } + .rng.cmp, .rng.custom { border-color:var(--ctl); } + .promo-b a, .share a, .tgt .lnk { display:inline-flex; align-items:center; min-height:24px; } + .promo-b > span { flex-wrap:wrap; max-width:100%; } /* The browser's default datetime-local chrome is a white box, which reads as a rendering fault in a dark console. color-scheme makes the native picker and the spin controls follow the theme instead of fighting it. */ input[type="datetime-local"]{ color-scheme:dark; - background:#11161d; - color:#e6edf3; - border:1px solid #272e38; + background:var(--screen); + color:var(--ink); + border:1px solid var(--ctl); border-radius:6px; padding:6px 9px; font:inherit; + min-width:0; max-width:100%; } -input[type="datetime-local"]:focus{outline:none;border-color:#3fb950} -html[data-theme="light"] input[type="datetime-local"], -body.light input[type="datetime-local"]{ - color-scheme:light;background:#fff;color:#111;border-color:#d0d7de; -} +input[type="datetime-local"]:focus{outline:none;border-color:var(--phos)} +html[data-theme="daylight"] input[type="datetime-local"], +html[data-theme="arctic"] input[type="datetime-local"], +html[data-theme="budapest"] input[type="datetime-local"]{ color-scheme:light; } /* Theme variables, not the dark palette written out. This used to sit in the footer where only the dark theme ever saw it; in the header it is dark-on-light in three of the five themes. */ -.lang-picker{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px; +.lang-picker{background:var(--panel);color:var(--ink);border:1px solid var(--ctl);border-radius:6px; padding:5px 8px;font:inherit;font-size:12px;cursor:pointer} .lang-picker:focus{outline:none;border-color:var(--phos)} .lang-picker option{background:var(--panel);color:var(--ink)}
+Skip to chart