From 27be6858348baf9be5cd055f51abef4af4346abd Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 13 Sep 2026 12:38:22 +0200 Subject: [PATCH] Link to source --- internal/config/config.go | 23 ++++++++++++++++++ internal/config/config_test.go | 37 +++++++++++++++++++++++++++++ internal/server/server.go | 5 +++- internal/server/server_test.go | 43 ++++++++++++++++++++++++++++++++++ web/templates/layout.html | 1 + 5 files changed, 108 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 181183c..00b3e8b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,11 @@ type Config struct { BasePath string // normalised: always "/" or "/prefix/" PublicURL string // absolute origin+path for generated links; "" => relative + // SourceURL is linked in the footer. It defaults to where this program is + // developed, and a fork should point it at its own; setting it empty drops + // the footer entirely, for an instance that would rather not advertise. + SourceURL string + MaxSize int64 // per-upload cap; Unlimited means no cap MaxExpiry time.Duration // longest lifetime a caller may request DefaultExpiry time.Duration // lifetime when the caller does not ask @@ -37,6 +42,11 @@ type Config struct { const EnvPrefix = "UNCENSORED_SEND_" +// defaultSourceURL is where this program is developed. Anyone running a +// modified copy should say so with --source-url, and anyone who would rather +// not mention it at all can pass an empty one. +const defaultSourceURL = "https://git.uncensored.hu/thayol/uncensored-send" + // Register wires every option onto s. Short forms exist only for the options // reached often; everything else is long-only, by design. func (c *Config) Register(s *Set) { @@ -50,6 +60,8 @@ func (c *Config) Register(s *Set) { "path prefix this service is mounted under") s.String(&c.PublicURL, "public-url", "u", "", "URL", "absolute base URL used in generated links; relative links when empty") + s.String(&c.SourceURL, "source-url", "", defaultSourceURL, "URL", + "where the footer's source link points; empty hides the footer") s.Size(&c.MaxSize, "max-size", "s", "2GiB", "largest upload accepted from an anonymous caller") @@ -110,6 +122,17 @@ func (c *Config) Normalise() error { c.PublicURL = strings.TrimSuffix(u.String(), "/") } + if c.SourceURL != "" { + u, err := url.Parse(c.SourceURL) + if err != nil { + return fmt.Errorf("--source-url: %w", err) + } + if !u.IsAbs() { + return fmt.Errorf("--source-url: %q is not absolute", c.SourceURL) + } + c.SourceURL = u.String() + } + if c.TokensPath == "" { c.TokensPath = c.DataDir + "/tokens.json" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dbdf715..22b493b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -156,6 +156,43 @@ func TestParseRefusesStrayArguments(t *testing.T) { } } +// The source link is a URL someone will set by hand on a fork, so a value that +// would render a broken link has to be refused at startup rather than shipped +// to every page. +func TestSourceURLMustBeAbsoluteOrEmpty(t *testing.T) { + base := func() Config { + c := Config{} + fs := NewSet("test", "UNCENSORED_SEND_TEST_") + c.Register(fs) + if err := fs.Parse(nil); err != nil { + t.Fatal(err) + } + return c + } + + c := base() + if err := c.Normalise(); err != nil { + t.Fatalf("the default source URL was refused: %v", err) + } + if c.SourceURL == "" { + t.Error("the default build links no source at all") + } + + c = base() + c.SourceURL = "" + if err := c.Normalise(); err != nil { + t.Errorf("switching the source link off was refused: %v", err) + } + + for _, bad := range []string{"not a url", "/relative/path", "example.org/repo"} { + c = base() + c.SourceURL = bad + if err := c.Normalise(); err == nil { + t.Errorf("--source-url %q was accepted", bad) + } + } +} + // The convention is: one hyphen for a letter, two for a word. It is enforced // here because the stdlib flag package treats both forms as the same. func TestHyphenConvention(t *testing.T) { diff --git a/internal/server/server.go b/internal/server/server.go index 8aa228c..068e456 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -241,6 +241,9 @@ type page struct { // case the markup carries no link rather than one that 404s. Favicon string + // Source is where the footer links; empty means no footer. + Source string + // Wide widens the page for content that is a table rather than a form. // The reading measure that suits the upload page is far too narrow for a // listing, which otherwise ends up behind a horizontal scrollbar. @@ -250,7 +253,7 @@ type page struct { // page builds the common fields, resolving the session so the header can show // who is logged in and offer only the links they can use. func (s *Server) page(r *http.Request, title string, script bool) page { - p := page{Base: s.cfg.BasePath, Title: title, Script: script} + p := page{Base: s.cfg.BasePath, Title: title, Script: script, Source: s.cfg.SourceURL} if s.favicon != "" { p.Favicon = s.cfg.BasePath + "static/" + s.favicon } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index fb6b3b8..134676c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1146,6 +1146,49 @@ func TestNoFaviconMeansNoLink(t *testing.T) { } } +// --- the footer ------------------------------------------------------------ + +// The footer names where the source lives, and an instance that would rather +// not say so can drop it. A fork gets to point at its own. +func TestSourceLinkIsOptional(t *testing.T) { + shown := newHarness(t, nil) + resp := shown.get(t, "/", "") + page, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(page), `