legal: close AGPL packaging gaps
test / test (push) Has been cancelled

Name the copyright holder, ship NOTICE with the image, serve the licence
from the panel footer on every page, and record the vendored htmx licence.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 21:56:51 +03:00
parent c713f51f24
commit 6c8bf0d3b3
17 changed files with 908 additions and 16 deletions
+25
View File
@@ -0,0 +1,25 @@
package web
import (
"net/http"
"github.com/mixeme/selfpost/internal/legal"
)
// handleLicense serves the AGPL-3.0 text so every interactive page's
// "License" footer link works without leaving the panel (AGPL Appropriate
// Legal Notices). Unauthenticated on purpose: the notice must be reachable
// from the login and setup screens too.
func (s *Server) handleLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(legal.License)
}
+42
View File
@@ -0,0 +1,42 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/legal"
)
func TestLicenseHandlerServesAGPL(t *testing.T) {
s := &Server{}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/license", nil)
s.handleLicense(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "GNU AFFERO GENERAL PUBLIC LICENSE") {
t.Error("response is missing the AGPL title")
}
if body != string(legal.License) {
t.Error("response body does not match legal.License")
}
}
func TestLicenseHandlerRejectsNonGET(t *testing.T) {
s := &Server{}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/license", nil)
s.handleLicense(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
+6 -1
View File
@@ -176,7 +176,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is
reference material, not something to read on the way past. */
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; line-height: 1.45; }
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.version a:hover { color: var(--fg); }
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
into a ragged edge, so centre it there. */
main.page-login .version, main.page-setup .version { text-align: center; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
+6 -2
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"html/template"
"net/http"
"github.com/mixeme/selfpost/internal/legal"
)
// templates holds the parsed page and fragment templates. Each page is parsed
@@ -84,13 +86,15 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
// The layout's navigation compares .Active against each item, so the key
// must exist on every authenticated page. Defaulting it here keeps a page
// that forgets it from failing to render — it simply highlights nothing.
// .Version, shown in the layout's footer, is supplied the same way: it is
// the same value on every page, so no handler should have to pass it.
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
// page, so no handler should have to pass them.
if m, ok := data.(map[string]any); ok {
if _, has := m["Active"]; !has {
m["Active"] = ""
}
m["Version"] = s.cfg.Version
m["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
+11 -5
View File
@@ -37,11 +37,17 @@
tell the send log from a single delivery's page. */}}
<main class="page-{{.Active}} {{template "wide" .}}">
{{template "content" .}}
{{/* The running version, on every authenticated page: it is what a backup
manifest is checked against on restore and the first thing to establish
when something behaves unexpectedly. Only for signed-in administrators —
the login and setup pages must not advertise it to the internet. */}}
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
where the Corresponding Source is, and that there is no warranty. Shown
on every page, including login/setup — those are interactive UIs too.
The running version stays signed-in only: it is what a backup manifest is
checked against on restore, and must not be advertised to the internet. */}}
<footer class="version">
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
<a href="/license">License (AGPL-3.0)</a> ·
<a href="{{.SourceURL}}">Source</a> ·
No warranty
</footer>
</main>
</div>
</body>
+43 -6
View File
@@ -105,16 +105,28 @@ func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
// The version comes from render(), not from each handler's data map, so the
// footer is only correct as long as every page composes with the layout and
// render keeps supplying the key. Both are asserted here rather than trusted.
// Appropriate Legal Notices (copyright, licence, source, no warranty) must
// appear on every page, including the signed-out ones.
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
legalBits := []string{
"Copyright © 2026 Mikhail Yenuchenko",
`href="/license"`,
"License (AGPL-3.0)",
`href="https://github.com/mixeme/selfpost"`,
"Source",
"No warranty",
}
rendered := 0
for name := range tmpl.pages {
var buf bytes.Buffer
err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
})
if err != nil {
// Pages whose content block needs more data than this cannot be
@@ -123,23 +135,38 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
continue
}
rendered++
if !strings.Contains(buf.String(), "SelfPost 9.9.9-test") {
out := buf.String()
if !strings.Contains(out, "SelfPost 9.9.9-test") {
t.Errorf("page %q does not show the version in the layout footer", name)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("page %q is missing legal notice %q", name, want)
}
}
}
if rendered == 0 {
t.Fatal("no page rendered, so the footer was never actually checked")
}
// Signed out (login, setup) the version must not be advertised.
// Signed out (login, setup) the version must not be advertised, but the
// Appropriate Legal Notices must still be present.
var buf bytes.Buffer
if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
}); err != nil {
t.Fatalf("execute login: %v", err)
}
if strings.Contains(buf.String(), "9.9.9-test") {
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", buf.String())
out := buf.String()
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", out)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("login page is missing legal notice %q", want)
}
}
}
@@ -159,8 +186,18 @@ func TestRenderSuppliesTheVersion(t *testing.T) {
if got := data["Version"]; got != "9.9.9-test" {
t.Errorf("render did not supply Version (got %v)", got)
}
if !strings.Contains(rec.Body.String(), "SelfPost 9.9.9-test") {
t.Errorf("rendered page does not show the version:\n%s", rec.Body.String())
if got := data["Copyright"]; got != "Copyright © 2026 Mikhail Yenuchenko" {
t.Errorf("render did not supply Copyright (got %v)", got)
}
if got := data["SourceURL"]; got != "https://github.com/mixeme/selfpost" {
t.Errorf("render did not supply SourceURL (got %v)", got)
}
body := rec.Body.String()
if !strings.Contains(body, "SelfPost 9.9.9-test") {
t.Errorf("rendered page does not show the version:\n%s", body)
}
if !strings.Contains(body, `href="/license"`) || !strings.Contains(body, "No warranty") {
t.Errorf("rendered page is missing Appropriate Legal Notices:\n%s", body)
}
}
+4
View File
@@ -147,6 +147,10 @@ func (s *Server) Handler() http.Handler {
// Health check stays unauthenticated for the container/orchestrator.
mux.HandleFunc("/healthz", handleHealth)
// AGPL Appropriate Legal Notices: the licence text itself, reachable
// without a session so the login and setup footers can link to it.
mux.HandleFunc("/license", s.handleLicense)
// Vendored static assets (HTMX). Served from the embedded FS, with a
// content ETag so a replaced asset survives the browser cache (static.go).
mux.Handle("/static/", staticHandler())