The header was a rigid flex row, so on a narrow screen the account name and the sign-out button were pushed off the side of the viewport where they could not be reached at all — not merely ugly, unusable. It wraps now, and below 40rem the account block takes its own full-width row so a long display name cannot crowd out the navigation. Wide content scrolls inside its own box rather than dragging the page sideways with it. Tables sit in an overflow-x container and <pre> is capped at the viewport width; without that, one long self-test line or one device table makes every other column of text unreadable, and on a phone it is not obvious that the page has moved at all. Long opaque strings — device ids, enrolment links — wrap anywhere rather than insisting on a width nothing has. Also: box-sizing on everything, stat cards that share a row instead of each claiming the full width, and larger touch targets on buttons, where .4rem is comfortable with a mouse and fiddly with a thumb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
8.9 KiB
Go
207 lines
8.9 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package adminui
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
)
|
|
|
|
// Templates are parsed once at start. html/template escapes by context, which is what makes it
|
|
// safe to render device names and finding text that ultimately arrived over a network.
|
|
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
|
"kb": func(n int64) int64 { return n / 1024 },
|
|
}).Parse(baseHTML))
|
|
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
|
data["Page"] = page
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, data); err != nil {
|
|
slog.Error("admin template", "page", page, "err", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
|
|
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
const baseHTML = `<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Echolot — {{.Page}}</title>
|
|
<style>
|
|
:root{color-scheme:dark}
|
|
*{box-sizing:border-box}
|
|
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
|
|
/* The header wraps rather than overflowing: on a phone a rigid flex row pushes the account name
|
|
and the sign-out button off the side of the screen, where they cannot be reached at all. */
|
|
header{display:flex;gap:.6rem 1.2rem;align-items:baseline;flex-wrap:wrap;
|
|
padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
|
|
header h1{font-size:1.1rem;margin:0;font-weight:600}
|
|
header nav{display:flex;flex-wrap:wrap;gap:.2rem 1rem}
|
|
header nav a{color:#9ecbff;text-decoration:none}
|
|
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem;
|
|
display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
|
|
main{padding:1.2rem;max-width:70rem}
|
|
table{border-collapse:collapse;width:100%;margin:0}
|
|
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
|
|
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
|
|
/* Wide content scrolls inside its own box. Letting the page scroll sideways instead makes every
|
|
other column of text unreadable, and on a phone it is not obvious that it happened. */
|
|
.tablewrap{overflow-x:auto;margin:.6rem 0;-webkit-overflow-scrolling:touch}
|
|
.tablewrap table{min-width:32rem}
|
|
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
|
|
code{overflow-wrap:anywhere}
|
|
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem;max-width:100%}
|
|
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0;
|
|
min-width:0}
|
|
.grid{display:flex;gap:1rem;flex-wrap:wrap}
|
|
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;
|
|
flex:1 1 8rem;min-width:8rem}
|
|
.stat b{display:block;font-size:1.6rem;font-weight:600}
|
|
.stat span{color:#9aa3ad;font-size:.85rem}
|
|
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
|
|
button.danger{background:#8b2f2f}
|
|
button.plain{background:#3a3f47}
|
|
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;
|
|
padding:.4rem .6rem;max-width:100%}
|
|
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
|
|
.muted{color:#9aa3ad}
|
|
form.inline{display:inline}
|
|
@media (max-width:40rem){
|
|
main{padding:.9rem}
|
|
header{padding:.7rem .9rem}
|
|
/* Full width on its own row, so the name can be long without stealing the nav's space. */
|
|
header .who{margin-left:0;width:100%;justify-content:space-between}
|
|
/* Touch targets: .4rem of padding is comfortable with a mouse and fiddly with a thumb. */
|
|
button{padding:.5rem .9rem}
|
|
.stat{padding:.7rem .9rem}
|
|
.stat b{font-size:1.4rem}
|
|
}
|
|
</style></head><body>
|
|
{{if ne .Page "login"}}
|
|
<header>
|
|
<h1>Echolot</h1>
|
|
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
|
|
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} <span class="muted">(your account)</span>{{end}}
|
|
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
|
</span>
|
|
</header>
|
|
{{end}}
|
|
<main>
|
|
|
|
{{if eq .Page "login"}}
|
|
<h2>Sign in</h2>
|
|
{{with .Error}}<p class="err">{{.}}</p>{{end}}
|
|
{{if .OIDC}}
|
|
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
|
|
<p class="muted">or use the break-glass account:</p>
|
|
{{end}}
|
|
{{if .LocalSet}}
|
|
<form method="post" action="/login" class="card">
|
|
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
|
|
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
|
|
<p><button>Sign in</button></p>
|
|
</form>
|
|
{{else}}
|
|
<p class="err">No break-glass admin is set. Run
|
|
<code>echolot-server --set-admin-password</code> on the host.</p>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "dashboard"}}
|
|
<div class="grid">
|
|
<div class="stat"><b>{{.Devices}}</b><span>{{if .Admin}}devices{{else}}your devices{{end}}</span></div>
|
|
{{if .Admin}}<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>{{end}}
|
|
<div class="stat"><b>{{.Runs}}</b><span>{{if .Admin}}stored runs{{else}}your runs{{end}}</span></div>
|
|
</div>
|
|
{{if not .Admin}}
|
|
<div class="card">
|
|
<p>This is your account. You can see the devices you have signed in on, review everything
|
|
they have uploaded, and delete any of it.</p>
|
|
<p class="muted">Administering the server — enrolling devices, revoking them, and
|
|
seeing other people's uploads — needs an administrator account.</p>
|
|
</div>
|
|
{{end}}
|
|
{{if .Admin}}
|
|
<div class="card">
|
|
<h3>Server</h3>
|
|
<p class="muted">version {{.Version}}</p>
|
|
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "devices"}}
|
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
|
{{with .Link}}
|
|
<div class="card">
|
|
<p><b>Enrolment link</b> — single use, valid 24 hours. Treat it like a password until spent.</p>
|
|
<p><code>{{.}}</code></p>
|
|
<p class="muted">On a device with adb:<br>
|
|
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
|
|
</div>
|
|
{{end}}
|
|
{{if .Admin}}
|
|
<form method="post" action="/enroll-tokens">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button>Create enrolment link</button>
|
|
</form>
|
|
{{end}}
|
|
<div class="tablewrap"><table>
|
|
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
|
|
{{range .Rows}}
|
|
<tr>
|
|
<td><code>{{.ID}}</code></td>
|
|
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">—</span>{{end}}</td>
|
|
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
|
|
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
|
|
<td>{{.Runs}}</td>
|
|
<td>{{if $.Admin}}<form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<button class="danger">Revoke</button></form>{{end}}</td>
|
|
</tr>
|
|
{{else}}
|
|
<tr><td colspan="6" class="muted">{{if $.Admin}}No devices enrolled.{{else}}You have not signed in on any device yet. Sign in from the Echolot app to link one.{{end}}</td></tr>
|
|
{{end}}
|
|
</table></div>
|
|
|
|
{{else if eq .Page "runs"}}
|
|
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
|
|
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
|
|
here can un-redact a run.</p>
|
|
<div class="tablewrap"><table>
|
|
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
|
|
{{range .Rows}}
|
|
<tr>
|
|
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
|
|
<td>{{.DeviceName}}</td>
|
|
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">—</span>{{end}}</td>
|
|
<td>{{.FindingCount}}</td>
|
|
<td>{{kb .SizeBytes}} kB</td>
|
|
<td>{{.Anonymization}}</td>
|
|
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
|
|
</tr>
|
|
{{else}}
|
|
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
|
|
{{end}}
|
|
</table></div>
|
|
|
|
{{else if eq .Page "run"}}
|
|
<h2>Run {{.ID}}</h2>
|
|
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button class="danger">Delete this run</button>
|
|
</form>
|
|
<pre>{{.JSON}}</pre>
|
|
{{end}}
|
|
|
|
</main></body></html>
|
|
`
|