Files
selfpost/internal/web/view/templates/layout.html
T
mixandClaude Opus 5.5 cad1182150
test / test (push) Canceled after 0s
audit 2c: what only the stand could answer, measured and written down
All 2026-10-15; each written beside the code or text it settles.

- A-43: /usr/sbin/postlog is -rwxr-xr-x root:root in the image (postfix
  3.7.11-0+deb12u1; dpkg-statoverride lists postdrop and postqueue only):
  Debian does not install it set-gid, so it is no use to dmarc-ingest
  (cmd/panel/dmarc_ingest.go).
- C-X1: OpenSSL 3.0.22 in the image prints the same public key from
  `x509 -pubkey` and `pkey -pubout` for RSA-2048, P-256, P-384 and a SEC1
  P-256 key - cert-reload's same_pair holds for certbot's ECDSA keys.
- C-K5, D-guide:933/:233: on the live server (Docker 29.6.1, userland
  proxy on 0.0.0.0 and ::, no IPv6 on the network) an outside IPv4 client
  and the host's own public IPv4 address are logged as themselves; the
  host's IPv6 address and 127.0.0.1 arrive as 172.18.0.1. Over IPv6 every
  client is the gateway: one level-1 budget, an application IP list cannot
  name them, rspamd sees the gateway. Guide paragraph under level 1, notes
  at the milter and allow-list, comments at the anvil setup and authip.go,
  and a Security entry in the CHANGELOG (an allow-list naming the gateway
  would admit every IPv6 client).
- D-guide:61: a published port nothing listens on (a throwaway container,
  -p 20999:25) is refused from outside over IPv4 and from the host's public
  IPv4, accepted and closed through 127.0.0.1 - Docker's proxy, which IPv6
  goes through too (not probed over IPv6 for this).
- C-M8: the live installation has received one DMARC report, of one
  record: nothing to set maxReportRecords by yet - said at the constant.
- D-S7, D-arch:516, D-arch:532: a new database filled through the store to
  120 days at 1 900 messages a day (228 000 messages, 1.37 million lines,
  464 MB) on the test server (2 cores): the longest write is the line prune
  after a 30-day gap, 1.9 s for 342 000 lines (0.4 s with nothing to
  delete); the send-log delete 0.6 s; 2 000 lines of a tailer pass 18 ms;
  VACUUM INTO 1.7 s; a second connection writing every 50 ms waited 1.8 s
  at most, no SQLITE_BUSY - inside busy_timeout's 5 s. architecture.md's
  "about a second per million lines" was five times too fast; its "six to
  ten lines of 200 bytes" is 6.0 lines of 146 bytes on the live data.
- D-B16: which favicon is fetched, by the probe server's log: Edge 155
  the SVG, the desktop app's embedded Chromium 152 the PNG, with or
  without sizes="any" - layout.html's "vector wins where supported, raster
  catches the rest" holds; Firefox and Safari not measured.
- D-arch:121: the allow/deny trial again, in rspamd/rspamd:3.11.1-3 with
  today's multimap.conf: every mix of allow and deny entries matching one
  message ended reject; a changed map was used about 10 s later.
- D-sec:424: rspamd runs as _rspamd, uid 11333; the panel is uid 999.

Stand: go vet and go test ./... pass on the server (24 packages); make
e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:14:58 +03:00

159 lines
11 KiB
HTML

