many many changes

38fa4e6cd9bb… · Ryan Schanzenbacher ·

parent c0427d5fc7c0… view tree

Changed files

diff --git a/AGENTS.md b/AGENTS.md
index 676ef83..ee02972 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,6 +51,7 @@ main.go flags, repo discovery, embedded assets
internal/gitx/ the ONLY package that knows git exists
internal/core/ Ctx, module registry, routes, server, cache, funcmap
internal/modules/ the extension point: one module per concern
+internal/notices/ operator notices: YAML store + admin UI (imports no core)
internal/api/ JSON DTOs -- the serialization allowlist
internal/site/ the route table
templates/ layouts/ + views/
@@ -127,6 +128,17 @@ The cache needs no invalidation logic because it is keyed on immutable SHAs.
Do not add TTLs or manual eviction on the assumption that entries go stale —
if an entry can go stale, the key is wrong.
+`Ctx.contentIdentity()` also folds in inputs that change *without* the commit
+moving: `RepoInfo.GPGKeyring` and `Site.Revision()`. Site revision is the
+site-level analogue of the keyring; operator notices use it so a banner edit
+invalidates both the module cache and every ETag. Anything else with that
+shape belongs there too.
+
+`Ctx.Immutable` marks responses served with the year-long immutable cache
+(see `Server.serve`). Commit-independent chrome must not be rendered into
+them, because the browser is told never to revalidate; the notices partial
+skips those pages for exactly this reason.
+
### 4. JSON endpoints use DTOs, never module values
`internal/api` defines explicit structs with json tags. Go marshals every
@@ -143,10 +155,18 @@ A repository can contain an `.html` file with a `<script>` tag. Serving it as
`text/html` on this origin turns any push into stored XSS against the whole
site — which is why GitHub puts raw content on a separate hostname. Raw
responses are `text/plain` (or `application/octet-stream` plus
-`Content-Disposition: attachment` for binaries), with
+`Content-Disposition: attachment` for binaries) with
`X-Content-Type-Options: nosniff` and
`Content-Security-Policy: default-src 'none'; sandbox`.
+The one deliberate exception is a validated raster image (PNG, JPEG, GIF,
+WebP, BMP, ICO, AVIF): `modules/image.go` requires the extension and the
+magic bytes to agree before `/raw/` may declare `image/*`, so an `.png`
+holding HTML is not relabelled. SVG is never in that list — it is an XML
+document that can carry a script, so it stays `text/plain`. A raster image
+is safe to serve inline because it cannot execute and `nosniff` stops the
+declared type from being re-sniffed; the blob page needs those bytes on the
+same origin for its `<img>` preview, and the same URL is the download link.
If raw content is ever moved to its own hostname, keep these headers anyway.
### 6. goldmark runs without `WithUnsafe`
@@ -237,6 +257,18 @@ A config key that nothing reads is a bug. `gsg.section` sat unused for a
while; if you add a key, wire it into a template or an endpoint in the same
change.
+### Add an operator notice source
+
+Notices (global and per-repo banners) live in `internal/notices`: a
+file-backed store watched for changes, plus the admin listener. The
+`notices` module merges the global list with the current repo's, and
+`templates/layouts/notices.html` renders them — but only when
+`Ctx.Immutable` is false. `main.go` wires the store to `Site.RevFn` so a
+change invalidates caches. `internal/notices` deliberately does not import
+`internal/core`; keep it that way so the module and the admin handler can
+both reach it without a cycle. The admin listener is the one deliberate
+write path, and it writes a config file, never a repository.
+
## Building and checking
```sh
@@ -304,3 +336,7 @@ Accurate as of now; update this list when you close one.
lookup per link.
- Module errors surface at render time, not load time. A CI pass that renders
every view against a fixture repository would catch them earlier.
+- Path queries in the structured JSON/YAML search store the deepest path
+ found for a line. Flow style (`{"a": 1, "b": 2}`) puts several values on
+ one row and keeps only the last path; a container query still reaches the
+ others through the prefix match, but a sibling's exact path does not.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..37babad
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG TARGETOS=linux
+ARG TARGETARCH=amd64
+RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/gsg .
+
+FROM alpine:3.20
+RUN apk add --no-cache git gnupg
+COPY --from=build /out/gsg /usr/local/bin/gsg
+
+EXPOSE 8080
+ENTRYPOINT ["gsg"]
+CMD ["-root", "/srv/git", "-addr", ":8080"]
diff --git a/README.md b/README.md
index 4754120..989bc2b 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ pattern wildcards, so there is no router dependency either).
go build -o gsg .
./gsg -root /srv/git -addr :8080 -name "rschanz.org"
./gsg -root /srv/git -templates ./templates # re-parse the theme per request
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
```
## The idea
@@ -97,14 +98,36 @@ A repository can contain an `.html` file with a `<script>` tag. Serving that
as `text/html` on your own origin turns any push into stored XSS against the
whole site — which is why GitHub puts raw content on
`raw.githubusercontent.com`, a separate origin. Same-host serving is only
-safe if the response cannot be interpreted as a document, so `/raw/` always
-sends `text/plain` (or `application/octet-stream` for binaries, plus
+safe if the response cannot be interpreted as a document, so `/raw/` sends
+`text/plain` (or `application/octet-stream` for binaries, plus
`Content-Disposition: attachment`), with `X-Content-Type-Options: nosniff`
and `Content-Security-Policy: default-src \'none\'; sandbox` as a backstop.
+The exception is a validated raster image (PNG, JPEG, GIF, WebP, BMP, ICO,
+AVIF): when the extension and the magic bytes agree, `/raw/` declares the
+real `image/*` type inline so the blob page can preview it. An extension by
+itself is not enough — an `.png` whose contents are HTML still downloads as
+`application/octet-stream`. SVG is deliberately excluded: it is an XML
+document that can execute script, so it stays `text/plain`.
+
If you would rather have real isolation, serve `/raw/` from a second
hostname and the CSP becomes belt-and-braces rather than the only line.
+### Images and structured config files
+
+An image blob shows an inline preview with a `download` link to the same
+`/raw/` URL; images over 2 MiB skip the preview and offer the download only.
+
+JSON and YAML blobs get per-level indent guides, fold controls, key/value
+search, and YAML anchor/alias links. The search box also accepts a value
+path: typing `obj1.property3.items[4].val` resolves it against the parsed
+document and highlights that line and, for a container, its subtree. A
+query containing `.` or `[` is tried as a path first and falls back to
+plain substring search when it does not resolve, so dotted text stays
+searchable. Paths quote keys that need it (`obj1["a.b"]`) and attach indices
+directly; SVG is never previewed, and files past 512 KiB skip the parsing
+tooling entirely.
+
### Branch graph
`modules/graph.go` lays out the DAG in lanes in one pass over
@@ -176,7 +199,8 @@ internal/core/route.go Route, View, JSON
internal/core/server.go request -> Ctx, ETags, cache policy
internal/core/cache.go LRU keyed on commit SHAs
internal/core/funcs.go template funcmap (mod, modOr, dict, date, bytes)
-internal/modules/ meta refs log tree readme repos commit diff blob
+internal/modules/ meta refs log tree readme repos commit diff blob notices
+internal/notices/ notices YAML store + admin UI (imports no core)
internal/api/ JSON DTOs — the serialization allowlist
internal/site/routes.go the route table (the file you edit most)
templates/ layouts/ + views/
@@ -196,6 +220,88 @@ and the template renders a flat list — no config, no headings.
git --git-dir=/srv/git/demo.git config gsg.section "Infrastructure"
```
+## Notices
+
+Operator-authored banners at the top of every page, site-wide and per
+repository. They live in one YAML file that is watched for changes, so
+editing it never needs a restart or a rebuild:
+
+```yaml
+global:
+ - type: critical # critical | warning | info | fix
+ title: Maintenance window
+ body: |
+ Saturday 02:00-04:00 UTC.
+ The site is read-only during the window.
+ link: https://status.example.org
+ link_text: status page
+repos:
+ demo:
+ - type: warning
+ title: Migrating to a new remote
+```
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml
+```
+
+Global notices render above every repository's; on the site index only the
+global list appears. The file is re-read on write (atomic-rename saves from
+an editor included); a parse error keeps the last good content rather than
+blanking the banners.
+
+### Editing from the web
+
+`-admin-addr` starts a second listener with a small HTML form to add, edit,
+and delete notices. It is deliberately separate from the read-only server:
+the admin routes are not reachable on the public port even if the reverse
+proxy is misconfigured.
+
+```sh
+./gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
+```
+
+Protect it with Caddy `forward_auth` and proxy it to the admin listener. gsg
+trusts the network in front of it, so the admin port must not be published
+directly. In Compose, put 8081 on the internal network only and pass
+`-admin-allow-remote`, because a container address is not loopback. Note
+that gsg performs no authentication itself -- the `forward_auth` block below
+is the only thing between the internet and the admin form.
+
+```caddyfile
+example.com {
+ # Admin: authenticate first, then proxy to the separate admin listener.
+ # handle, not handle_path: the admin mux matches the full /admin/...
+ # path, so stripping the prefix would 404 every request.
+ handle /admin/* {
+ forward_auth authentik:9000 {
+ uri /outpost.goauthentik.io/auth/caddy
+ copy_headers X-Authentik-Username
+ }
+ reverse_proxy gsg:8081
+ }
+
+ # Public, read-only.
+ handle {
+ reverse_proxy gsg:8080
+ }
+}
+```
+
+The `X-Authentik-Username` header is used only to attribute the mutation in
+the log; it is never consulted for authorization. Authentik decides who may
+reach the handler at all.
+
+Mutations are POST-only, CSRF-protected (double-submit cookie plus
+`Origin`/`Sec-Fetch-Site`), and logged with the `X-Authentik-Username`
+header, which is used for audit only, never for authorization.
+
+**Immutable commit pages carry no notices.** `/{repo}/commit/{rev}` is
+served `Cache-Control: public, max-age=31536000, immutable`, so a banner
+rendered into it would stay frozen in the reader's browser. Notices are
+suppressed there by design; every other page picks them up on the next
+request.
+
## README rendering
`modules/markdown.go` uses goldmark with GFM, Typographer, and automatic
diff --git a/internal/core/core.go b/internal/core/core.go
index cdc447f..509d656 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -20,6 +20,14 @@ type Site struct {
BaseURL string
Root string // directory containing bare repos
+ // RevFn, when set, returns a token that changes whenever site-level
+ // configuration changes without a commit moving -- operator notices,
+ // for example. Ctx.contentIdentity() folds it into both the module
+ // cache key and the response ETag, so a module reading that config
+ // cannot serve a result that predates the change. Set once at startup,
+ // before the server begins serving.
+ RevFn func() string
+
idx atomic.Pointer[repoIndex]
mu sync.Mutex // serializes upsert/remove; RepoList/Lookup never block
}
@@ -51,6 +59,16 @@ func (s *Site) Lookup(slug string) (Handle, bool) {
return h, ok
}
+// Revision is the site-level analogue of RepoInfo.GPGKeyring: a token for
+// inputs that change outside a commit. Nil-safe because tests construct
+// bare *Ctx values and LoadTemplates parses with &Ctx{}.
+func (s *Site) Revision() string {
+ if s == nil || s.RevFn == nil {
+ return ""
+ }
+ return s.RevFn()
+}
+
// upsert and remove are the only writers of the repo index, both called
// exclusively from watch.go's reconcile -- at startup and at runtime alike,
// through the same code path.
@@ -176,6 +194,14 @@ type Ctx struct {
// cache and ETags correct rather than merely convenient.
Commit string
+ // Immutable is true when this response is served with the year-long
+ // immutable cache policy (see Server.serve). Page chrome that can change
+ // without the commit moving must not be rendered into it, because the
+ // browser is told never to revalidate. Operator notices are the first
+ // such thing; this is deliberately the *computed* condition, not just
+ // Route.Immutable, so it follows whatever the caching rule becomes.
+ Immutable bool
+
// Store is the process-wide cache, nil in tests. Only consulted when
// Commit is set.
Store Store
@@ -208,6 +234,9 @@ func NewCtx(site *Site, r *gitx.Repo, info RepoInfo, ref string) *Ctx {
// is part of it for the same reason: watch.go's reconcile can change a
// repo's keyring under commits whose SHAs haven't moved, and a page
// fetched before that would otherwise cache "unsigned" forever.
+// Site.Revision() is the site-level equivalent of the keyring: operator
+// notices change without a commit moving, so a page cached before a notice
+// edit would otherwise keep serving the old banners forever.
//
// CacheKey and the response ETag (server.go) are both built from this, so
// a new Var or a keyring change can't invalidate one without invalidating
@@ -220,7 +249,7 @@ func (c *Ctx) contentIdentity() string {
}
sort.Strings(keys)
var b strings.Builder
- b.WriteString(c.Commit + "|" + c.Info.GPGKeyring)
+ b.WriteString(c.Commit + "|" + c.Info.GPGKeyring + "|" + c.Site.Revision())
for _, k := range keys {
b.WriteString("|" + k + "=" + c.Vars[k])
}
diff --git a/internal/core/core_test.go b/internal/core/core_test.go
new file mode 100644
index 0000000..9ad819a
--- /dev/null
+++ b/internal/core/core_test.go
@@ -0,0 +1,27 @@
+package core
+
+import (
+ "strings"
+ "testing"
+)
+
+// The site revision is the site-level analogue of RepoInfo.GPGKeyring: it
+// must be part of contentIdentity or a notice change cannot invalidate the
+// module cache or the response ETag.
+func TestContentIdentityIncludesSiteRevision(t *testing.T) {
+ a := NewCtx(&Site{RevFn: func() string { return "rev-a" }}, nil, RepoInfo{}, "main")
+ b := NewCtx(&Site{RevFn: func() string { return "rev-b" }}, nil, RepoInfo{}, "main")
+ a.Commit, b.Commit = "sha", "sha"
+ if a.contentIdentity() == b.contentIdentity() {
+ t.Fatal("site revision is not part of contentIdentity")
+ }
+}
+
+// Tests construct bare *Ctx values (see blob_test.go) and LoadTemplates
+// parses with &Ctx{}; contentIdentity must tolerate a nil Site.
+func TestContentIdentityNilSite(t *testing.T) {
+ got := (&Ctx{}).contentIdentity()
+ if !strings.HasPrefix(got, "|") {
+ t.Fatalf("contentIdentity() = %q", got)
+ }
+}
diff --git a/internal/core/errorpage.go b/internal/core/errorpage.go
index 365396e..1e47baa 100644
--- a/internal/core/errorpage.go
+++ b/internal/core/errorpage.go
@@ -33,7 +33,7 @@ var errorTmpl = template.Must(template.New("error").Parse(`<!doctype html>
// execute, it falls back to a plain-text body rather than propagating.
func renderErrorPage(code int, site string) (body []byte, contentType string) {
data := struct {
- Code int
+ Code int
Status, Message, Site string
}{
Code: code,
diff --git a/internal/core/route.go b/internal/core/route.go
index 4a3800e..dba5f32 100644
--- a/internal/core/route.go
+++ b/internal/core/route.go
@@ -136,8 +136,11 @@ type HeaderSetter interface {
// XSS against the whole site -- which is exactly why GitHub puts raw content
// on raw.githubusercontent.com, a separate origin. Serving it from the same
// host is only safe if the response cannot be interpreted as a document:
-// text/plain or octet-stream, nosniff so the browser does not second-guess
-// that, and a sandbox CSP as a backstop.
+// text/plain or octet-stream for everything the module did not validate,
+// nosniff so the browser does not second-guess that, and a sandbox CSP as a
+// backstop. The one exception is InlineContentTyper: a module that has
+// checked the bytes may name a raster image type, which cannot execute and
+// is still covered by nosniff.
type Raw struct{ Module string }
func (Raw) ContentType() string { return "text/plain; charset=utf-8" }
@@ -163,8 +166,27 @@ func (r Raw) Headers(c *Ctx, h http.Header) {
if err != nil {
return
}
+ // A module may declare a validated inline MIME (raster images). It is
+ // checked before IsBinary so an image is served as image/*, not
+ // octet-stream; only modules that vetted the bytes may return one, and
+ // the nosniff above keeps the declared type from being second-guessed.
+ if ct, ok := v.(InlineContentTyper); ok {
+ if s := ct.InlineContentType(); s != "" {
+ h.Set("Content-Type", s)
+ return
+ }
+ }
if bin, ok := v.(interface{ IsBinary() bool }); ok && bin.IsBinary() {
h.Set("Content-Type", "application/octet-stream")
h.Set("Content-Disposition", "attachment")
}
}
+
+// InlineContentTyper lets a raw-serving module declare a MIME type that is
+// safe to render inline on this origin. It exists so core stays ignorant of
+// formats: the module that knows how to validate image bytes is the module
+// that names the type. An empty return keeps the text/plain or
+// octet-stream-attachment default.
+type InlineContentTyper interface {
+ InlineContentType() string
+}
diff --git a/internal/core/server.go b/internal/core/server.go
index adf677c..4c5cd65 100644
--- a/internal/core/server.go
+++ b/internal/core/server.go
@@ -123,7 +123,7 @@ func (s *Server) serve(rt Route, w http.ResponseWriter, r *http.Request) {
// on, so this can't drift out of sync with what CacheKey covers.
etag := `"` + shortHash(rt.Name, r.URL.Path, r.URL.RawQuery, tag, c.contentIdentity()) + `"`
w.Header().Set("ETag", etag)
- if rt.Immutable && c.Commit != "" {
+ if c.Immutable {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
@@ -201,8 +201,20 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
if err != nil {
return nil, Errorf(http.StatusNotFound, "%v", err)
}
+ headSHA := c.Commit
+ // Two-dot compares are undirected as far as the caller is concerned:
+ // whichever ref is the ancestor should read as "base", regardless of
+ // which side of ".." it was typed on. Three-dot is left alone --
+ // its merge-base semantics are direction-sensitive by design.
+ if dots == "2" && baseSHA != headSHA {
+ if anc, err := repo.IsAncestor(headSHA, baseSHA); err == nil && anc {
+ base, ref = ref, base
+ baseSHA, headSHA = headSHA, baseSHA
+ }
+ }
+ c.Ref, c.Commit = ref, headSHA
c.Vars["base"], c.Vars["head"] = base, ref
- c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, c.Commit
+ c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA
c.Vars["dots"] = dots
}
if rt.Name == "log" || rt.Name == "graph" {
@@ -217,6 +229,11 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
}
}
}
+ // One source of truth for the immutable policy: the cache header in
+ // serve() reads the same field, so page chrome that must not be frozen
+ // into a year-long response is suppressed by exactly the condition that
+ // makes the response year-long.
+ c.Immutable = rt.Immutable && c.Commit != ""
return c, nil
}
diff --git a/internal/gitx/git.go b/internal/gitx/git.go
index b7b38e2..48512b5 100644
--- a/internal/gitx/git.go
+++ b/internal/gitx/git.go
@@ -6,6 +6,7 @@ package gitx
import (
"bytes"
"context"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -108,7 +109,12 @@ func (r *Repo) run(args ...string) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
}
- full := append([]string{"--git-dir", r.Dir}, args...)
+ // safe.directory=* bypasses git's dubious-ownership check (CVE-2022-24765).
+ // gsg runs against operator-chosen repo roots, commonly bind-mounted into
+ // a container where the mount ends up owned by a different uid than the
+ // process -- a trust boundary git can't see, since HOME=/nonexistent
+ // below means there's no config file left to set this in from outside.
+ full := append([]string{"--git-dir", r.Dir, "-c", "safe.directory=*"}, args...)
cmd := exec.CommandContext(ctx, "git", full...)
// Refuse to inherit anything that alters git's behaviour.
cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "GIT_TERMINAL_PROMPT=0", "PATH=" + os.Getenv("PATH")}
@@ -515,6 +521,20 @@ func (r *Repo) MergeBase(a, b string) (string, error) {
return strings.TrimSpace(string(out)), nil
}
+// IsAncestor reports whether a is an ancestor of b (true, in particular,
+// when a == b).
+func (r *Repo) IsAncestor(a, b string) (bool, error) {
+ _, err := r.run("merge-base", "--is-ancestor", a, b)
+ if err == nil {
+ return true, nil
+ }
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
+ return false, nil
+ }
+ return false, err
+}
+
// LogAll walks every ref in topological order. This is the input to the
// branch graph: --topo-order guarantees a child always precedes its
// parents, which the lane assignment depends on.
diff --git a/internal/gitx/git_test.go b/internal/gitx/git_test.go
index cc3b760..73dd814 100644
--- a/internal/gitx/git_test.go
+++ b/internal/gitx/git_test.go
@@ -91,3 +91,67 @@ func TestSplitRefPath(t *testing.T) {
})
}
}
+
+// TestIsAncestor builds its own repo (rather than reusing newTestRepo,
+// whose branches all point at the same single commit) because it needs a
+// real linear ancestor/descendant pair plus a genuinely diverged pair:
+// "main" advances past the point where "other" branched off, then "other"
+// advances on its own, so main's tip and other's tip share c1 as a common
+// ancestor but are not ancestors of each other.
+func TestIsAncestor(t *testing.T) {
+ dir := t.TempDir()
+ runGit(t, dir, "init", "-q", "-b", "main")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c1")
+
+ r, err := Open(dir)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ c1, err := r.Resolve("main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ runGit(t, dir, "branch", "other")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c2")
+ c2, err := r.Resolve("main")
+ if err != nil {
+ t.Fatalf("Resolve(main): %v", err)
+ }
+
+ runGit(t, dir, "checkout", "-q", "other")
+ runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c3-diverged")
+ c3, err := r.Resolve("other")
+ if err != nil {
+ t.Fatalf("Resolve(other): %v", err)
+ }
+
+ cases := []struct {
+ name string
+ a, b string
+ want bool
+ }{
+ {"strict ancestor", c1, c2, true},
+ {"reversed order is not an ancestor relationship", c2, c1, false},
+ {"a commit is its own ancestor", c1, c1, true},
+ {"diverged commits are not ancestors either direction", c2, c3, false},
+ {"diverged commits, reverse direction", c3, c2, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := r.IsAncestor(tc.a, tc.b)
+ if err != nil {
+ t.Fatalf("IsAncestor(%q, %q): %v", tc.a, tc.b, err)
+ }
+ if got != tc.want {
+ t.Errorf("IsAncestor(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
+ }
+ })
+ }
+
+ t.Run("bogus revision is an error, not false", func(t *testing.T) {
+ if _, err := r.IsAncestor("deadbeef", c1); err == nil {
+ t.Error("IsAncestor with a bogus revision: got nil error, want non-nil")
+ }
+ })
+}
diff --git a/internal/modules/blob.go b/internal/modules/blob.go
index dab96e1..57cf2f0 100644
--- a/internal/modules/blob.go
+++ b/internal/modules/blob.go
@@ -14,6 +14,7 @@ type Blob struct {
Path string
Size int
Binary bool
+ Image string // validated raster MIME, "" when not a previewable image
Lines []Line
Submodule *Submodule
Lang string // "json" or "yaml", when the path implies one; "" otherwise
@@ -30,6 +31,14 @@ func (b Blob) Structured() bool { return b.Lang != "" && !b.Oversize }
// so the banner explaining the fallback can name the threshold.
func (b Blob) StructuredLimit() int { return maxStructuredSize }
+// Preview reports whether the image is small enough to render inline. An
+// image over maxPreviewSize is still downloadable; it just is not fetched a
+// second time by every reader who only wanted to see the tree around it.
+func (b Blob) Preview() bool { return b.Image != "" && b.Size <= maxPreviewSize }
+
+// PreviewLimit names the threshold for the too-large notice.
+func (b Blob) PreviewLimit() int { return maxPreviewSize }
+
// Submodule describes a gitlink entry (mode 160000): a pointer to a commit
// in another repository rather than file content, so there is nothing to
// diff or display line-by-line.
@@ -48,6 +57,7 @@ type Line struct {
Depth int // nesting level, in indent units
FoldTo int // last line of the block this one opens; 0 if it opens none
Key string // mapping key declared here, for key-scoped search
+ Path string // canonical value path, for path queries; "" when unknown
Anchor *Anchor // YAML anchor declared here
Ref *StructRef // YAML alias resolved here
}
@@ -80,6 +90,13 @@ func blobMod(c *core.Ctx, args ...string) (any, error) {
if len(head) > 8000 {
head = head[:8000]
}
+ // Check the image magic before the generic binary test: a valid raster
+ // image is served as a preview rather than a wall of numbered bytes.
+ b.Image = imageContentType(path, raw)
+ if b.Image != "" {
+ b.Binary = true
+ return b, nil
+ }
if bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head) {
b.Binary = true
return b, nil
@@ -113,10 +130,12 @@ type RawBlob struct {
Path string
Data []byte
Binary bool
+ Image string // validated raster MIME; see imageContentType
}
-func (r RawBlob) Bytes() []byte { return r.Data }
-func (r RawBlob) IsBinary() bool { return r.Binary }
+func (r RawBlob) Bytes() []byte { return r.Data }
+func (r RawBlob) IsBinary() bool { return r.Binary }
+func (r RawBlob) InlineContentType() string { return r.Image }
func init() { core.Register("blobraw", blobRawMod) }
@@ -136,5 +155,10 @@ func blobRawMod(c *core.Ctx, args ...string) (any, error) {
if len(head) > 8000 {
head = head[:8000]
}
- return RawBlob{Path: path, Data: raw, Binary: bytes.IndexByte(head, 0) >= 0}, nil
+ return RawBlob{
+ Path: path,
+ Data: raw,
+ Binary: bytes.IndexByte(head, 0) >= 0,
+ Image: imageContentType(path, raw),
+ }, nil
}
diff --git a/internal/modules/image.go b/internal/modules/image.go
new file mode 100644
index 0000000..288e206
--- /dev/null
+++ b/internal/modules/image.go
@@ -0,0 +1,70 @@
+package modules
+
+import (
+ "bytes"
+ "path"
+ "strings"
+)
+
+// maxPreviewSize bounds how large an image the blob view will render inline.
+// Past it the view keeps the download link but drops the preview, so a large
+// file is not pulled over the wire twice (once as the page's subresource,
+// once as the bytes the reader actually wanted). The /raw/ route itself is
+// not capped here: a direct request is an explicit request for the bytes,
+// and MaxGit still bounds every git read.
+const maxPreviewSize = 2 << 20 // 2 MiB
+
+// imageContentType returns the MIME type to serve at p when its leading
+// bytes are that format and the extension agrees, and "" otherwise.
+//
+// SECURITY: this is the one place /raw/ is allowed to declare a non-text
+// Content-Type, so the rules are deliberately narrow:
+// - only raster formats are recognised. SVG is an XML document that can
+// carry <script>, so it is never in this list and keeps the text/plain
+// fallback; the danger is top-level navigation to an image/svg+xml
+// response on this origin, not <img>, where scripts do not run.
+// - the extension and the magic bytes must BOTH match, so an .png whose
+// contents are really HTML is not relabelled as an image. Relaxing the
+// current attachment rule for attacker-influenced bytes is only safe
+// because a mislabelled file fails here and keeps the download.
+// - the caller still sends X-Content-Type-Options: nosniff and the
+// sandbox CSP, so even a polyglot declared image/* cannot be re-sniffed
+// as a document.
+func imageContentType(p string, data []byte) string {
+ switch strings.ToLower(path.Ext(p)) {
+ case ".png":
+ if bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) {
+ return "image/png"
+ }
+ case ".jpg", ".jpeg":
+ if bytes.HasPrefix(data, []byte("\xff\xd8\xff")) {
+ return "image/jpeg"
+ }
+ case ".gif":
+ if bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a")) {
+ return "image/gif"
+ }
+ case ".webp":
+ if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) &&
+ bytes.Equal(data[8:12], []byte("WEBP")) {
+ return "image/webp"
+ }
+ case ".bmp":
+ if bytes.HasPrefix(data, []byte("BM")) {
+ return "image/bmp"
+ }
+ case ".ico":
+ if bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")) {
+ return "image/x-icon"
+ }
+ case ".avif":
+ // ISO-BMFF: "ftyp" at offset 4, major brand avif/avis at 8. A file
+ // with a compatible-but-not-major avif brand is left unlabelled
+ // rather than guessed at.
+ if len(data) >= 12 && bytes.Equal(data[4:8], []byte("ftyp")) &&
+ (bytes.Equal(data[8:12], []byte("avif")) || bytes.Equal(data[8:12], []byte("avis"))) {
+ return "image/avif"
+ }
+ }
+ return ""
+}
diff --git a/internal/modules/image_test.go b/internal/modules/image_test.go
new file mode 100644
index 0000000..ab563a0
--- /dev/null
+++ b/internal/modules/image_test.go
@@ -0,0 +1,99 @@
+package modules
+
+import "testing"
+
+// A file only earns an inline image MIME when its extension and its magic
+// bytes agree and the format is a raster one. The cases that must return ""
+// are the attacker-influenced ones: content that is not an image at all, and
+// SVG, which is an active document rather than a picture.
+func TestImageContentType(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + "rest of the file")
+ tests := []struct {
+ name string
+ path string
+ data []byte
+ want string
+ }{
+ {"png", "logo.png", png, "image/png"},
+ {"uppercase extension", "LOGO.PNG", png, "image/png"},
+ {"jpeg", "a.jpg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"},
+ {"jpeg long extension", "a.jpeg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"},
+ {"gif87", "a.gif", []byte("GIF87a...."), "image/gif"},
+ {"gif89", "a.gif", []byte("GIF89a...."), "image/gif"},
+ {"webp", "a.webp", []byte("RIFF\x00\x00\x00\x00WEBPVP8 "), "image/webp"},
+ {"bmp", "a.bmp", []byte("BM...."), "image/bmp"},
+ {"ico", "a.ico", []byte{0x00, 0x00, 0x01, 0x00}, "image/x-icon"},
+ {"avif", "a.avif", []byte("\x00\x00\x00\x18ftypavif"), "image/avif"},
+
+ // HTML wearing a .png name, and a real PNG named .txt, must both
+ // fall back to the download path.
+ {"html named png", "evil.png", []byte("<html><script>x</script>"), ""},
+ {"png extension without magic", "evil.png", []byte("not an image"), ""},
+ {"magic without extension", "evil.txt", png, ""},
+ {"truncated png", "evil.png", []byte("\x89PNG"), ""},
+
+ // SVG can carry <script> and would be stored XSS if served as a
+ // document on this origin, so it is never recognised here.
+ {"svg", "evil.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>`), ""},
+ {"no extension", "Makefile", []byte("all:\n"), ""},
+ }
+ for _, tc := range tests {
+ if got := imageContentType(tc.path, tc.data); got != tc.want {
+ t.Errorf("%s: imageContentType(%q) = %q, want %q", tc.name, tc.path, got, tc.want)
+ }
+ }
+}
+
+// The blob view only renders a preview for a validated raster image, and
+// only while it is under the size cap; both facts are what tell the view
+// whether to show an <img>, a download notice, or numbered bytes.
+func TestImageBlobPreview(t *testing.T) {
+ png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
+ big := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, maxPreviewSize)...)
+ c := blobCtx(t, map[string]string{
+ "logo.png": string(png),
+ "big.png": string(big),
+ "evil.png": "<html>not an image</html>",
+ })
+
+ b := blobAt(t, c, "logo.png")
+ if b.Image != "image/png" || !b.Preview() || !b.Binary {
+ t.Errorf("small image: Image=%q Preview=%v Binary=%v", b.Image, b.Preview(), b.Binary)
+ }
+ if b.PreviewLimit() != maxPreviewSize {
+ t.Errorf("PreviewLimit = %d, want %d", b.PreviewLimit(), maxPreviewSize)
+ }
+
+ bigBlob := blobAt(t, c, "big.png")
+ if bigBlob.Image != "image/png" {
+ t.Errorf("large image: Image = %q, want image/png", bigBlob.Image)
+ }
+ if bigBlob.Preview() {
+ t.Error("large image was previewed past the size cap")
+ }
+
+ // Not an image, so it keeps the plain binary path rather than a preview.
+ evil := blobAt(t, c, "evil.png")
+ if evil.Image != "" || evil.Preview() {
+ t.Errorf("misnamed html: Image=%q Preview=%v, want none", evil.Image, evil.Preview())
+ }
+}
+
+// /raw/ must declare the image MIME so the preview and direct navigation
+// render a picture, and must expose it through the interface core checks.
+func TestRawBlobInlineImage(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n" + "body")
+ c := blobCtx(t, map[string]string{"logo.png": string(png)})
+ c.Path = "logo.png"
+ v, err := blobRawMod(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, ok := v.(RawBlob)
+ if !ok {
+ t.Fatalf("got %T", v)
+ }
+ if r.InlineContentType() != "image/png" {
+ t.Errorf("InlineContentType = %q, want image/png", r.InlineContentType())
+ }
+}
diff --git a/internal/modules/notices.go b/internal/modules/notices.go
new file mode 100644
index 0000000..e0df81f
--- /dev/null
+++ b/internal/modules/notices.go
@@ -0,0 +1,29 @@
+package modules
+
+import (
+ "gsg/internal/core"
+ "gsg/internal/notices"
+)
+
+func init() { core.Register("notices", noticesMod) }
+
+// notices: the site-wide banners followed by the ones for the repository
+// being viewed, in that order. Site-scoped routes (the index) build a Ctx
+// with c.Repo == nil and an empty Info.Slug, so this must never touch
+// c.Repo and must skip the repo half when Slug is empty. A nil default
+// store (the feature is off) is an empty list, not an error -- the
+// template uses modOr and guards on Immutable, so an empty list renders
+// nothing.
+func noticesMod(c *core.Ctx, args ...string) (any, error) {
+ st := notices.Default()
+ if st == nil {
+ return []notices.Notice(nil), nil
+ }
+ snap := st.Snapshot()
+ out := make([]notices.Notice, 0, len(snap.Doc.Global)+8)
+ out = append(out, snap.Doc.Global...)
+ if c.Info.Slug != "" {
+ out = append(out, snap.Doc.Repos[c.Info.Slug]...)
+ }
+ return out, nil
+}
diff --git a/internal/modules/notices_test.go b/internal/modules/notices_test.go
new file mode 100644
index 0000000..02ff9e3
--- /dev/null
+++ b/internal/modules/notices_test.go
@@ -0,0 +1,88 @@
+package modules
+
+import (
+ "html/template"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gsg/internal/core"
+ "gsg/internal/notices"
+)
+
+// Global notices come before the repository's own; a site-scoped Ctx (nil
+// Repo, empty Slug) gets the global list only and must not panic.
+func TestNoticesModMergesGlobalThenRepo(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "notices.yaml")
+ st, err := notices.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+ err = st.Set(notices.Document{
+ Global: []notices.Notice{{Type: notices.TypeCritical, Title: "global"}},
+ Repos: map[string][]notices.Notice{"demo": {{Type: notices.TypeInfo, Title: "repo"}}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ notices.SetDefault(st)
+ t.Cleanup(func() { notices.SetDefault(nil) })
+
+ v, err := noticesMod(&core.Ctx{Info: core.RepoInfo{Slug: "demo"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := v.([]notices.Notice)
+ if !ok {
+ t.Fatalf("notices returned %T", v)
+ }
+ if len(got) != 2 || got[0].Title != "global" || got[1].Title != "repo" {
+ t.Fatalf("got %+v", got)
+ }
+
+ v, err = noticesMod(&core.Ctx{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := v.([]notices.Notice); len(got) != 1 || got[0].Title != "global" {
+ t.Fatalf("site-scoped got %+v", got)
+ }
+}
+
+// The notices partial must render nothing on a page that will be cached
+// immutably, and must not even call the module there -- the guard wraps the
+// modOr call, not just its output.
+func TestNoticesPartialSuppressesImmutable(t *testing.T) {
+ src, err := os.ReadFile("../../templates/layouts/notices.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ tmpl, err := template.New("notices.html").Funcs(template.FuncMap{
+ "modOr": func(string, ...string) any {
+ calls++
+ return []notices.Notice{{Type: notices.TypeInfo, Title: "banner"}}
+ },
+ }).Parse(string(src))
+ if err != nil {
+ t.Fatal(err)
+ }
+ render := func(immutable bool) string {
+ var sb strings.Builder
+ if err := tmpl.ExecuteTemplate(&sb, "notices", struct{ Immutable bool }{immutable}); err != nil {
+ t.Fatal(err)
+ }
+ return sb.String()
+ }
+
+ got := render(true)
+ if strings.Contains(got, "banner") || calls != 0 {
+ t.Fatalf("immutable page rendered notices (calls=%d): %q", calls, got)
+ }
+ got = render(false)
+ if !strings.Contains(got, "banner") || calls != 1 {
+ t.Fatalf("non-immutable page did not render (calls=%d): %q", calls, got)
+ }
+}
diff --git a/internal/modules/structured.go b/internal/modules/structured.go
index b3f1093..f26045f 100644
--- a/internal/modules/structured.go
+++ b/internal/modules/structured.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"html/template"
"regexp"
+ "sort"
"strconv"
"strings"
@@ -161,9 +162,10 @@ func annotateStructure(lines []Line, raw []byte, lang string) {
}
}
if lang != "yaml" {
+ annotateJSONPaths(lines, raw)
return
}
- anchors, refs := yamlAnchors(src)
+ anchors, refs, paths := yamlAnnotations(src)
for n, a := range anchors {
if n >= 1 && n <= len(lines) {
lines[n-1].Anchor = a
@@ -174,6 +176,11 @@ func annotateStructure(lines []Line, raw []byte, lang string) {
lines[n-1].Ref = r
}
}
+ for n, p := range paths {
+ if n >= 1 && n <= len(lines) {
+ lines[n-1].Path = p
+ }
+ }
}
// lineIndent counts leading spaces, or returns -1 for a line that carries no
@@ -236,27 +243,29 @@ var (
jsonKeyRe = regexp.MustCompile(`^\s*"((?:[^"\\]|\\.)*)"\s*:`)
)
-// yamlAnchors resolves every alias in the file to the line its anchor is
-// declared on, keyed by 1-based line number. Anchors are per-document, so
-// the whole stream is decoded document by document -- a k8s manifest that
-// defines its anchors in the second document would otherwise come back
-// empty. A malformed document ends the walk with whatever was found first;
-// the parse-error banner already reports the failure itself.
-func yamlAnchors(src [][]byte) (map[int]*Anchor, map[int]*StructRef) {
+// yamlAnnotations resolves every alias in the file to the line its anchor is
+// declared on, keyed by 1-based line number, and records each line's
+// canonical value path. Anchors are per-document, so the whole stream is
+// decoded document by document -- a k8s manifest that defines its anchors in
+// the second document would otherwise come back empty. A malformed document
+// ends the walk with whatever was found first; the parse-error banner
+// already reports the failure itself.
+func yamlAnnotations(src [][]byte) (map[int]*Anchor, map[int]*StructRef, map[int]string) {
anchors := map[int]*Anchor{}
refs := map[int]*StructRef{}
+ paths := map[int]string{}
dec := yaml.NewDecoder(bytes.NewReader(bytes.Join(src, []byte("\n"))))
for {
var doc yaml.Node
if err := dec.Decode(&doc); err != nil {
break
}
- walkYAML(&doc, src, anchors, refs)
+ walkYAML(&doc, src, anchors, refs, paths, "")
}
- return anchors, refs
+ return anchors, refs, paths
}
-func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]*StructRef) {
+func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]*StructRef, paths map[int]string, path string) {
if n.Anchor != "" {
if line := anchorLine(src, n.Anchor, n.Line); line > 0 {
if _, ok := anchors[line]; !ok {
@@ -274,9 +283,43 @@ func walkYAML(n *yaml.Node, src [][]byte, anchors map[int]*Anchor, refs map[int]
a.Uses++
}
}
+ // An alias displays only *name here; the aliased value's lines are
+ // wherever the anchor was declared. Recursing into n.Alias would
+ // emit paths pointing at those other lines, so record this line's
+ // path and stop.
+ recordPath(paths, n.Line, path)
+ return
}
- for _, c := range n.Content {
- walkYAML(c, src, anchors, refs)
+
+ switch n.Kind {
+ case yaml.DocumentNode:
+ for _, c := range n.Content {
+ walkYAML(c, src, anchors, refs, paths, path)
+ }
+ case yaml.MappingNode:
+ for i := 0; i+1 < len(n.Content); i += 2 {
+ k, v := n.Content[i], n.Content[i+1]
+ child := joinPath(path, k.Value)
+ if k.Value == "<<" {
+ // A merge key is not a property of the mapping, and the
+ // rendered text never expands it, so a `<<` segment would
+ // name something the reader cannot see. Keep the parent.
+ child = path
+ }
+ // Walk the key too (anchors may be declared on one), but record
+ // the value's path last so it wins for the shared line.
+ walkYAML(k, src, anchors, refs, paths, path)
+ recordPath(paths, k.Line, child)
+ walkYAML(v, src, anchors, refs, paths, child)
+ }
+ case yaml.SequenceNode:
+ for i, c := range n.Content {
+ child := joinIndex(path, i)
+ recordPath(paths, c.Line, child)
+ walkYAML(c, src, anchors, refs, paths, child)
+ }
+ default:
+ recordPath(paths, n.Line, path)
}
}
@@ -362,3 +405,112 @@ func withIndentGuides(lines []template.HTML, unit int) []template.HTML {
}
return out
}
+
+// --------------------------------------------------------------- paths
+
+// A path query names a value the way obj1.property3.items[4].val does.
+// Both the server (here) and static/js/structured.js write the same
+// canonical form so a query can be compared as a string: simple keys are
+// dotted, indices attach directly, and anything else is bracket-quoted.
+var (
+ simplePathKey = regexp.MustCompile(`^[A-Za-z0-9_$-]+$`)
+ pathEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
+)
+
+func joinPath(parent, key string) string {
+ if simplePathKey.MatchString(key) {
+ if parent == "" {
+ return key
+ }
+ return parent + "." + key
+ }
+ return parent + `["` + pathEscaper.Replace(key) + `"]`
+}
+
+func joinIndex(parent string, i int) string {
+ return parent + "[" + strconv.Itoa(i) + "]"
+}
+
+// recordPath keeps the last path recorded for a line. A line can carry more
+// than one path (JSON/YAML flow style puts several values on one row), and
+// the last one parsed is the deepest -- the one a reader naming a full path
+// is most likely to mean. A container query still reaches its children by
+// the boundary-aware prefix match in structured.js, so dropping the shallow
+// path costs nothing there.
+func recordPath(paths map[int]string, line int, path string) {
+ if path == "" || line <= 0 {
+ return
+ }
+ paths[line] = path
+}
+
+// annotateJSONPaths records each line's canonical value path. It reuses
+// encoding/json's own decoder rather than a second parser, so the offsets it
+// sees are exactly the ones the format defines.
+func annotateJSONPaths(lines []Line, raw []byte) {
+ paths := jsonPaths(raw)
+ for i := range lines {
+ if p, ok := paths[lines[i].N]; ok {
+ lines[i].Path = p
+ }
+ }
+}
+
+func jsonPaths(raw []byte) map[int]string {
+ paths := map[int]string{}
+ lineOf := newLineIndex(raw)
+ dec := json.NewDecoder(bytes.NewReader(raw))
+
+ // walk consumes one JSON value at path. Objects record the path on the
+ // key's line and recurse into the value; arrays record it on each
+ // element's line. A malformed document simply stops -- the parse-error
+ // banner owns reporting that.
+ var walk func(path string)
+ walk = func(path string) {
+ tok, err := dec.Token()
+ if err != nil {
+ return
+ }
+ recordPath(paths, lineOf(dec.InputOffset()), path)
+ d, ok := tok.(json.Delim)
+ if !ok {
+ return
+ }
+ switch d {
+ case '{':
+ for dec.More() {
+ kt, err := dec.Token()
+ if err != nil {
+ return
+ }
+ key, _ := kt.(string)
+ child := joinPath(path, key)
+ recordPath(paths, lineOf(dec.InputOffset()), child)
+ walk(child)
+ }
+ dec.Token() // consume '}'
+ case '[':
+ for i := 0; dec.More(); i++ {
+ walk(joinIndex(path, i))
+ }
+ dec.Token() // consume ']'
+ }
+ }
+ walk("")
+ return paths
+}
+
+// newLineIndex maps a byte offset to a 1-based line number. Newline
+// positions are found once and binary-searched per token, so indexing a
+// whole file stays O(n log n) rather than counting from zero each time.
+func newLineIndex(raw []byte) func(int64) int {
+ var newlines []int64
+ for i, b := range raw {
+ if b == '\n' {
+ newlines = append(newlines, int64(i))
+ }
+ }
+ return func(off int64) int {
+ return sort.Search(len(newlines), func(i int) bool { return newlines[i] >= off }) + 1
+ }
+}
diff --git a/internal/modules/structured_test.go b/internal/modules/structured_test.go
index 4da0c4f..37b6277 100644
--- a/internal/modules/structured_test.go
+++ b/internal/modules/structured_test.go
@@ -156,3 +156,119 @@ func TestMinifiedJSONHasNoFolds(t *testing.T) {
t.Errorf("FoldTo = %d, want 0", ls[0].FoldTo)
}
}
+
+// Nested arrays of objects are the shape a path query like
+// obj1.property3.items[4].val is written against.
+func TestJSONPaths(t *testing.T) {
+ const src = `{
+ "obj1": {
+ "property3": {
+ "items": [
+ {
+ "val": 0
+ },
+ {
+ "val": 1
+ }
+ ]
+ }
+ }
+}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+
+ want := map[int]string{
+ 2: "obj1",
+ 3: "obj1.property3",
+ 4: "obj1.property3.items",
+ 5: "obj1.property3.items[0]",
+ 6: "obj1.property3.items[0].val",
+ 8: "obj1.property3.items[1]",
+ 9: "obj1.property3.items[1].val",
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.Path != got {
+ t.Errorf("line %d Path = %q, want %q", l.N, l.Path, got)
+ }
+ } else if l.Path != "" {
+ t.Errorf("line %d Path = %q, want none", l.N, l.Path)
+ }
+ }
+}
+
+// A flow-style row carries several paths at once; the deepest is kept, so
+// a query naming the final member still resolves. The shallow ones stay
+// reachable when a container query prefix-matches the deep path.
+func TestJSONFlowPathKeepsDeepest(t *testing.T) {
+ const src = `{"a": {"b": 1}}`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "json")
+ if ls[0].Path != "a.b" {
+ t.Errorf("Path = %q, want a.b", ls[0].Path)
+ }
+}
+
+func TestYAMLPaths(t *testing.T) {
+ ls := lines(sampleYAML)
+ annotateStructure(ls, []byte(sampleYAML), "yaml")
+
+ want := map[int]string{
+ 1: "defaults",
+ 2: "defaults.adapter",
+ 3: "defaults.pool",
+ 5: "development",
+ 6: "development", // merge key keeps the parent path
+ 7: "development.database",
+ 9: "test",
+ 10: "test",
+ 11: "test.database",
+ 12: "test.hosts",
+ 13: "test.hosts[0]",
+ 14: "test.hosts[1]",
+ }
+ for _, l := range ls {
+ if got, ok := want[l.N]; ok {
+ if l.Path != got {
+ t.Errorf("line %d Path = %q, want %q", l.N, l.Path, got)
+ }
+ } else if l.Path != "" {
+ t.Errorf("line %d Path = %q, want none", l.N, l.Path)
+ }
+ }
+}
+
+func TestYAMLSequenceOfMappingsPaths(t *testing.T) {
+ const src = `obj1:
+ property3:
+ items:
+ - val: 0
+ - val: 1
+`
+ ls := lines(src)
+ annotateStructure(ls, []byte(src), "yaml")
+ if ls[3].Path != "obj1.property3.items[0].val" {
+ t.Errorf("line 4 Path = %q", ls[3].Path)
+ }
+ if ls[4].Path != "obj1.property3.items[1].val" {
+ t.Errorf("line 5 Path = %q", ls[4].Path)
+ }
+}
+
+func TestPathKeyQuoting(t *testing.T) {
+ tests := []struct{ parent, key, want string }{
+ {"", "obj1", "obj1"},
+ {"obj1", "items", "obj1.items"},
+ {"obj1", "a.b", `obj1["a.b"]`},
+ {"", "weird key", `["weird key"]`},
+ {"obj1", `quo"te`, `obj1["quo\"te"]`},
+ }
+ for _, tc := range tests {
+ if got := joinPath(tc.parent, tc.key); got != tc.want {
+ t.Errorf("joinPath(%q, %q) = %q, want %q", tc.parent, tc.key, got, tc.want)
+ }
+ }
+ if got := joinIndex("obj1.items", 4); got != "obj1.items[4]" {
+ t.Errorf("joinIndex = %q", got)
+ }
+}
diff --git a/internal/notices/admin.html b/internal/notices/admin.html
new file mode 100644
index 0000000..e67b63b
--- /dev/null
+++ b/internal/notices/admin.html
@@ -0,0 +1,64 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Notices &middot; {{ .Site }}</title>
+<link rel="stylesheet" href="/static/css/base.css">
+<link rel="stylesheet" href="/static/css/admin.css">
+<script src="/static/js/admin.js" defer></script>
+</head>
+<body>
+<main class="wrap">
+ <header class="hero">
+ <h1 class="page-title">Notices</h1>
+ <p class="page-subtitle">Global notices render above every repository's.</p>
+ </header>
+
+ {{ if .Saved }}<div class="notice notice-fix">Saved.</div>{{ end }}
+ {{ if .Error }}<div class="notice notice-critical">
+ <strong class="notice-title">Not saved</strong>
+ <pre class="notice-body">{{ .Error }}</pre>
+ </div>{{ end }}
+
+ <form method="post" action="/admin/notices" class="admin-form">
+ <input type="hidden" name="csrf" value="{{ .CSRF }}">
+ <div class="table-wrap">
+ <table class="table admin-table">
+ <thead>
+ <tr>
+ <th>Scope</th><th>Type</th><th>Title</th><th>Body</th>
+ <th>Link</th><th>Link text</th><th>Remove</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{ range $i, $row := .Rows }}
+ <tr>
+ <td><select name="scope">
+ <option value="global"{{ if eq $row.Scope "global" }} selected{{ end }}>global</option>
+ {{ range $.Repos }}<option value="{{ . }}"{{ if eq $row.Scope . }} selected{{ end }}>{{ . }}</option>{{ end }}
+ </select></td>
+ <td><select name="type">
+ <option value="critical"{{ if eq $row.Type "critical" }} selected{{ end }}>critical</option>
+ <option value="warning"{{ if eq $row.Type "warning" }} selected{{ end }}>warning</option>
+ <option value="info"{{ if eq $row.Type "info" }} selected{{ end }}>info</option>
+ <option value="fix"{{ if eq $row.Type "fix" }} selected{{ end }}>fix</option>
+ </select></td>
+ <td><input name="title" value="{{ $row.Title }}"></td>
+ <td><textarea name="body" rows="2">{{ $row.Body }}</textarea></td>
+ <td><input name="link" value="{{ $row.Link }}"></td>
+ <td><input name="link_text" value="{{ $row.LinkText }}"></td>
+ <td class="admin-del"><input type="checkbox" name="del" value="{{ $i }}"></td>
+ </tr>
+ {{ end }}
+ </tbody>
+ </table>
+ </div>
+ <p class="admin-actions">
+ <button class="chip admin-save" type="submit">Save notices</button>
+ <button class="chip js-only" type="button" id="add-notice">Add notice</button>
+ </p>
+ </form>
+</main>
+</body>
+</html>
diff --git a/internal/notices/handler.go b/internal/notices/handler.go
new file mode 100644
index 0000000..5be09e3
--- /dev/null
+++ b/internal/notices/handler.go
@@ -0,0 +1,264 @@
+package notices
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "embed"
+ "encoding/hex"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+//go:embed admin.html
+var adminFS embed.FS
+
+// HandlerConfig is everything the admin surface needs that this package
+// cannot discover for itself. Repos is the visible repository list, used to
+// populate the scope dropdown.
+type HandlerConfig struct {
+ Store *Store
+ Repos []string
+ SiteName string
+ UserHeader string // e.g. "X-Authentik-Username"; logging only, never authz
+ Logger *slog.Logger
+}
+
+type adminRow struct {
+ Scope, Type, Title, Body, Link, LinkText string
+}
+
+type adminView struct {
+ Site, CSRF, Error string
+ Saved bool
+ Repos []string
+ Rows []adminRow
+}
+
+const (
+ csrfCookie = "gsg_admin_csrf"
+ maxFormBytes = 1 << 20
+)
+
+type handler struct {
+ cfg HandlerConfig
+ tmpl *template.Template
+}
+
+// NewHandler builds the admin mux. It is deliberately separate from the
+// read-only server: the only write path in gsg lives here, and it writes a
+// config file, never a repository.
+func NewHandler(cfg HandlerConfig) (http.Handler, error) {
+ if cfg.Store == nil {
+ return nil, fmt.Errorf("notices admin: no store")
+ }
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ if cfg.UserHeader == "" {
+ cfg.UserHeader = "X-Authentik-Username"
+ }
+ tmpl, err := template.ParseFS(adminFS, "admin.html")
+ if err != nil {
+ return nil, fmt.Errorf("notices admin template: %w", err)
+ }
+ h := &handler{cfg: cfg, tmpl: tmpl}
+
+ mux := http.NewServeMux()
+ // Method-qualified patterns: anything else (PUT, DELETE, or a stray
+ // GET/POST to another path) is refused by the mux, not silently handled.
+ mux.HandleFunc("GET /admin/{$}", h.index)
+ mux.HandleFunc("GET /admin/notices", h.get)
+ mux.HandleFunc("POST /admin/notices", h.post)
+ return mux, nil
+}
+
+func (h *handler) index(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/admin/notices", http.StatusSeeOther)
+}
+
+func (h *handler) get(w http.ResponseWriter, r *http.Request) {
+ h.secure(w)
+ tok, err := ensureCSRF(w, r)
+ if err != nil {
+ http.Error(w, "cannot start session", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, tok, "", r.URL.Query().Get("saved") == "1")
+}
+
+func (h *handler) post(w http.ResponseWriter, r *http.Request) {
+ h.secure(w)
+
+ // Header checks first: they need no body. Then bound and parse the
+ // body, then compare the CSRF token against the cookie. Parsing before
+ // the token check is safe because the read is capped.
+ if !sameOrigin(r) {
+ h.cfg.Logger.Warn("notices admin: origin rejected",
+ "host", r.Host, "origin", r.Header.Get("Origin"),
+ "sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ if !checkCSRF(r) {
+ h.cfg.Logger.Warn("notices admin: csrf rejected",
+ "user", r.Header.Get(h.cfg.UserHeader))
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+
+ doc, err := docFromForm(r)
+ if err == nil {
+ err = h.cfg.Store.Set(doc)
+ }
+ if err != nil {
+ tok, _ := ensureCSRF(w, r)
+ h.render(w, http.StatusBadRequest, tok, err.Error(), false)
+ return
+ }
+
+ h.cfg.Logger.Info("notices updated",
+ "user", r.Header.Get(h.cfg.UserHeader),
+ "global", len(doc.Global), "repos", len(doc.Repos))
+ http.Redirect(w, r, "/admin/notices?saved=1", http.StatusSeeOther)
+}
+
+// secure sets the admin response headers. style-src 'self' only: the page
+// ships no inline script or style, so the CSP can stay tight.
+func (h *handler) secure(w http.ResponseWriter) {
+ hdr := w.Header()
+ hdr.Set("X-Content-Type-Options", "nosniff")
+ hdr.Set("X-Frame-Options", "DENY")
+ hdr.Set("Content-Security-Policy",
+ "default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'")
+}
+
+func (h *handler) render(w http.ResponseWriter, status int, csrf, errMsg string, saved bool) {
+ snap := h.cfg.Store.Snapshot()
+ rows := make([]adminRow, 0, len(snap.Doc.Global)+2)
+ for _, n := range snap.Doc.Global {
+ rows = append(rows, adminRow{"global", n.Type, n.Title, n.Body, n.Link, n.LinkText})
+ }
+ slugs := make([]string, 0, len(snap.Doc.Repos))
+ for slug := range snap.Doc.Repos {
+ slugs = append(slugs, slug)
+ }
+ sort.Strings(slugs)
+ for _, slug := range slugs {
+ for _, n := range snap.Doc.Repos[slug] {
+ rows = append(rows, adminRow{slug, n.Type, n.Title, n.Body, n.Link, n.LinkText})
+ }
+ }
+ // One blank row is the no-JS "add" affordance: fill it and save. The
+ // admin.js helper clones it for the Add button.
+ rows = append(rows, adminRow{"global", TypeInfo, "", "", "", ""})
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := h.tmpl.Execute(w, adminView{
+ Site: h.cfg.SiteName, CSRF: csrf, Error: errMsg, Saved: saved,
+ Repos: h.cfg.Repos, Rows: rows,
+ }); err != nil {
+ h.cfg.Logger.Error("notices admin render", "err", err)
+ }
+}
+
+// docFromForm rebuilds the document from the rendered row list. Rows are
+// parallel slices (scope[], type[], ...); deletion is a sparse del[] list
+// of row indices. A row with every field blank is treated as unused -- so
+// clearing a row removes it. PostForm, not Form, so a query string cannot
+// inject or override fields.
+func docFromForm(r *http.Request) (Document, error) {
+ form := r.PostForm
+ at := func(name string, i int) string {
+ if v := form[name]; i < len(v) {
+ return v[i]
+ }
+ return ""
+ }
+ del := map[int]bool{}
+ for _, s := range form["del"] {
+ if i, err := strconv.Atoi(s); err == nil {
+ del[i] = true
+ }
+ }
+ n := len(form["scope"])
+ doc := Document{Repos: map[string][]Notice{}}
+ for i := 0; i < n; i++ {
+ if del[i] {
+ continue
+ }
+ nt := Notice{
+ Type: at("type", i), Title: at("title", i), Body: at("body", i),
+ Link: at("link", i), LinkText: at("link_text", i),
+ }
+ if nt.Type == "" && nt.Title == "" && nt.Body == "" && nt.Link == "" {
+ continue
+ }
+ scope := at("scope", i)
+ if scope == "" || scope == "global" {
+ doc.Global = append(doc.Global, nt)
+ } else {
+ doc.Repos[scope] = append(doc.Repos[scope], nt)
+ }
+ }
+ return doc, nil // Store.Set validates
+}
+
+func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) {
+ if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 {
+ return c.Value, nil
+ }
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ tok := hex.EncodeToString(b)
+ http.SetCookie(w, &http.Cookie{
+ Name: csrfCookie, Value: tok, Path: "/admin",
+ HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
+ MaxAge: 86400,
+ })
+ return tok, nil
+}
+
+func checkCSRF(r *http.Request) bool {
+ c, err := r.Cookie(csrfCookie)
+ if err != nil {
+ return false
+ }
+ got := r.PostForm.Get("csrf")
+ return subtle.ConstantTimeCompare([]byte(c.Value), []byte(got)) == 1
+}
+
+func isHTTPS(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+// sameOrigin blocks a cross-site POST even if the auth cookie were sent.
+// Forward auth proves the browser holds a session; it does not prove the
+// request came from our own page.
+func sameOrigin(r *http.Request) bool {
+ switch r.Header.Get("Sec-Fetch-Site") {
+ case "", "same-origin", "none":
+ default:
+ return false
+ }
+ if o := r.Header.Get("Origin"); o != "" {
+ u, err := url.Parse(o)
+ if err != nil || !strings.EqualFold(u.Host, r.Host) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/notices/notices.go b/internal/notices/notices.go
new file mode 100644
index 0000000..c855f67
--- /dev/null
+++ b/internal/notices/notices.go
@@ -0,0 +1,221 @@
+// Package notices holds operator-authored banners shown at the top of every
+// page: a site-wide list plus a list per repository. Content lives in one
+// YAML file that is watched for changes, so editing it never needs a
+// rebuild, and the optional admin listener can rewrite it.
+//
+// This package deliberately does not import internal/core: it is plain
+// config, and keeping it dependency-free lets both the module and the admin
+// handler reach it without an import cycle.
+package notices
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Severities. The value doubles as the CSS class suffix, so it is validated
+// on every load and save; a typo must not become an unstyled banner.
+const (
+ TypeCritical = "critical"
+ TypeWarning = "warning"
+ TypeInfo = "info"
+ TypeFix = "fix"
+)
+
+// Notice is one banner. Link and LinkText are optional; an empty Link
+// renders no anchor.
+type Notice struct {
+ Type string `yaml:"type"`
+ Title string `yaml:"title,omitempty"`
+ Body string `yaml:"body,omitempty"`
+ Link string `yaml:"link,omitempty"`
+ LinkText string `yaml:"link_text,omitempty"`
+}
+
+// Document is the on-disk shape. Global renders above every repo's list.
+type Document struct {
+ Global []Notice `yaml:"global,omitempty"`
+ Repos map[string][]Notice `yaml:"repos,omitempty"`
+}
+
+// Snapshot is an immutable document plus the content hash identifying it.
+// They are carried together so a reader can never see data and revision
+// disagree.
+type Snapshot struct {
+ Doc Document
+ Rev string
+}
+
+const (
+ maxNotices = 200
+ maxPerRepo = 50
+ maxTitle = 200
+ maxBody = 4000
+ maxLink = 2048
+ maxLinkText = 120
+)
+
+// typeAliases make hand-editing forgiving without letting an invalid type
+// reach the template.
+var typeAliases = map[string]string{
+ "critical": TypeCritical,
+ "warning": TypeWarning,
+ "warn": TypeWarning,
+ "info": TypeInfo,
+ "informational": TypeInfo,
+ "information": TypeInfo,
+ "fix": TypeFix,
+ "fixed": TypeFix,
+}
+
+func canonicalType(s string) (string, bool) {
+ t, ok := typeAliases[strings.ToLower(strings.TrimSpace(s))]
+ return t, ok
+}
+
+// validSlug mirrors a repository directory name: one path element, no
+// separators. It keeps the repos map keys from becoming junk.
+func validSlug(s string) bool {
+ if s == "" || s == "." || s == ".." {
+ return false
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
+ r == '.', r == '_', r == '-':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// checkLink allows a site-relative path and the http/https/mailto schemes.
+// html/template would neutralise a javascript: URL anyway, but rejecting it
+// here turns a silent #ZgotmplZ into a visible form error.
+func checkLink(raw string) error {
+ if strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\x00\r\n") {
+ return fmt.Errorf("link %q is not allowed", raw)
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("link %q: %w", raw, err)
+ }
+ if !u.IsAbs() {
+ return nil // relative path
+ }
+ switch strings.ToLower(u.Scheme) {
+ case "http", "https", "mailto":
+ return nil
+ default:
+ return fmt.Errorf("link %q: scheme %q is not allowed", raw, u.Scheme)
+ }
+}
+
+// validate trims and canonicalises in place and returns every problem it
+// found, so the admin form can show all of them at once.
+func validate(doc *Document) error {
+ var errs []error
+ total := 0
+ check := func(list []Notice, where string) []Notice {
+ for i := range list {
+ n := &list[i]
+ total++
+ if t, ok := canonicalType(n.Type); ok {
+ n.Type = t
+ } else {
+ errs = append(errs, fmt.Errorf("%s[%d]: type %q must be one of critical, warning, info, fix", where, i, n.Type))
+ }
+ n.Title = strings.TrimSpace(n.Title)
+ n.Body = strings.TrimRight(n.Body, "\n")
+ n.Link = strings.TrimSpace(n.Link)
+ n.LinkText = strings.TrimSpace(n.LinkText)
+ if n.Title == "" && n.Body == "" {
+ errs = append(errs, fmt.Errorf("%s[%d]: needs a title or a body", where, i))
+ }
+ switch {
+ case utf8.RuneCountInString(n.Title) > maxTitle:
+ errs = append(errs, fmt.Errorf("%s[%d]: title longer than %d characters", where, i, maxTitle))
+ case utf8.RuneCountInString(n.Body) > maxBody:
+ errs = append(errs, fmt.Errorf("%s[%d]: body longer than %d characters", where, i, maxBody))
+ case utf8.RuneCountInString(n.LinkText) > maxLinkText:
+ errs = append(errs, fmt.Errorf("%s[%d]: link text longer than %d characters", where, i, maxLinkText))
+ }
+ if len(n.Link) > maxLink {
+ errs = append(errs, fmt.Errorf("%s[%d]: link longer than %d bytes", where, i, maxLink))
+ }
+ if n.Link != "" {
+ if err := checkLink(n.Link); err != nil {
+ errs = append(errs, fmt.Errorf("%s[%d]: %w", where, i, err))
+ }
+ }
+ }
+ return list
+ }
+
+ doc.Global = check(doc.Global, "global")
+ slugs := make([]string, 0, len(doc.Repos))
+ for slug := range doc.Repos {
+ slugs = append(slugs, slug)
+ }
+ sort.Strings(slugs)
+ for _, slug := range slugs {
+ if !validSlug(slug) {
+ errs = append(errs, fmt.Errorf("repos: %q is not a valid repository slug", slug))
+ continue
+ }
+ if len(doc.Repos[slug]) > maxPerRepo {
+ errs = append(errs, fmt.Errorf("repos.%s: more than %d notices", slug, maxPerRepo))
+ }
+ doc.Repos[slug] = check(doc.Repos[slug], "repos."+slug)
+ }
+ if total > maxNotices {
+ errs = append(errs, fmt.Errorf("more than %d notices in total", maxNotices))
+ }
+ return errors.Join(errs...)
+}
+
+// parse decodes strictly: an unknown key is a typo the operator should see,
+// not something silently ignored. An empty file is an empty document.
+func parse(b []byte) (Document, error) {
+ var doc Document
+ dec := yaml.NewDecoder(bytes.NewReader(b))
+ dec.KnownFields(true)
+ if err := dec.Decode(&doc); err != nil {
+ if errors.Is(err, io.EOF) {
+ return Document{Repos: map[string][]Notice{}}, nil
+ }
+ return Document{}, fmt.Errorf("notices: %w", err)
+ }
+ if err := validate(&doc); err != nil {
+ return Document{}, err
+ }
+ if doc.Repos == nil {
+ doc.Repos = map[string][]Notice{}
+ }
+ return doc, nil
+}
+
+// marshal assumes the document has already been through validate, which is
+// what canonicalises severity aliases and trims fields. Both callers (parse
+// and Store.Set) validate first.
+func marshal(doc Document) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ if err := enc.Encode(doc); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/notices/store.go b/internal/notices/store.go
new file mode 100644
index 0000000..bfb04bc
--- /dev/null
+++ b/internal/notices/store.go
@@ -0,0 +1,230 @@
+package notices
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+)
+
+// Store owns one notices file. Reads are lock-free (an atomic snapshot);
+// writes and the watcher's reloads serialise on mu so a web save and an
+// external edit can never interleave.
+type Store struct {
+ path string
+ dir string
+
+ mu sync.Mutex
+ cur atomic.Pointer[Snapshot]
+
+ fsw *fsnotify.Watcher
+ done chan struct{}
+ closeOnce sync.Once
+}
+
+// Open loads the file (an absent file is an empty document) and starts
+// watching its directory. A parse error is fatal at startup so a typo is
+// visible immediately; later reload errors keep the last good snapshot.
+func Open(path string) (*Store, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ dir := filepath.Dir(abs)
+ if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
+ return nil, fmt.Errorf("notices: directory %s does not exist", dir)
+ }
+
+ s := &Store{path: abs, dir: dir, done: make(chan struct{})}
+ snap, err := readSnapshot(abs)
+ if err != nil {
+ return nil, err
+ }
+ s.cur.Store(snap)
+
+ fsw, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, fmt.Errorf("notices watch: %w", err)
+ }
+ if err := fsw.Add(dir); err != nil {
+ fsw.Close()
+ return nil, fmt.Errorf("notices watch: %w", err)
+ }
+ s.fsw = fsw
+ go s.watch()
+ return s, nil
+}
+
+// readSnapshot reads and hashes the file. A missing file is an empty
+// document, not an error -- the admin UI creates it on first save.
+func readSnapshot(path string) (*Snapshot, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ b = nil
+ } else {
+ return nil, fmt.Errorf("notices: %w", err)
+ }
+ }
+ doc, err := parse(b)
+ if err != nil {
+ return nil, err
+ }
+ return &Snapshot{Doc: doc, Rev: hash(b)}, nil
+}
+
+func hash(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])[:16]
+}
+
+// Snapshot is the current document. The returned value shares its backing
+// slices and map with the stored one; callers must not mutate it.
+func (s *Store) Snapshot() Snapshot {
+ p := s.cur.Load()
+ if p == nil {
+ return Snapshot{}
+ }
+ return *p
+}
+
+// Revision is the content hash. O(1); safe to call on every request.
+func (s *Store) Revision() string {
+ p := s.cur.Load()
+ if p == nil {
+ return ""
+ }
+ return p.Rev
+}
+
+// Set validates, writes atomically (temp file in the same directory, fsync,
+// rename, 0600), and publishes the new snapshot immediately, so the next
+// request sees it without waiting for the watcher.
+func (s *Store) Set(doc Document) error {
+ if err := validate(&doc); err != nil {
+ return err
+ }
+ b, err := marshal(doc)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ tmp, err := os.CreateTemp(s.dir, ".notices-*.tmp")
+ if err != nil {
+ return fmt.Errorf("notices: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName) // no-op once the rename succeeds
+ if err := tmp.Chmod(0o600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(b); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmpName, s.path); err != nil {
+ return err
+ }
+
+ s.cur.Store(&Snapshot{Doc: doc, Rev: hash(b)})
+ return nil
+}
+
+// watch is the only goroutine that touches the debounce timer, so no lock
+// is needed for it. Events for our own ".notices-*.tmp" files are ignored;
+// only the real path triggers a reload.
+func (s *Store) watch() {
+ const debounce = 200 * time.Millisecond
+ var timer *time.Timer
+ var timerC <-chan time.Time
+
+ for {
+ select {
+ case ev, ok := <-s.fsw.Events:
+ if !ok {
+ return
+ }
+ if filepath.Clean(ev.Name) != s.path {
+ continue
+ }
+ if timer == nil {
+ timer = time.NewTimer(debounce)
+ } else {
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(debounce)
+ }
+ timerC = timer.C
+ case <-timerC:
+ timerC = nil
+ s.reload()
+ case err, ok := <-s.fsw.Errors:
+ if !ok {
+ return
+ }
+ slog.Warn("notices watch error", "err", err)
+ case <-s.done:
+ if timer != nil {
+ timer.Stop()
+ }
+ return
+ }
+ }
+}
+
+// reload re-reads the file and swaps the snapshot only when the content
+// hash moved. A parse error keeps the previous snapshot, so a half-written
+// or mistyped file never blanks the banners.
+func (s *Store) reload() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ snap, err := readSnapshot(s.path)
+ if err != nil {
+ slog.Warn("notices: keeping previous snapshot", "err", err)
+ return
+ }
+ if cur := s.cur.Load(); cur != nil && cur.Rev == snap.Rev {
+ return
+ }
+ s.cur.Store(snap)
+ slog.Info("notices reloaded", "revision", snap.Rev)
+}
+
+func (s *Store) Close() {
+ s.closeOnce.Do(func() {
+ close(s.done)
+ if s.fsw != nil {
+ s.fsw.Close()
+ }
+ })
+}
+
+// def is the process-wide store the `notices` module reads. A nil default
+// means the feature is off; the module returns an empty list, not an error.
+var def atomic.Pointer[Store]
+
+func SetDefault(s *Store) { def.Store(s) }
+func Default() *Store { return def.Load() }
diff --git a/internal/notices/store_test.go b/internal/notices/store_test.go
new file mode 100644
index 0000000..48ed6b7
--- /dev/null
+++ b/internal/notices/store_test.go
@@ -0,0 +1,150 @@
+package notices
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestParseRejectsUnknownField(t *testing.T) {
+ _, err := parse([]byte("global:\n - type: info\n subject: hi\n"))
+ if err == nil {
+ t.Fatal("unknown field accepted")
+ }
+ if !strings.Contains(err.Error(), "subject") {
+ t.Fatalf("error does not name the field: %v", err)
+ }
+}
+
+func TestParseRejectsBadType(t *testing.T) {
+ _, err := parse([]byte("global:\n - type: critcal\n title: hi\n"))
+ if err == nil || !strings.Contains(err.Error(), "critcal") {
+ t.Fatalf("bad type: err = %v", err)
+ }
+}
+
+func TestParseEmpty(t *testing.T) {
+ for _, in := range [][]byte{nil, []byte(""), []byte("{}\n")} {
+ doc, err := parse(in)
+ if err != nil {
+ t.Fatalf("parse(%q): %v", in, err)
+ }
+ if len(doc.Global) != 0 || len(doc.Repos) != 0 {
+ t.Fatalf("parse(%q) = %+v, want empty", in, doc)
+ }
+ if doc.Repos == nil {
+ t.Fatalf("parse(%q): Repos is nil", in)
+ }
+ }
+}
+
+func TestTypeAliasesAndTrimming(t *testing.T) {
+ doc, err := parse([]byte("global:\n - type: informational\n title: ' spaced '\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := doc.Global[0]
+ if n.Type != TypeInfo || n.Title != "spaced" {
+ t.Fatalf("got %+v", n)
+ }
+}
+
+func TestLinkSchemes(t *testing.T) {
+ cases := []struct {
+ link string
+ ok bool
+ }{
+ {"/status", true},
+ {"https://example.org/a", true},
+ {"mailto:ops@example.org", true},
+ {"javascript:alert(1)", false},
+ {"//evil.example", false},
+ {"ftp://example.org", false},
+ }
+ for _, tc := range cases {
+ doc := Document{Global: []Notice{{Type: TypeInfo, Title: "x", Link: tc.link}}}
+ err := validate(&doc)
+ if tc.ok && err != nil {
+ t.Errorf("link %q rejected: %v", tc.link, err)
+ }
+ if !tc.ok && err == nil {
+ t.Errorf("link %q accepted", tc.link)
+ }
+ }
+}
+
+func TestSetRoundTripAndRevision(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "notices.yaml")
+ st, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+
+ if st.Revision() == "" {
+ t.Fatal("empty store has no revision")
+ }
+ doc := Document{
+ Global: []Notice{{Type: TypeCritical, Title: "Outage", Body: "line\nline"}},
+ Repos: map[string][]Notice{"demo": {{Type: "informational", Title: "Repo"}}},
+ }
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ // informational must have been canonicalised in the published snapshot.
+ if got := st.Snapshot().Doc.Repos["demo"][0].Type; got != TypeInfo {
+ t.Fatalf("published type = %q, want %q", got, TypeInfo)
+ }
+ rev := st.Revision()
+
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if perm := fi.Mode().Perm(); perm != 0o600 {
+ t.Errorf("file mode = %o, want 600", perm)
+ }
+
+ again, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(again.Close)
+ if !reflect.DeepEqual(st.Snapshot().Doc, again.Snapshot().Doc) {
+ t.Fatalf("round trip mismatch:\n%+v\n%+v", st.Snapshot().Doc, again.Snapshot().Doc)
+ }
+
+ // A no-op save is byte-identical and keeps the revision.
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ if st.Revision() != rev {
+ t.Fatalf("no-op save changed revision %q -> %q", rev, st.Revision())
+ }
+
+ // A content change moves it.
+ doc.Global[0].Body = "different"
+ if err := st.Set(doc); err != nil {
+ t.Fatal(err)
+ }
+ if st.Revision() == rev {
+ t.Fatal("content change kept the same revision")
+ }
+}
+
+func TestOpenMissingFileAndBadDir(t *testing.T) {
+ dir := t.TempDir()
+ st, err := Open(filepath.Join(dir, "notices.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(st.Close)
+ if len(st.Snapshot().Doc.Global) != 0 {
+ t.Fatal("missing file is not empty")
+ }
+ if _, err := Open(filepath.Join(dir, "nope", "notices.yaml")); err == nil {
+ t.Fatal("missing directory accepted")
+ }
+}
diff --git a/internal/site/routes.go b/internal/site/routes.go
index 6d31993..ee96fb2 100644
--- a/internal/site/routes.go
+++ b/internal/site/routes.go
@@ -48,13 +48,15 @@ func Routes() []core.Route {
Pattern: "GET /{repo}/raw/{rest...}",
Render: core.Raw{Module: "blobraw"}},
- // The DAG across every ref.
+ // The DAG across every ref. {rest...} carries only a ref (never a
+ // path, there's nothing to browse into) -- it exists purely so the
+ // selected branch survives navigating here and back out again.
{Name: "graph", NavLabel: "graph",
- Pattern: "GET /{repo}/graph/{$}",
+ Pattern: "GET /{repo}/graph/{rest...}",
Render: core.View{Template: "graph.html"}},
{Name: "refs", NavLabel: "refs",
- Pattern: "GET /{repo}/refs/{$}",
+ Pattern: "GET /{repo}/refs/{rest...}",
Render: core.View{Template: "refs.html"}},
// Arbitrary two-commit diff: /demo/compare/main...feature
diff --git a/main.go b/main.go
index 2faa1d3..e6c1499 100644
--- a/main.go
+++ b/main.go
@@ -2,6 +2,7 @@
//
// gsg -root /srv/git -addr :8080 -name "rschanz.org"
// gsg -root /srv/git -templates ./templates # hot-reload the theme
+// gsg -root /srv/git -notices /etc/gsg/notices.yaml -admin-addr 127.0.0.1:8081
//
// Per-repository settings live in each repo's own git config, cgit-style:
//
@@ -14,15 +15,18 @@ package main
import (
"embed"
"flag"
+ "fmt"
"io/fs"
"log"
"log/slog"
+ "net"
"net/http"
"os"
"time"
"gsg/internal/core"
_ "gsg/internal/modules" // registers the built-in modules
+ "gsg/internal/notices"
"gsg/internal/site"
)
@@ -32,25 +36,53 @@ var templateFS embed.FS
//go:embed static
var staticFS embed.FS
+type options struct {
+ root, addr, name, base, tmplDir string
+ noticesPath, adminAddr string
+ adminAllowRemote bool
+ cacheSize int
+ timeout time.Duration
+}
+
func main() {
- var (
- root = flag.String("root", "/srv/git", "directory containing bare repositories")
- addr = flag.String("addr", ":8080", "listen address")
- name = flag.String("name", "git", "site name")
- base = flag.String("base", "", "base URL")
- tmplDir = flag.String("templates", "", "serve templates from this directory, re-parsed per request")
- cache = flag.Int("cache", 4096, "module cache entries")
- timeout = flag.Duration("timeout", 10*time.Second, "per-request git budget")
- )
+ var o options
+ flag.StringVar(&o.root, "root", "/srv/git", "directory containing bare repositories")
+ flag.StringVar(&o.addr, "addr", ":8080", "listen address")
+ flag.StringVar(&o.name, "name", "git", "site name")
+ flag.StringVar(&o.base, "base", "", "base URL")
+ flag.StringVar(&o.tmplDir, "templates", "", "serve templates from this directory, re-parsed per request")
+ flag.StringVar(&o.noticesPath, "notices", "", "YAML file of site and per-repo notices (empty disables the feature)")
+ flag.StringVar(&o.adminAddr, "admin-addr", "", "listen address for the notices admin UI (empty disables it; use loopback unless behind a container network)")
+ flag.BoolVar(&o.adminAllowRemote, "admin-allow-remote", false, "permit -admin-addr to bind a non-loopback address (required in containers)")
+ flag.IntVar(&o.cacheSize, "cache", 4096, "module cache entries")
+ flag.DurationVar(&o.timeout, "timeout", 10*time.Second, "per-request git budget")
flag.Parse()
- if err := run(*root, *addr, *name, *base, *tmplDir, *cache, *timeout); err != nil {
+ if err := run(o); err != nil {
log.Fatalf("gsg: %v", err)
}
}
-func run(root, addr, name, base, tmplDir string, cacheSize int, timeout time.Duration) error {
- siteCfg := &core.Site{Name: name, BaseURL: base, Root: root}
+func run(o options) error {
+ siteCfg := &core.Site{Name: o.name, BaseURL: o.base, Root: o.root}
+
+ var nstore *notices.Store
+ if o.noticesPath != "" {
+ var err error
+ nstore, err = notices.Open(o.noticesPath)
+ if err != nil {
+ return err
+ }
+ defer nstore.Close()
+ notices.SetDefault(nstore)
+ // The site-level analogue of RepoInfo.GPGKeyring: notices can
+ // change without a commit moving, so the revision has to be in
+ // every module cache key and ETag (see Ctx.contentIdentity).
+ siteCfg.RevFn = nstore.Revision
+ }
+ if o.adminAddr != "" && nstore == nil {
+ return fmt.Errorf("-admin-addr requires -notices")
+ }
if err := core.Start(siteCfg); err != nil {
return err
@@ -58,18 +90,18 @@ func run(root, addr, name, base, tmplDir string, cacheSize int, timeout time.Dur
var tfs fs.FS
var err error
- if tmplDir != "" {
- tfs = os.DirFS(tmplDir)
+ if o.tmplDir != "" {
+ tfs = os.DirFS(o.tmplDir)
} else if tfs, err = fs.Sub(templateFS, "templates"); err != nil {
return err
}
- srv, err := core.NewServer(siteCfg, site.Routes(), tfs, cacheSize)
+ srv, err := core.NewServer(siteCfg, site.Routes(), tfs, o.cacheSize)
if err != nil {
return err
}
- srv.TemplateDir = tmplDir
- srv.Timeout = timeout
+ srv.TemplateDir = o.tmplDir
+ srv.Timeout = o.timeout
static, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -80,15 +112,76 @@ func run(root, addr, name, base, tmplDir string, cacheSize int, timeout time.Dur
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
mux.Handle("/", srv.Handler())
+ servers := []*http.Server{{
+ Addr: o.addr,
+ Handler: mux,
+ ReadHeaderTimeout: 5 * time.Second,
+ WriteTimeout: o.timeout + 5*time.Second,
+ }}
+
+ if o.adminAddr != "" {
+ // The public mux stays strictly read-only. The admin routes live on
+ // their own listener so a misconfigured Caddy forward_auth cannot
+ // expose a write path on the public port.
+ if !o.adminAllowRemote && !isLoopbackAddr(o.adminAddr) {
+ return fmt.Errorf("-admin-addr %q is not loopback; pass -admin-allow-remote to allow it", o.adminAddr)
+ }
+ repos := make([]string, 0)
+ for _, h := range siteCfg.RepoList() {
+ if !h.Info.Hidden {
+ repos = append(repos, h.Info.Slug)
+ }
+ }
+ adminHandler, err := notices.NewHandler(notices.HandlerConfig{
+ Store: nstore,
+ Repos: repos,
+ SiteName: o.name,
+ UserHeader: "X-Authentik-Username",
+ Logger: slog.Default(),
+ })
+ if err != nil {
+ return err
+ }
+ amux := http.NewServeMux()
+ // Serve /static/ on the admin listener too. Behind Caddy the admin page
+ // is same-origin and its /static/ requests go to the public listener,
+ // but hitting the admin port directly (dev, curl) would otherwise 404
+ // every stylesheet. Static assets are public either way.
+ amux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
+ amux.Handle("/admin/", adminHandler)
+ servers = append(servers, &http.Server{
+ Addr: o.adminAddr,
+ Handler: amux,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ })
+ slog.Info("gsg admin listening", "addr", o.adminAddr)
+ }
+
slog.Info("gsg listening",
- "addr", addr, "repos", len(siteCfg.RepoList()),
+ "addr", o.addr, "repos", len(siteCfg.RepoList()),
"routes", len(site.Routes()), "modules", len(core.Names()))
- hs := &http.Server{
- Addr: addr,
- Handler: mux,
- ReadHeaderTimeout: 5 * time.Second,
- WriteTimeout: timeout + 5*time.Second,
+ errc := make(chan error, len(servers))
+ for _, s := range servers {
+ s := s
+ go func() { errc <- s.ListenAndServe() }()
+ }
+ return <-errc
+}
+
+// isLoopbackAddr is a blunt guard, not a security boundary: it stops the
+// admin listener from silently binding 0.0.0.0 outside a deliberate opt-in.
+func isLoopbackAddr(addr string) bool {
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ host = addr
+ }
+ switch host {
+ case "127.0.0.1", "::1", "localhost":
+ return true
+ default:
+ return false
}
- return hs.ListenAndServe()
}
diff --git a/static/css/admin.css b/static/css/admin.css
new file mode 100644
index 0000000..2ed9feb
--- /dev/null
+++ b/static/css/admin.css
@@ -0,0 +1,60 @@
+/* Admin surface only. Reuses base.css tokens and components; nothing here
+ is loaded by the public pages. No literal colours -- everything comes
+ from the :root custom properties. */
+
+.admin-table td,
+.admin-table th { vertical-align: top; }
+
+.admin-table select,
+.admin-table input,
+.admin-table textarea {
+ font: inherit;
+ font-size: .8rem;
+ color: var(--fg);
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .25rem .45rem;
+ width: 100%;
+ min-width: 6rem;
+}
+
+.admin-table textarea { resize: vertical; }
+
+.admin-table select:focus-visible,
+.admin-table input:focus-visible,
+.admin-table textarea:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.admin-table .admin-del,
+.admin-table .admin-del input {
+ width: auto;
+ min-width: 0;
+ text-align: center;
+}
+
+.admin-save { cursor: pointer; font-size: .8rem; padding: .4rem .9rem; }
+
+/* Progressive enhancement: the Add button is only useful with admin.js, so
+ it stays hidden until that script adds .has-js to <body>. The checkbox is
+ always visible without JS. */
+.js-only { display: none; }
+.has-js .js-only { display: inline-flex; }
+
+.admin-actions {
+ display: flex;
+ gap: .5rem;
+ align-items: center;
+ margin-top: .75rem;
+}
+
+.row-remove { cursor: pointer; }
+
+.notice-error,
+.notice pre {
+ margin: .35rem 0 0;
+ white-space: pre-wrap;
+ font: inherit;
+}
diff --git a/static/css/base.css b/static/css/base.css
index c52f976..392b7aa 100644
--- a/static/css/base.css
+++ b/static/css/base.css
@@ -35,6 +35,17 @@
--del: #EF4444;
--del-bg: rgba(239, 68, 68, 0.15);
+ /* Operator notice severities. One token pair per type; the .notice-*
+ modifiers below are the only place the palette is applied. */
+ --notice-critical: #EF4444;
+ --notice-critical-bg: rgba(239, 68, 68, 0.15);
+ --notice-warning: #F59E0B;
+ --notice-warning-bg: rgba(245, 158, 11, 0.15);
+ --notice-info: #60A5FA;
+ --notice-info-bg: rgba(96, 165, 250, 0.15);
+ --notice-fix: #10B981;
+ --notice-fix-bg: rgba(16, 185, 129, 0.15);
+
/* Counterpart to --accent -- tags and other "second kind of thing"
markers use this instead of a shade too close to the purple accent. */
--tag: #FAA78B;
@@ -67,7 +78,7 @@
--pad: clamp(.85rem, 2.5vw, 1.5rem);
}
-@media (prefers-color-scheme: light) {
+/**@media (prefers-color-scheme: light) {
:root {
--bg: #F3F4F6;
--bg-elev: #FFFFFF;
@@ -83,12 +94,16 @@
--add: #059669;
--del: #DC2626;
--tag: #B45309;
+ --notice-critical: #DC2626;
+ --notice-warning: #B45309;
+ --notice-info: #1D4ED8;
+ --notice-fix: #059669;
--syn-kw: #4F46E5;
--syn-str: #047857;
--syn-num: #B45309;
--syn-fn: #1D4ED8;
}
-}
+} **/
/* ---------------------------------------------------------------- reset & base */
@@ -237,6 +252,11 @@ main.wrap { padding-top: 1.25rem; padding-bottom: 2rem; }
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.25;
+ /* Repository titles are often long unbroken slugs ("my-project-name").
+ Without this they push past the viewport instead of wrapping. Balance
+ avoids a single dangling word on the second line. */
+ overflow-wrap: anywhere;
+ text-wrap: balance;
}
.page-subtitle {
margin: .35rem 0 0;
@@ -350,6 +370,32 @@ main.wrap { padding-top: 1.25rem; padding-bottom: 2rem; }
.chip-ok { color: var(--add); border-color: currentColor; }
.chip-bad { color: var(--del); border-color: currentColor; }
+.compare-form {
+ display: flex;
+ align-items: center;
+ gap: .4rem;
+}
+.compare-input {
+ font: inherit; font-size: .78rem;
+ color: var(--fg);
+ background: none;
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ padding: .25rem .6rem;
+ min-width: 0;
+ flex: 1 1 20rem;
+}
+.compare-input:hover { border-color: var(--accent); }
+.compare-input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+.compare-form .chip {
+ cursor: pointer;
+ font: inherit;
+ font-size: .7rem;
+}
+
/* ---------------------------------------------------------------- footer */
footer {
@@ -384,6 +430,17 @@ footer a { color: var(--accent); }
margin: .75rem 0;
}
+/* Operator notices. The severity only changes the left rule and the
+ background tint; the base .notice keeps the shared shape. */
+.notices { margin: .75rem 0 1rem; }
+.notice-critical { border-left-color: var(--notice-critical); background: var(--notice-critical-bg); }
+.notice-warning { border-left-color: var(--notice-warning); background: var(--notice-warning-bg); }
+.notice-info { border-left-color: var(--notice-info); background: var(--notice-info-bg); }
+.notice-fix { border-left-color: var(--notice-fix); background: var(--notice-fix-bg); }
+.notice-title { display: block; color: var(--fg); }
+.notice-body { white-space: pre-wrap; }
+.notice-link { margin-left: .35rem; }
+
/* ---------------------------------------------------------------- error page */
.error-page { padding-top: 3rem; padding-bottom: 3rem; text-align: center; }
@@ -405,7 +462,17 @@ footer a { color: var(--accent); }
flex-wrap: wrap;
height: auto; padding-block: .5rem;
}
- .nav-start { flex: 1 1 auto; }
+ .nav-start {
+ flex: 1 1 auto;
+ /* Let the repo name drop below the brand rather than overflow the row. */
+ flex-wrap: wrap;
+ row-gap: .15rem;
+ }
+ .nav-repo-name {
+ min-width: 0;
+ margin-left: 0;
+ overflow-wrap: anywhere;
+ }
.nav-end { margin-left: auto; }
.tabs {
justify-self: auto;
diff --git a/static/css/blob.css b/static/css/blob.css
index 8d15f4d..f367717 100644
--- a/static/css/blob.css
+++ b/static/css/blob.css
@@ -142,3 +142,27 @@
border-color: var(--accent);
}
.sbar-btn:disabled { opacity: .45; cursor: default; }
+
+/* Image preview. The bytes come from /raw/, which serves validated raster
+ formats inline; the frame keeps a large image from widening the page and
+ the checkerboard keeps a transparent PNG legible in both themes. */
+.blob-image {
+ margin: .75rem 0;
+ padding: .75rem;
+ background: var(--surface-2);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ text-align: center;
+ overflow: auto;
+}
+.blob-image img {
+ max-width: 100%;
+ height: auto;
+ background-image:
+ linear-gradient(45deg, var(--line-soft) 25%, transparent 25%),
+ linear-gradient(-45deg, var(--line-soft) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, var(--line-soft) 75%),
+ linear-gradient(-45deg, transparent 75%, var(--line-soft) 75%);
+ background-size: 16px 16px;
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
+}
diff --git a/static/css/repo-list.css b/static/css/repo-list.css
index 9b0858f..c8b4395 100644
--- a/static/css/repo-list.css
+++ b/static/css/repo-list.css
@@ -1,14 +1,21 @@
/* Repo listing — owned by views/index.html */
+/* ---- Grid variant (.repo-row) ------------------------------------------
+ minmax(0, …) lets the fr tracks shrink below their min-content width;
+ bare `2fr` is minmax(auto, 2fr), so one long unbroken repo name would
+ hold the track open and overflow the row. */
.repo-row {
display: grid;
- grid-template-columns: 2fr 3fr auto;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto;
gap: 1rem;
align-items: center;
}
.repo-name a {
color: var(--fg);
font-weight: 600;
+ /* `anywhere` (unlike `break-word`) also lowers min-content width, which
+ is what actually lets the track shrink. */
+ overflow-wrap: anywhere;
}
.repo-name a:hover { color: var(--accent); }
.repo-owner {
@@ -25,17 +32,90 @@
.repo-desc {
color: var(--fg-muted);
font-size: .85rem;
+ overflow-wrap: anywhere;
}
.repo-meta {
color: var(--fg-subtle);
font-size: .8rem;
text-align: right;
- white-space: nowrap;
+ overflow-wrap: break-word;
}
@media (max-width: 640px) {
.repo-row {
- grid-template-columns: 1fr;
- gap: 0.5rem;
+ grid-template-columns: minmax(0, 1fr);
+ gap: .5rem;
+ }
+ .repo-meta { text-align: left; }
+}
+
+/* ---- Table variant (.table-repos) --------------------------------------
+ Each section is its own <table>. Fixed layout with shared percentages
+ keeps the columns aligned across sections. Two requirements:
+
+ - The table needs a non-auto width. Without it, browsers fall back to
+ auto layout (CSS 2.1 §17.5.2.1), which sizes columns from content
+ per table and produces the cross-section drift.
+ - The percentages must sum to 100%.
+
+ col-meta at 17% fits "2026-09-03 15:04" on one line down to roughly a
+ 900px viewport. Narrower than that, the cell wraps at the space between
+ date and time. This only works because the cell isn't `nowrap`:
+ overflow-wrap never applies where white-space forbids wrapping. */
+.table-repos {
+ table-layout: fixed;
+ width: 100%;
+}
+.table-repos .col-repo { width: 25%; }
+.table-repos .col-desc { width: 58%; }
+.table-repos .col-meta { width: 17%; }
+.table-repos td:last-child { white-space: normal; }
+
+/* Only the full header labels are shown. The narrow layout below hides the
+ header row visually, so the short labels are no longer used; delete
+ .label-short from the markup and this rule can go. */
+.table-repos .label-short { display: none; }
+
+/* Below 640px, columns are dropped and each row is stacked:
+
+ name ............ date
+ description
+
+ Once nothing sits side by side, cross-section alignment no longer
+ matters. The header is hidden visually but stays in the accessibility
+ tree. Changing `display` on table elements can remove their implicit
+ table roles in some engines, so add role="table" / "row" / "cell" in
+ the markup if screen-reader semantics matter. */
+@media (max-width: 640px) {
+ .table-repos,
+ .table-repos tbody,
+ .table-repos td {
+ display: block;
+ width: auto;
+ }
+ .table-repos thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+ .table-repos tr {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "repo meta"
+ "desc desc";
+ gap: .25rem .75rem;
+ padding: .6rem 0;
+ }
+ .table-repos td:nth-child(1) { grid-area: repo; }
+ .table-repos td:nth-child(2) { grid-area: desc; }
+ .table-repos td:nth-child(3) {
+ grid-area: meta;
+ text-align: right;
+ /* Safe to keep on one line here: the `auto` track sizes to fit it. */
+ white-space: nowrap;
}
}
diff --git a/static/js/admin.js b/static/js/admin.js
new file mode 100644
index 0000000..41bf282
--- /dev/null
+++ b/static/js/admin.js
@@ -0,0 +1,60 @@
+// Progressive enhancement for the notices admin form.
+//
+// Without JavaScript the server-rendered checkbox removes a row and the
+// single blank row is how you add one. This script only makes those two
+// actions interactive: it turns the checkbox into a Remove button and
+// clones the blank row for an Add button. It never changes what the form
+// submits, so a JS-on submit and a JS-off submit build the same document.
+(function () {
+ 'use strict';
+
+ var form = document.querySelector('form.admin-form');
+ if (!form) return;
+ document.body.classList.add('has-js');
+
+ var tbody = form.querySelector('tbody');
+ if (!tbody) return;
+
+ // Capture the server-rendered blank "add" row before anyone types in it,
+ // and use its markup as the template for rows the Add button creates.
+ var seed = tbody.lastElementChild;
+ if (!seed) return;
+ var seedHTML = seed.outerHTML;
+
+ function enhance(row) {
+ var cell = row.querySelector('.admin-del');
+ if (!cell || cell.dataset.enhanced) return;
+ cell.dataset.enhanced = '1';
+
+ // The checkbox stays in the DOM (hidden) so a form that is somehow
+ // submitted without JS still means what it did before.
+ var box = cell.querySelector('input[type=checkbox]');
+ if (box) box.hidden = true;
+
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'chip row-remove';
+ btn.textContent = 'Remove';
+ btn.addEventListener('click', function () {
+ row.parentNode.removeChild(row);
+ });
+ cell.appendChild(btn);
+ }
+
+ function addRow() {
+ var wrap = document.createElement('tbody');
+ wrap.innerHTML = seedHTML;
+ var row = wrap.firstElementChild;
+ row.querySelectorAll('input, textarea').forEach(function (el) {
+ if (el.type === 'checkbox') el.checked = false;
+ else el.value = '';
+ });
+ tbody.appendChild(row);
+ enhance(row);
+ }
+
+ Array.prototype.forEach.call(tbody.querySelectorAll('tr'), enhance);
+
+ var add = document.getElementById('add-notice');
+ if (add) add.addEventListener('click', addRow);
+})();
diff --git a/static/js/structured.js b/static/js/structured.js
index 6a28d5c..22ce34b 100644
--- a/static/js/structured.js
+++ b/static/js/structured.js
@@ -43,6 +43,7 @@
depth: +tr.dataset.depth || 0,
foldTo: +tr.dataset.foldTo || 0,
key: key.toLowerCase(),
+ path: (tr.dataset.path || '').toLowerCase(),
text: text.toLowerCase(),
blank: text.trim() === '',
val: val.trim().toLowerCase(),
@@ -105,11 +106,82 @@
}
var lastQ = '';
+
+ /* ---- path queries -------------------------------------------------- */
+
+ // A query containing a dot or bracket is tried as a value path first:
+ // obj1.property3.items[4].val. It is normalised to the same canonical
+ // form the server writes into data-path (internal/modules/structured.go),
+ // so a quoted key round-trips and indices attach directly. A query that
+ // resolves to nothing falls through to the substring search below, which
+ // keeps dotted text like a version string searchable.
+ function normalizePath(q) {
+ q = q.trim();
+ if (q[0] === '$') q = q.slice(1);
+ var out = '', first = true, i = 0;
+ function appendKey(k) {
+ if (/^[A-Za-z0-9_$-]+$/.test(k)) out += (first ? '' : '.') + k;
+ else out += '["' + k.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
+ first = false;
+ }
+ while (i < q.length) {
+ var c = q[i];
+ if (c === '.') { i++; continue; }
+ if (c === '[') {
+ var j = i + 1;
+ if (q[j] === '"' || q[j] === "'") {
+ var quote = q[j], key = '';
+ j++;
+ while (j < q.length && q[j] !== quote) {
+ if (q[j] === '\\' && j + 1 < q.length) { key += q[j + 1]; j += 2; }
+ else { key += q[j]; j++; }
+ }
+ if (q[j] !== quote || q[j + 1] !== ']') return '';
+ appendKey(key);
+ i = j + 2;
+ continue;
+ }
+ var num = '';
+ while (j < q.length && q[j] !== ']') { num += q[j]; j++; }
+ if (q[j] !== ']' || !/^\d+$/.test(num)) return '';
+ out += '[' + num + ']';
+ first = false;
+ i = j + 1;
+ continue;
+ }
+ var start = i;
+ while (i < q.length && q[i] !== '.' && q[i] !== '[') i++;
+ appendKey(q.slice(start, i));
+ }
+ return out;
+ }
+
+ // pathMatches returns the set of line numbers the query names, or null
+ // when the query is not a path or names nothing. An exact match also
+ // brings in the subtree beneath it (its items[0], items[0].val, ...), so
+ // naming a container shows its contents. The boundary check keeps a.b
+ // from matching a sibling called a.bc.
+ function pathMatches(q) {
+ var target = normalizePath(q);
+ if (!target) return null;
+ var hit = {}, any = false;
+ R.forEach(function (r) {
+ if (!r.path) return;
+ if (r.path === target) { hit[r.n] = true; any = true; return; }
+ if (r.path.indexOf(target) === 0) {
+ var next = r.path[target.length];
+ if (next === '.' || next === '[') { hit[r.n] = true; any = true; }
+ }
+ });
+ return any ? hit : null;
+ }
+
function search(q) {
q = lastQ = q.trim().toLowerCase();
+ var hits = q && /[.\[]/.test(q) ? pathMatches(q) : null;
matches = [];
R.forEach(function (r) {
- r.match = q !== '' && hay(r).indexOf(q) >= 0;
+ r.match = hits ? !!hits[r.n] : (q !== '' && hay(r).indexOf(q) >= 0);
if (r.match) matches.push(r);
});
matches.forEach(function (r) { expose(r.n); });
@@ -177,7 +249,8 @@
var input = document.createElement('input');
input.type = 'search';
input.className = 'sbar-q';
- input.placeholder = 'Search keys and values… (/)';
+ input.placeholder = 'Search keys, values, or a path like a.b[0]… (/)';
+ input.title = 'Substring search, or a path such as obj.items[0].key';
input.setAttribute('aria-label', 'Search this file');
var scopes = document.createElement('div');
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 6f5c0f2..67f1315 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -9,16 +9,16 @@
</head>
<body>
{{ template "nav" . }}
-<main class="wrap">{{ block "main" . }}{{ end }}</main>
+<main class="wrap">{{ template "notices" . }}{{ block "main" . }}{{ end }}</main>
<footer class="wrap">
<div class="footer-left">
<span class="logo" style="width:24px;height:24px;font-size:0.9rem;">G</span>
<span>{{ .Site.Name }}</span>
</div>
<div class="footer-right">
- <!--<span>served from bare repositories</span>-->
- <span style="color:var(--line);margin:0 .5rem;">&bull;</span>
- <span>{{ .Site.Name }} v1.0</span>
+ <!--<span>served from bare repositories</span>
+ <span style="color:var(--line);margin:0 .5rem;">&bull;</span>-->
+ <span>{{ .Site.Name }} v1.1</span>
</div>
</footer>
{{ block "scripts" . }}{{ end }}
@@ -43,9 +43,18 @@
</nav>
<div class="nav-end">
{{ with modOr "refs" }}
+ {{/* .Ref is a SHA (or a compare range) on commit/diff/compare pages,
+ which never matches a branch or tag name. Without a matching
+ option the browser silently selects the first branch instead,
+ which misleadingly reads as "you're on this branch". Show an
+ explicit detached entry there instead of letting that happen. */}}
+ {{ $known := false }}
+ {{ range .Branches }}{{ if eq .Name $.Ref }}{{ $known = true }}{{ end }}{{ end }}
+ {{ range .Tags }}{{ if eq .Name $.Ref }}{{ $known = true }}{{ end }}{{ end }}
<label class="refpicker">
<span class="sr">Ref</span>
- <select onchange="location.href=this.value">
+ <select onchange="if(this.value) location.href=this.value">
+ {{ if not $known }}<option value="" selected disabled>{{ trunc 12 $.Ref }} (detached)</option>{{ end }}
{{ range .Branches }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
{{ range .Tags }}<option value="/{{ $.Info.Slug }}/tree/{{ .Name }}"{{ if eq .Name $.Ref }} selected{{ end }}>{{ .Name }}</option>{{ end }}
</select>
diff --git a/templates/layouts/notices.html b/templates/layouts/notices.html
new file mode 100644
index 0000000..7209fec
--- /dev/null
+++ b/templates/layouts/notices.html
@@ -0,0 +1,24 @@
+{{/* Operator notices. Global ones are always followed by the current
+ repository's, which is why the module merges them into one slice.
+
+ The .Immutable guard is not cosmetic: commit pages are served with a
+ year-long immutable cache, so a notice rendered into one would be
+ frozen in the reader's browser with no way to revalidate. Those pages
+ simply carry no banners. Guarding here rather than in the module means
+ an immutable page never even computes them. */}}
+{{ define "notices" }}
+{{ if not .Immutable }}
+{{ with modOr "notices" }}
+<div class="notices">
+ {{ range . }}
+ {{ $n := . }}
+ <div class="notice notice-{{ .Type }}">
+ {{ with .Title }}<strong class="notice-title">{{ . }}</strong>{{ end }}
+ {{ with .Body }}<span class="notice-body">{{ . }}</span>{{ end }}
+ {{ with .Link }}<a class="notice-link" href="{{ . }}">{{ with $n.LinkText }}{{ . }}{{ else }}More{{ end }}</a>{{ end }}
+ </div>
+ {{ end }}
+</div>
+{{ end }}
+{{ end }}
+{{ end }}
diff --git a/templates/views/blob.html b/templates/views/blob.html
index f1e82e9..93aeef9 100644
--- a/templates/views/blob.html
+++ b/templates/views/blob.html
@@ -12,6 +12,7 @@
{{ if not $b.Submodule }}
<span class="text-muted">&middot; {{ bytes64 $b.Size }}{{ if not $b.Binary }} &middot; {{ len $b.Lines }} lines{{ end }}</span>
<a class="chip" style="margin-left:.5rem;" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>
+ {{ if $b.Image }}<a class="chip" style="margin-left:.5rem;" href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" download>download</a>{{ end }}
{{ end }}
</p>
</div>
@@ -22,6 +23,15 @@
This is a submodule, pinned at
<code>{{ if $b.Submodule.Remote }}{{ $b.Submodule.Remote }}:{{ end }}{{ $b.Submodule.SHA }}</code>.
</div>
+{{ else if $b.Preview }}
+ <figure class="blob-image">
+ <img src="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" alt="{{ $b.Path }}">
+ </figure>
+{{ else if $b.Image }}
+ <div class="notice">
+ This image is {{ bytes64 $b.Size }}, over the {{ bytes64 $b.PreviewLimit }} preview limit.
+ <a href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}" download>Download it</a> to view it locally.
+ </div>
{{ else if $b.Binary }}
<div class="notice">This is a binary file. Fetch it with <a href="{{ $slug }}/raw/{{ .Ref }}/{{ $b.Path }}">raw</a>.</div>
{{ else }}
@@ -45,7 +55,8 @@
<tr id="L{{ .N }}" data-n="{{ .N }}"
{{- if .FoldTo }} data-fold-to="{{ .FoldTo }}"{{ end }}
{{- if .Depth }} data-depth="{{ .Depth }}"{{ end }}
- {{- with .Key }} data-key="{{ . }}"{{ end }}>
+ {{- with .Key }} data-key="{{ . }}"{{ end }}
+ {{- with .Path }} data-path="{{ . }}"{{ end }}>
<td class="ln"><a href="#L{{ .N }}">{{ .N }}</a></td>
<td class="lc">{{ .Text }}{{ with .Anchor }}{{ if .Uses }}<span class="anchor-uses">{{ .Uses }} use{{ if ne .Uses 1 }}s{{ end }}</span>{{ end }}{{ end }}{{ with .Ref }}<a class="aref" href="#L{{ .Line }}" title="&amp;{{ .Name }} &mdash; line {{ .Line }}{{ with .Preview }}&#10;&#10;{{ . }}{{ end }}">&rarr;&nbsp;{{ .Line }}</a>{{ end }}</td>
</tr>
diff --git a/templates/views/commit.html b/templates/views/commit.html
index f2562fc..9522094 100644
--- a/templates/views/commit.html
+++ b/templates/views/commit.html
@@ -22,14 +22,25 @@
<div class="readme"><pre style="white-space:pre-wrap;margin:0;">{{ . }}</pre></div>
{{ end }}
-{{ with $c.Parents }}
- <div class="chips" style="margin-bottom:1rem;">
+<div class="chips" style="margin-bottom:1rem;">
+ {{ with $c.Parents }}
+ {{ $multi := gt (len $c.Parents) 1 }}
{{ range . }}
<a class="chip" href="{{ $slug }}/commit/{{ . }}">parent {{ trunc 12 . }}</a>
- <a class="chip" href="{{ $slug }}/compare/{{ . }}..{{ $c.SHA }}">diff</a>
+ {{/* For a merge commit, each parent gives a genuinely different diff.
+ For an ordinary single-parent commit it's the same diff already
+ rendered below, so skip the redundant chip there. */}}
+ {{ if $multi }}<a class="chip" href="{{ $slug }}/compare/{{ . }}..{{ $c.SHA }}">diff vs {{ trunc 8 . }}</a>{{ end }}
{{ end }}
- </div>
-{{ end }}
+ {{ end }}
+ <a class="chip" href="{{ $slug }}/tree/{{ $c.SHA }}">view tree</a>
+</div>
+
+<form class="compare-form" style="margin-bottom:1rem;"
+ onsubmit="var v=this.rev.value.trim(); if(v) location.href='{{ $slug }}/compare/'+encodeURIComponent(v)+'..{{ $c.SHA }}'; return false;">
+ <input type="text" name="rev" class="compare-input" placeholder="compare against a branch, tag, or commit&hellip;" required>
+ <button type="submit" class="chip">Compare</button>
+</form>
<article class="card">
<header class="page-head">
diff --git a/templates/views/index.html b/templates/views/index.html
index fafbfa4..8f26a42 100644
--- a/templates/views/index.html
+++ b/templates/views/index.html
@@ -12,12 +12,17 @@
{{/* An unnamed section means gsg.section is unset everywhere: render flat. */}}
{{ with .Name }}<h2 id="{{ slug . }}" class="page-subtitle">{{ . }}</h2>{{ end }}
<div class="table-wrap">
-<table class="table">
+<table class="table table-repos">
+ <colgroup>
+ <col class="col-repo">
+ <col class="col-desc">
+ <col class="col-meta">
+ </colgroup>
<thead>
<tr>
- <th>Repository</th>
+ <th><span class="label-full">Repository</span><span class="label-short">Repo</span></th>
<th>Description</th>
- <th class="text-right">Last Updated</th>
+ <th class="text-right"><span class="label-full">Last Updated</span><span class="label-short">Updated</span></th>
</tr>
</thead>
<tbody>