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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user