{{/* The shell of every redesigned page (docs/plans/panel-redesign.md § Three
layers): a brick navbar with the wordmark and hover menus, the stamp's
perforated edge, the strip of sibling pages, the page, the footer. It is
built exactly like the mockups' build.py builds it, and it is the only
place besides components.html where sp- classes are written. A page never
writes any of it.
layout.html is executed with a Frame (components.go): .Shell is what the
shell shows, .Page is the page's own data, handed to the page's "content"
template. Which entries are visible comes from .Shell (Engine.shell derives
it from the role, the feature flags and the user's reach); the paths are
written out here, so the menu can be read, and checked against the router
(guard_routes_test.go), without running anything. */}}
{{/* layout_head is the head of both documents; the stylesheets, in this order,
and the two scripts are all a page ever loads. */}}
{{define "layout_head"}}<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · SelfPost</title>
{{/* The tab icon is the stamp's small-size variant — the initials rather than
the two-line wordmark, which stops resolving below 32px. The PNG is declared
first and the SVG second: a browser takes the last icon whose type it
understands, so vector wins wherever it is supported and the raster catches
the rest. HTML links.html#rel-icon leaves "most appropriate" to the
browser, so measured, 2026-10-15, by which file was requested: Edge 155
(Chromium) takes the SVG, the Claude desktop app's embedded browser
(Chromium 152) the PNG, and adding sizes="any" to the SVG changes
neither. Firefox and Safari not measured (assumptions D-B16). */}}
<link rel="icon" href="/static/favicon.png" sizes="32x32">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/bulma.min.css">
<link rel="stylesheet" href="/static/tabler-icons.css">
<link rel="stylesheet" href="/static/panel.css">
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
the head for the request-indicator classes. The panel uses no
hx-indicator, and that injected element would be the one thing the
Content-Security-Policy has to make an exception for, so it
is switched off here. htmx 2.0.4, src/htmx.js: the ready handler runs
mergeMetaConfig() before insertIndicatorStyles(), which adds the element
only while includeIndicatorStyles is not false. Both scripts are defer:
they run in this order once parsing ends and before DOMContentLoaded
(HTML Living Standard, parsing.html#the-end). */}}
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script>
</head>{{end}}
{{/* layout.html is the signed-in document. Engine.Render uses
layout_signed_out instead when there is no user. */}}
{{define "layout.html"}}<!DOCTYPE html>
<html lang="en" data-theme="light">
{{template "layout_head" .Shell}}
<body>
{{template "nav" .Shell}}
{{template "subnav" .Shell}}
<main>
<div class="container sp-page">
{{template "content" .Page}}
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the
licence, where the Corresponding Source is, and that there is no
warranty. The running version is shown signed in only: it is what a
backup manifest is checked against on restore, and must not be
advertised to the internet. */}}
<footer class="sp-foot">SelfPost {{.Shell.Version}} · {{.Shell.Copyright}} · <a href="/license">License (AGPL-3.0)</a> · <a href="{{.Shell.SourceURL}}">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>{{end}}
{{/* layout_signed_out is the split brand / form screen of sign-in and setup:
the stamp on the brick, the page's own form beside it, and the legal
notice without the version. */}}
{{define "layout_signed_out"}}<!DOCTYPE html>
<html lang="en" data-theme="light">
{{template "layout_head" .Shell}}
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="/static/logo.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
{{template "content" .Page}}
<p class="sp-foot">{{.Shell.Copyright}} · <a href="/license">License (AGPL-3.0)</a> · <a href="{{.Shell.SourceURL}}">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>{{end}}
{{/* nav is the navbar and the perforated edge under it (input: Shell). A group
with nothing in it for this user is absent; the group of the current page
carries sp-current and its current entry is-active. */}}
{{define "nav"}}<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="{{.Home}}"><img src="/static/wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
{{if .ShowOverview}} <a class="navbar-item{{if eq .Section "overview"}} sp-current{{end}}" href="/overview">Overview</a>
{{end}}{{if .ShowOutbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "outbound"}} sp-current{{end}}">
<a class="navbar-link" href="/outbound/domains">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "domains")}} is-active{{end}}" href="/outbound/domains"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "log")}} is-active{{end}}" href="/outbound/log"><i class="ti ti-list-details"></i>Log</a>
{{if .ShowQueue}} <a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "queue")}} is-active{{end}}" href="/outbound/queue"><i class="ti ti-stack-2"></i>Queue</a>
{{end}}{{if .ShowDMARC}} <a class="navbar-item{{if and (eq .Section "outbound") (eq .Page "dmarc")}} is-active{{end}}" href="/outbound/dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a>
{{end}} </div>
</div>
{{end}}{{if .ShowInbound}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "inbound"}} sp-current{{end}}">
<a class="navbar-link" href="/inbound/domains">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "log")}} is-active{{end}}" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>
{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
{{end}} </div>
</div>
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
<a class="navbar-link" href="/server/health">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "health")}} is-active{{end}}" href="/server/health"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "log")}} is-active{{end}}" href="/server/log"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "backup")}} is-active{{end}}" href="/server/backup"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "users")}} is-active{{end}}" href="/server/users"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item{{if and (eq .Section "server") (eq .Page "settings")}} is-active{{end}}" href="/server/settings"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
{{end}} </div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable{{if eq .Section "user"}} sp-current{{end}}">
<a class="navbar-link" href="/account"><i class="ti ti-user-circle"></i>{{.User}}</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="/account"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="/help"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
{{/* Signing out is a POST (the handler answers nothing else), so it
stays a form that works without scripts. The submit button
carries navbar-item: Bulma resets a button to plain text, so
it takes the dropdown's entry styling like the links around
it, and panel.css hands it the hover tint Bulma gives to
a.navbar-item alone. */}}
<form method="post" action="/logout"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>{{end}}
{{/* subnav is the strip of sibling pages under the edge (input: Shell): the
pages of the current group, the current one marked. A page outside every
group (the user menu, the kit page's neighbours) has none. */}}
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
{{end}}{{end}}