diff --git a/AGENTS.md b/AGENTS.md
index ee02972..34fe639 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,10 +50,13 @@ codebase worse.
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/access/ repo visibility + share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
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
+internal/site/ the route table and route-specific request parsers
templates/ layouts/ + views/
static/ one stylesheet
```
@@ -68,6 +71,11 @@ registry. It must not know git semantics. If you find yourself importing
`gitx` into `core` for anything beyond the `Handle`/`Repo` plumbing that is
already there, stop and reconsider.
+**`internal/access`** owns the rule for whether an identity may view or
+share a repo. It deliberately imports nothing from `core`, so the 404 gate,
+the index module, share minting and share redemption all call the same
+policy. Do not grow a second visibility check elsewhere.
+
**`internal/modules`** is where features go. A module is:
```go
@@ -238,7 +246,9 @@ A module for the data, a view in `templates/views/`, one entry in
template trees, because `html/template` has a single namespace for defined
blocks and two views both defining `main` would collide. That is why
`LoadTemplates` parses each view with the layouts rather than globbing
-everything together.
+everything together. If the route needs a new path grammar, declare a
+`Route.Request` parser in `internal/site`; core still resolves every ref it
+returns. Pagination is declared with `Route.PageSize`.
### Add a JSON endpoint
diff --git a/README.md b/README.md
index 597fa40..7758ed9 100644
--- a/README.md
+++ b/README.md
@@ -195,14 +195,18 @@ query params, GitHub does what this does.
main.go flags, repo discovery, embedded assets
internal/gitx/ the ONLY package that knows git exists
internal/core/core.go Ctx, RepoInfo, module registry, Store
-internal/core/route.go Route, View, JSON
+internal/core/route.go Route, View, JSON, route request parsing
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/access/ visibility and share-grant policy (imports no core)
+internal/authn/ login/logout endpoints
+internal/httpx/ shared admin/share HTTP security helpers
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)
+internal/site/request.go route-specific path parsers (compare ranges)
templates/ layouts/ + views/
```
diff --git a/internal/access/access.go b/internal/access/access.go
new file mode 100644
index 0000000..2a99e13
--- /dev/null
+++ b/internal/access/access.go
@@ -0,0 +1,82 @@
+// Package access holds the rules that decide who may see or share a
+// repository. It deliberately imports nothing from core: the visibility
+// rule is used by the 404 gate, the index module, share-link minting, and
+// share-link redemption, and putting it in one dependency-free place is
+// what keeps those four from drifting apart.
+package access
+
+import (
+ "strings"
+ "time"
+)
+
+// Policy is the authorization-relevant subset of a repository's config.
+// core.RepoInfo is the full config; Policy is what the rules below need.
+type Policy struct {
+ Slug string
+ Hidden bool
+ VisibleTo []string
+ ShareRevokedAfter time.Time
+}
+
+// Identity is the verified caller. SharedSlugs/SharedIssuedAt come from a
+// valid gsg_share cookie and are empty when the caller has no share grant.
+type Identity struct {
+ User string
+ SharedSlugs []string
+ SharedIssuedAt time.Time
+}
+
+// CanView reports whether id may see the repository at all. Public repos
+// are visible to everyone; a hidden repo is visible to an owner named in
+// gsg.visible-to or to an unrevoked share grant.
+func (p Policy) CanView(id Identity) bool {
+ if !p.Hidden {
+ return true
+ }
+ return p.canViewAsOwner(id.User) ||
+ (p.coversShare(id.SharedSlugs) && ShareLive(p.ShareRevokedAfter, id.SharedIssuedAt))
+}
+
+// CanShare reports whether user may mint a share link for this repository.
+// Only hidden repos are shareable (sharing a public repo would grant
+// nothing new), and only an owner may mint -- a share grant cannot be
+// amplified into a broader grant.
+func (p Policy) CanShare(user string) bool {
+ return p.Hidden && p.canViewAsOwner(user)
+}
+
+// canViewAsOwner matches gsg.visible-to case-insensitively. Anonymous
+// callers never match: "" would otherwise vacuous-match a blank entry.
+func (p Policy) canViewAsOwner(user string) bool {
+ if user == "" {
+ return false
+ }
+ for _, u := range p.VisibleTo {
+ if strings.EqualFold(u, user) {
+ return true
+ }
+ }
+ return false
+}
+
+func (p Policy) coversShare(slugs []string) bool {
+ for _, s := range slugs {
+ if s == p.Slug {
+ return true
+ }
+ }
+ return false
+}
+
+// ShareLive reports whether a grant issued at issuedAt survives a
+// repository's gsg.share-revoked-after value. A zero revokedAfter means no
+// revocation; a grant issued at or before the timestamp is revoked, which
+// is why the comparison is strict -- the operator's intent is "everything
+// issued before this moment is dead", and the two sides are second-granular.
+//
+// The free function is for callers (share redemption) that only have the
+// timestamp, not a full Policy.
+func ShareLive(revokedAfter, issuedAt time.Time) bool {
+ return revokedAfter.IsZero() || issuedAt.After(revokedAfter)
+}
diff --git a/internal/access/access_test.go b/internal/access/access_test.go
new file mode 100644
index 0000000..0a1ee6d
--- /dev/null
+++ b/internal/access/access_test.go
@@ -0,0 +1,107 @@
+package access
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCanView(t *testing.T) {
+ revoked := time.Unix(1_700_000_000, 0)
+ granted := revoked.Add(time.Minute)
+
+ cases := []struct {
+ name string
+ p Policy
+ id Identity
+ want bool
+ }{
+ {
+ name: "public repo",
+ p: Policy{Slug: "demo"},
+ want: true,
+ },
+ {
+ name: "hidden anonymous",
+ p: Policy{Slug: "demo", Hidden: true},
+ want: false,
+ },
+ {
+ name: "hidden owner",
+ p: Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"Alice"}},
+ id: Identity{User: "alice"},
+ want: true,
+ },
+ {
+ name: "hidden non-owner",
+ p: Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"alice"}},
+ id: Identity{User: "bob"},
+ want: false,
+ },
+ {
+ name: "hidden share grant",
+ p: Policy{Slug: "demo", Hidden: true},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: granted},
+ want: true,
+ },
+ {
+ name: "share grant for another repo",
+ p: Policy{Slug: "demo", Hidden: true},
+ id: Identity{SharedSlugs: []string{"other"}, SharedIssuedAt: granted},
+ want: false,
+ },
+ {
+ name: "share grant before revocation",
+ p: Policy{Slug: "demo", Hidden: true, ShareRevokedAfter: revoked},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: revoked},
+ want: false,
+ },
+ {
+ name: "share grant after revocation",
+ p: Policy{Slug: "demo", Hidden: true, ShareRevokedAfter: revoked},
+ id: Identity{SharedSlugs: []string{"demo"}, SharedIssuedAt: granted},
+ want: true,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.p.CanView(tc.id); got != tc.want {
+ t.Fatalf("CanView() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestCanShare(t *testing.T) {
+ p := Policy{Slug: "demo", Hidden: true, VisibleTo: []string{"alice"}}
+
+ if !p.CanShare("alice") {
+ t.Fatal("owner should be able to share a hidden repo")
+ }
+ if p.CanShare("bob") {
+ t.Fatal("non-owner should not be able to share")
+ }
+ if p.CanShare("") {
+ t.Fatal("anonymous should not be able to share")
+ }
+ if (Policy{Slug: "public", VisibleTo: []string{"alice"}}).CanShare("alice") {
+ t.Fatal("public repo should not be shareable")
+ }
+}
+
+func TestShareLive(t *testing.T) {
+ revoked := time.Unix(1_700_000_000, 0)
+
+ if !ShareLive(time.Time{}, revoked) {
+ t.Fatal("zero revokedAfter should not revoke")
+ }
+ if ShareLive(revoked, revoked) {
+ t.Fatal("grant issued at the revocation time should be revoked")
+ }
+ if ShareLive(revoked, revoked.Add(-time.Second)) {
+ t.Fatal("grant issued before revocation should be revoked")
+ }
+ if !ShareLive(revoked, revoked.Add(time.Second)) {
+ t.Fatal("grant issued after revocation should survive")
+ }
+}
diff --git a/internal/authn/authn.go b/internal/authn/authn.go
new file mode 100644
index 0000000..122cd6a
--- /dev/null
+++ b/internal/authn/authn.go
@@ -0,0 +1,79 @@
+// Package authn owns gsg's browser session endpoints. Login trusts the
+// identity header that the reverse proxy sets after forward_auth; logout
+// only clears gsg's own cookie. Keeping both here means main.go composes
+// listeners rather than implementing authentication.
+//
+// The two handlers are returned separately on purpose: login must only ever
+// be mounted on the admin listener, while logout belongs on the public one.
+// A single combined handler would make that distinction a calling
+// convention instead of a structural one.
+package authn
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "gsg/internal/session"
+)
+
+// Config is the small amount of state the session endpoints need.
+type Config struct {
+ Sessions *session.Manager
+ UserHeader string // e.g. "X-Authentik-Username"; defaults to that
+}
+
+// Handlers are mounted individually by the caller.
+type Handlers struct {
+ Login http.Handler
+ Logout http.Handler
+}
+
+// NewHandlers builds the login and logout endpoints.
+func NewHandlers(cfg Config) (Handlers, error) {
+ if cfg.Sessions == nil {
+ return Handlers{}, fmt.Errorf("authn: no session manager")
+ }
+ if cfg.UserHeader == "" {
+ cfg.UserHeader = "X-Authentik-Username"
+ }
+ return Handlers{
+ Login: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { login(cfg, w, r) }),
+ Logout: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logout(cfg, w, r) }),
+ }, nil
+}
+
+// login mints a session from the proxy-supplied identity. Reaching this
+// handler at all is the authorization decision: the admin listener is the
+// only place it is mounted, and Caddy's forward_auth runs before it.
+func login(cfg Config, w http.ResponseWriter, r *http.Request) {
+ user := r.Header.Get(cfg.UserHeader)
+ if user == "" {
+ http.Error(w, "missing identity header", http.StatusUnauthorized)
+ return
+ }
+ http.SetCookie(w, cfg.Sessions.Issue(user))
+ http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
+}
+
+// logout needs no round trip through the identity provider; it only clears
+// the cookie this process issued.
+func logout(cfg Config, w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, cfg.Sessions.Clear())
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// safeNext returns next if it is a same-site relative path, else "/". next
+// comes from a query parameter on a login redirect; without this check it
+// would be an open redirect (?next=https://evil.example) off a trusted
+// domain, right after proving who the user is.
+func safeNext(next string) string {
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return "/"
+ }
+ if u, err := url.Parse(next); err != nil || u.Scheme != "" || u.Host != "" {
+ return "/"
+ }
+ return next
+}
diff --git a/internal/authn/authn_test.go b/internal/authn/authn_test.go
new file mode 100644
index 0000000..97f82c0
--- /dev/null
+++ b/internal/authn/authn_test.go
@@ -0,0 +1,104 @@
+package authn
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gsg/internal/session"
+)
+
+func TestSafeNext(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"", "/"},
+ {"/", "/"},
+ {"/demo/", "/demo/"},
+ {"/share/new?x=1", "/share/new?x=1"},
+ {"//evil.example", "/"},
+ {"https://evil.example", "/"},
+ {"http://evil.example/path", "/"},
+ {"relative", "/"},
+ }
+ for _, tc := range cases {
+ if got := safeNext(tc.in); got != tc.want {
+ t.Errorf("safeNext(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestLoginRequiresIdentityHeader(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login", nil)
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
+ }
+ if cookies := rec.Result().Cookies(); len(cookies) != 0 {
+ t.Fatalf("unauthenticated login set cookies: %v", cookies)
+ }
+}
+
+func TestLoginIssuesCookieAndHonorsSafeNext(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login?next=/demo/", nil)
+ req.Header.Set("X-Authentik-Username", "alice")
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if got := rec.Header().Get("Location"); got != "/demo/" {
+ t.Fatalf("Location = %q, want /demo/", got)
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != session.CookieName {
+ t.Fatalf("cookies = %v, want one %s cookie", cookies, session.CookieName)
+ }
+}
+
+func TestLoginRejectsOffsiteNext(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/login?next=https://evil.example/", nil)
+ req.Header.Set("X-Authentik-Username", "alice")
+ rec := httptest.NewRecorder()
+ h.Login.ServeHTTP(rec, req)
+
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Fatalf("Location = %q, want /", got)
+ }
+}
+
+func TestLogoutClearsCookie(t *testing.T) {
+ h := testHandlers(t)
+ req := httptest.NewRequest("GET", "/auth/logout", nil)
+ rec := httptest.NewRecorder()
+ h.Logout.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
+ }
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Fatalf("Location = %q, want /", got)
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != session.CookieName {
+ t.Fatalf("cookies = %v, want one %s cookie", cookies, session.CookieName)
+ }
+ if cookies[0].MaxAge >= 0 {
+ t.Fatalf("logout cookie MaxAge = %d, want negative", cookies[0].MaxAge)
+ }
+}
+
+func testHandlers(t *testing.T) Handlers {
+ t.Helper()
+ h, err := NewHandlers(Config{Sessions: session.NewManager()})
+ if err != nil {
+ t.Fatalf("NewHandlers: %v", err)
+ }
+ return h
+}
diff --git a/internal/core/core.go b/internal/core/core.go
index f8e5ae1..6a9a336 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
+ "gsg/internal/access"
"gsg/internal/gitx"
)
@@ -61,8 +62,8 @@ func (s *Site) RepoList() []Handle {
}
// Lookup resolves a repo by slug for per-repo routes, hidden or not -- the
-// caller (Server.buildCtx) is responsible for enforcing RepoInfo.Hidden /
-// CanView before using the result.
+// caller (Server.buildCtx) is responsible for enforcing the access policy
+// before using the result.
func (s *Site) Lookup(slug string) (Handle, bool) {
idx := s.idx.Load()
if idx == nil {
@@ -255,44 +256,16 @@ func splitUsers(v string) []string {
return out
}
-// CanView reports whether user (an authenticated username, or "" for
-// anonymous) may see this repo even if it's Hidden. Anonymous callers never
-// match -- VisibleTo entries are login usernames, and "" would otherwise
-// vacuously match a blank/misconfigured entry.
-func (info RepoInfo) CanView(user string) bool {
- if user == "" {
- return false
+// AccessPolicy is the authorization-relevant subset of RepoInfo. The rules
+// themselves live in internal/access so core, modules, share minting and
+// share redemption cannot drift into slightly different answers.
+func (info RepoInfo) AccessPolicy() access.Policy {
+ return access.Policy{
+ Slug: info.Slug,
+ Hidden: info.Hidden,
+ VisibleTo: info.VisibleTo,
+ ShareRevokedAfter: info.ShareRevokedAfter,
}
- for _, u := range info.VisibleTo {
- if strings.EqualFold(u, user) {
- return true
- }
- }
- return false
-}
-
-// SharedVisible reports whether a verified gsg_share grant -- the slug set
-// and issued-at time from its ShareClaims -- still unlocks this repo. It's
-// not enough for slugs to just name this repo: if gsg.share-revoked-after
-// has been bumped since the grant was issued, it's revoked, even though the
-// signature on the browser's cookie is still perfectly valid. This is
-// checked on every request (not just at redemption), which is what makes
-// bumping the timestamp reach a browser that already redeemed the link.
-func (info RepoInfo) SharedVisible(slugs []string, issuedAt time.Time) bool {
- found := false
- for _, s := range slugs {
- if s == info.Slug {
- found = true
- break
- }
- }
- if !found {
- return false
- }
- if !info.ShareRevokedAfter.IsZero() && !issuedAt.After(info.ShareRevokedAfter) {
- return false
- }
- return true
}
// ----------------------------------------------------------------- context
@@ -315,9 +288,9 @@ type Ctx struct {
// SharedSlugs and SharedIssuedAt come from a verified gsg_share cookie,
// if one was present (SharedSlugs is nil otherwise). They are not yet
- // filtered for revocation -- RepoInfo.SharedVisible applies
- // gsg.share-revoked-after against SharedIssuedAt at the point of use,
- // since revocation is per-repo and this Ctx may span several.
+ // filtered for revocation -- access.Policy applies gsg.share-revoked-after
+ // against SharedIssuedAt at the point of use, since revocation is per-repo
+ // and this Ctx may span several.
SharedSlugs []string
SharedIssuedAt time.Time
diff --git a/internal/core/route.go b/internal/core/route.go
index dba5f32..6ffba36 100644
--- a/internal/core/route.go
+++ b/internal/core/route.go
@@ -84,8 +84,37 @@ type Route struct {
// repo-scoped navigation (see nav.go) -- the top tab bar and the
// page-nav chips both derive from this instead of hardcoding links.
NavLabel string
+
+ // Request, when set, parses this route's path parameters. A nil parser
+ // uses the default {rest...}/{rev} handling. Parsers only split path
+ // text into ref candidates: core still calls gitx.Repo.Resolve, so user
+ // input never reaches git as raw ref syntax (see AGENTS invariant 1).
+ Request RequestFunc
+
+ // PageSize, when greater than zero, enables ?ofs= pagination at that
+ // page size and folds the normalized offset into Ctx.Vars["ofs"].
+ PageSize int
+}
+
+// Request is the path-derived input a route hands back to core before any
+// ref is resolved. Ref is the head commit; Range is set only for routes
+// that compare two commits.
+type Request struct {
+ Ref string
+ Path string
+ Range *Range
}
+// Range describes a base..head request. Ref on Request carries head.
+type Range struct {
+ Base string
+ ThreeDot bool
+}
+
+// RequestFunc parses route-specific path parameters. It must not invoke
+// git or resolve refs itself.
+type RequestFunc func(*http.Request) (Request, error)
+
type Scope int
const (
@@ -95,8 +124,9 @@ const (
// Page sizes for routes that paginate via ?ofs=. Kept in core (not
// modules) so buildCtx can quantize an incoming ofs to a real page
-// boundary without an import cycle. Must match the literal args the
-// log.html/graph.html templates pass to `mod "log"`/`mod "graph"`.
+// boundary without an import cycle. Routes declare the matching PageSize,
+// and it must match the literal arg the log.html/graph.html template passes
+// to `mod "log"`/`mod "graph"`.
const (
LogPageSize = 20
GraphPageSize = 150
diff --git a/internal/core/server.go b/internal/core/server.go
index ba6a9e8..031604c 100644
--- a/internal/core/server.go
+++ b/internal/core/server.go
@@ -13,6 +13,7 @@ import (
"sync"
"time"
+ "gsg/internal/access"
"gsg/internal/gitx"
"gsg/internal/session"
)
@@ -191,18 +192,16 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
// Resolved once, before the scope branch, so the site-scoped early
// return (the index page -- the main surface a login or share link is
// meant to affect) doesn't skip it.
- user := s.Sessions.Verify(r)
- var sharedSlugs []string
- var sharedIssuedAt time.Time
+ identity := access.Identity{User: s.Sessions.Verify(r)}
if claims, ok := s.Sessions.VerifyShareCookie(r); ok {
- sharedSlugs, sharedIssuedAt = claims.Slugs, claims.IssuedAt()
+ identity.SharedSlugs, identity.SharedIssuedAt = claims.Slugs, claims.IssuedAt()
}
if rt.Scope == ScopeSite {
c := NewCtx(s.Site, nil, RepoInfo{}, "")
c.Store = s.cache
- c.User = user
- c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
+ c.User = identity.User
+ c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt
return c, nil
}
@@ -212,44 +211,25 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
}
// Hidden repos are 404, indistinguishable from nonexistent, unless the
- // resolved identity is explicitly allowed to see this one, either as an
- // owner (gsg.visible-to) or via an unrevoked share grant.
- if h.Info.Hidden && !h.Info.CanView(user) && !h.Info.SharedVisible(sharedSlugs, sharedIssuedAt) {
+ // resolved identity is explicitly allowed to see this one. The rule lives
+ // in internal/access so this gate, the index module and share redemption
+ // agree about owners, grants and revocation.
+ if !h.Info.AccessPolicy().CanView(identity) {
return nil, Errorf(http.StatusNotFound, "no such repository %q", slug)
}
repo := h.Repo.WithContext(ctx.Ctx(), s.MaxGit)
- // {rest...} carries ref and path fused together; {rev} is a bare ref;
- // {range} is "base..head" or "base...head".
- ref, path := "", ""
- var base string
- dots := "2"
-
- switch {
- case r.PathValue("range") != "":
- rng := r.PathValue("range")
- var head string
- if b, hd, ok := strings.Cut(rng, "..."); ok {
- base, head, dots = b, hd, "3"
- } else if b, hd, ok := strings.Cut(rng, ".."); ok {
- base, head = b, hd
- } else {
- return nil, Errorf(http.StatusBadRequest, "compare needs base..head")
- }
- ref = head
- case r.PathValue("rest") != "":
- ref, path = repo.SplitRefPath(r.PathValue("rest"))
- case r.PathValue("rev") != "":
- ref = r.PathValue("rev")
- default:
- ref = repo.HeadRef()
+ req, err := parseRequest(rt, repo, r)
+ if err != nil {
+ return nil, err
}
+ ref, path := req.Ref, req.Path
c := NewCtx(s.Site, repo, h.Info, ref)
c.Path = path
c.Store = s.cache
- c.User = user
- c.SharedSlugs, c.SharedIssuedAt = sharedSlugs, sharedIssuedAt
+ c.User = identity.User
+ c.SharedSlugs, c.SharedIssuedAt = identity.SharedSlugs, identity.SharedIssuedAt
if !repo.Empty() {
sha, err := repo.Resolve(ref)
@@ -258,7 +238,12 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
}
c.Commit = sha
}
- if base != "" {
+ if req.Range != nil {
+ base := req.Range.Base
+ dots := "2"
+ if req.Range.ThreeDot {
+ dots = "3"
+ }
baseSHA, err := repo.Resolve(base)
if err != nil {
return nil, Errorf(http.StatusNotFound, "%v", err)
@@ -279,14 +264,10 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
c.Vars["baseSHA"], c.Vars["headSHA"] = baseSHA, headSHA
c.Vars["dots"] = dots
}
- if rt.Name == "log" || rt.Name == "graph" {
- pageSize := LogPageSize
- if rt.Name == "graph" {
- pageSize = GraphPageSize
- }
+ if rt.PageSize > 0 {
if v := r.URL.Query().Get("ofs"); v != "" {
if ofs, err := strconv.Atoi(v); err == nil && ofs > 0 {
- ofs -= ofs % pageSize // snap to a page boundary before it can become a cache key
+ ofs -= ofs % rt.PageSize // snap to a page boundary before it can become a cache key
c.Vars["ofs"] = strconv.Itoa(ofs)
}
}
@@ -299,6 +280,24 @@ func (s *Server) buildCtx(ctx contexter, rt Route, r *http.Request) (*Ctx, error
return c, nil
}
+// parseRequest applies the route's parser, or the default {rest...}/{rev}
+// handling. Both only identify ref candidates; buildCtx below always calls
+// Repo.Resolve before anything downstream sees a commit.
+func parseRequest(rt Route, repo *gitx.Repo, r *http.Request) (Request, error) {
+ if rt.Request != nil {
+ return rt.Request(r)
+ }
+ switch {
+ case r.PathValue("rest") != "":
+ ref, path := repo.SplitRefPath(r.PathValue("rest"))
+ return Request{Ref: ref, Path: path}, nil
+ case r.PathValue("rev") != "":
+ return Request{Ref: r.PathValue("rev")}, nil
+ default:
+ return Request{Ref: repo.HeadRef()}, nil
+ }
+}
+
func (s *Server) fail(w http.ResponseWriter, r *http.Request, rt Route, err error) {
code := statusOf(err)
if errors.Is(err, gitx.ErrTruncated) {
diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go
new file mode 100644
index 0000000..c8ad9ca
--- /dev/null
+++ b/internal/httpx/httpx.go
@@ -0,0 +1,85 @@
+// Package httpx holds small HTTP helpers shared by gsg's non-page
+// surfaces. The admin and share handlers must make the same origin,
+// scheme, header and remote-address decisions; keeping them here means a
+// security fix lands in one place instead of three.
+package httpx
+
+import (
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// SecureOptions controls the admin/share response policy. These pages are
+// deliberately not the themed site: no inline script or style, so the CSP
+// can stay tight.
+type SecureOptions struct {
+ // ScriptSrc is the CSP script-src value. Empty means "'none'"; the
+ // notices admin page needs "'self'" for admin.js, while share pages
+ // ship no JavaScript at all.
+ ScriptSrc string
+ // NoStore marks a response whose body must never be stored, even by
+ // the requesting browser -- a minted share URL is a bearer capability.
+ NoStore bool
+}
+
+// Secure sets the response headers every admin/share page must carry.
+func Secure(w http.ResponseWriter, opts SecureOptions) {
+ scriptSrc := opts.ScriptSrc
+ if scriptSrc == "" {
+ scriptSrc = "'none'"
+ }
+ 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 "+scriptSrc+
+ "; img-src 'self'; form-action 'self'; base-uri 'none'")
+ if opts.NoStore {
+ hdr.Set("Cache-Control", "no-store")
+ }
+}
+
+// SameOrigin blocks a cross-site POST even if a session cookie would
+// otherwise be sent. A valid cookie proves the browser is logged in; 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
+}
+
+// IsHTTPS reports whether the browser's original request was HTTPS,
+// accounting for the reverse proxy's X-Forwarded-Proto.
+func IsHTTPS(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+// AbsoluteURL builds an absolute URL for a path on this host, using the
+// same proxy-aware scheme as IsHTTPS.
+func AbsoluteURL(r *http.Request, path string) string {
+ scheme := "http"
+ if IsHTTPS(r) {
+ scheme = "https"
+ }
+ return scheme + "://" + r.Host + path
+}
+
+// RemoteIP strips the port from r.RemoteAddr for use as a rate-limit key.
+func RemoteIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go
new file mode 100644
index 0000000..79dca96
--- /dev/null
+++ b/internal/httpx/httpx_test.go
@@ -0,0 +1,98 @@
+package httpx
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSecure(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Secure(rec, SecureOptions{ScriptSrc: "'self'", NoStore: true})
+ h := rec.Header()
+
+ if got := h.Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("X-Content-Type-Options = %q", got)
+ }
+ if got := h.Get("X-Frame-Options"); got != "DENY" {
+ t.Errorf("X-Frame-Options = %q", got)
+ }
+ if got := h.Get("Cache-Control"); got != "no-store" {
+ t.Errorf("Cache-Control = %q", got)
+ }
+ if got := h.Get("Content-Security-Policy"); got == "" ||
+ !strings.Contains(got, "script-src 'self'") || !strings.Contains(got, "base-uri 'none'") {
+ t.Errorf("Content-Security-Policy = %q", got)
+ }
+}
+
+func TestSecureDefaultsToNoScript(t *testing.T) {
+ rec := httptest.NewRecorder()
+ Secure(rec, SecureOptions{})
+ if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "script-src 'none'") {
+ t.Errorf("Content-Security-Policy = %q, want script-src 'none'", got)
+ }
+ if got := rec.Header().Get("Cache-Control"); got != "" {
+ t.Errorf("Cache-Control = %q, want unset", got)
+ }
+}
+
+func TestSameOrigin(t *testing.T) {
+ cases := []struct {
+ name string
+ fetch string
+ origin string
+ want bool
+ }{
+ {name: "no headers", want: true},
+ {name: "same origin", fetch: "same-origin", origin: "https://git.example", want: true},
+ {name: "cross site", fetch: "cross-site", want: false},
+ {name: "different origin", origin: "https://evil.example", want: false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ r := httptest.NewRequest("POST", "https://git.example/admin/notices", nil)
+ if tc.fetch != "" {
+ r.Header.Set("Sec-Fetch-Site", tc.fetch)
+ }
+ if tc.origin != "" {
+ r.Header.Set("Origin", tc.origin)
+ }
+ if got := SameOrigin(r); got != tc.want {
+ t.Fatalf("SameOrigin() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestIsHTTPSAndAbsoluteURL(t *testing.T) {
+ r := httptest.NewRequest("GET", "http://git.example/share/new", nil)
+ r.Host = "git.example"
+ if IsHTTPS(r) {
+ t.Fatal("plain request reported HTTPS")
+ }
+ if got := AbsoluteURL(r, "/share/view/token"); got != "http://git.example/share/view/token" {
+ t.Fatalf("AbsoluteURL() = %q", got)
+ }
+
+ r.Header.Set("X-Forwarded-Proto", "https")
+ if !IsHTTPS(r) {
+ t.Fatal("proxied HTTPS request not recognized")
+ }
+ if got := AbsoluteURL(r, "/share/view/token"); got != "https://git.example/share/view/token" {
+ t.Fatalf("AbsoluteURL() = %q", got)
+ }
+}
+
+func TestRemoteIP(t *testing.T) {
+ r := httptest.NewRequest("GET", "/", nil)
+ r.RemoteAddr = "203.0.113.9:54321"
+ if got := RemoteIP(r); got != "203.0.113.9" {
+ t.Fatalf("RemoteIP() = %q", got)
+ }
+ r.RemoteAddr = "unix"
+ if got := RemoteIP(r); got != "unix" {
+ t.Fatalf("RemoteIP() = %q", got)
+ }
+}
diff --git a/internal/modules/repos.go b/internal/modules/repos.go
index 79a8dae..75d57c8 100644
--- a/internal/modules/repos.go
+++ b/internal/modules/repos.go
@@ -6,6 +6,7 @@ import (
"sync"
"time"
+ "gsg/internal/access"
"gsg/internal/core"
)
@@ -90,12 +91,17 @@ func reposMod(c *core.Ctx, args ...string) (any, error) {
// "/" pays one git log per repo they can unlock, on every request (not
// cross-request-memoized), which is the trade-off for keeping
// publicRepos's shared cache above entirely free of personalization.
+ identity := access.Identity{
+ User: c.User,
+ SharedSlugs: c.SharedSlugs,
+ SharedIssuedAt: c.SharedIssuedAt,
+ }
var unlocked []RepoSummary
for _, h := range c.Site.RepoList() {
if !h.Info.Hidden {
continue
}
- if h.Info.CanView(c.User) || h.Info.SharedVisible(c.SharedSlugs, c.SharedIssuedAt) {
+ if h.Info.AccessPolicy().CanView(identity) {
unlocked = append(unlocked, summarize(h))
}
}
diff --git a/internal/notices/handler.go b/internal/notices/handler.go
index e9e65ca..5c467b0 100644
--- a/internal/notices/handler.go
+++ b/internal/notices/handler.go
@@ -9,10 +9,11 @@ import (
"html/template"
"log/slog"
"net/http"
- "net/url"
"sort"
"strconv"
"strings"
+
+ "gsg/internal/httpx"
)
//go:embed admin.html
@@ -135,7 +136,7 @@ func (h *handler) index(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) get(w http.ResponseWriter, r *http.Request) {
- h.secure(w)
+ httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
tok, err := ensureCSRF(w, r)
if err != nil {
http.Error(w, "cannot start session", http.StatusInternalServerError)
@@ -145,12 +146,12 @@ func (h *handler) get(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) post(w http.ResponseWriter, r *http.Request) {
- h.secure(w)
+ httpx.Secure(w, httpx.SecureOptions{ScriptSrc: "'self'"})
// 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) {
+ if !httpx.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"))
@@ -185,16 +186,6 @@ func (h *handler) post(w http.ResponseWriter, r *http.Request) {
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)
@@ -278,7 +269,7 @@ func ensureCSRF(w http.ResponseWriter, r *http.Request) (string, error) {
tok := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: csrfCookie, Value: tok, Path: "/admin",
- HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
+ HttpOnly: true, Secure: httpx.IsHTTPS(r), SameSite: http.SameSiteStrictMode,
MaxAge: 86400,
})
return tok, nil
@@ -292,25 +283,3 @@ func checkCSRF(r *http.Request) bool {
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/share/share.go b/internal/share/share.go
index afb646b..b4c1aeb 100644
--- a/internal/share/share.go
+++ b/internal/share/share.go
@@ -16,14 +16,14 @@ import (
"fmt"
"html/template"
"log/slog"
- "net"
"net/http"
- "net/url"
"sort"
"strings"
"sync"
"time"
+ "gsg/internal/access"
+ "gsg/internal/httpx"
"gsg/internal/session"
)
@@ -133,7 +133,7 @@ func (h *handler) renderForm(w http.ResponseWriter, status int, user, errMsg str
repos := h.cfg.VisibleHiddenRepos(user)
sort.Slice(repos, func(i, j int) bool { return repos[i].Slug < repos[j].Slug })
- secureHeaders(w)
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := h.newTmpl.Execute(w, formView{
@@ -153,7 +153,7 @@ func (h *handler) newSubmit(w http.ResponseWriter, r *http.Request) {
// but it's still an authenticated action worth CSRF-class protection --
// a cross-site page shouldn't be able to make a logged-in visitor's
// browser silently mint share links.
- if !sameOrigin(r) {
+ if !httpx.SameOrigin(r) {
h.cfg.Logger.Warn("share new: origin rejected",
"host", r.Host, "origin", r.Header.Get("Origin"),
"sec-fetch-site", r.Header.Get("Sec-Fetch-Site"))
@@ -222,10 +222,12 @@ func (h *handler) newSubmit(w http.ResponseWriter, r *http.Request) {
"user", user, "repos", len(slugs), "password", password != "",
"exp", time.Unix(claims.Exp, 0).Format(time.RFC3339))
- secureHeaders(w)
+ // The result page's body is a bearer capability (the share URL itself),
+ // so unlike the rest of the site it must never be stored anywhere.
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.newTmpl.Execute(w, formView{
- Site: h.cfg.SiteName, Result: absoluteURL(r, "/share/view/"+tok),
+ Site: h.cfg.SiteName, Result: httpx.AbsoluteURL(r, "/share/view/"+tok),
}); err != nil {
h.cfg.Logger.Error("share new render", "err", err)
}
@@ -256,7 +258,7 @@ func (h *handler) view(w http.ResponseWriter, r *http.Request) {
}
func (h *handler) renderPassword(w http.ResponseWriter, status int, tok, errMsg string) {
- secureHeaders(w)
+ httpx.Secure(w, httpx.SecureOptions{NoStore: true})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := h.viewTmpl.Execute(w, viewData{
@@ -287,7 +289,7 @@ func (h *handler) redeem(w http.ResponseWriter, r *http.Request) {
// also has the right semantics anyway -- one source gets a fixed
// budget of guesses per window, not a fixed budget per link it tries.
if peek, ok := session.PeekShareToken(tok); ok && peek.Pwd {
- if !h.limiter.allow(remoteIP(r)) {
+ if !h.limiter.allow(httpx.RemoteIP(r)) {
http.Error(w, "too many attempts, try again later", http.StatusTooManyRequests)
return
}
@@ -315,7 +317,7 @@ func (h *handler) redeem(w http.ResponseWriter, r *http.Request) {
if !exists {
continue
}
- if !revokedAfter.IsZero() && !claims.IssuedAt().After(revokedAfter) {
+ if !access.ShareLive(revokedAfter, claims.IssuedAt()) {
continue
}
kept = append(kept, slug)
@@ -364,55 +366,6 @@ func (l *rateLimiter) allow(key string) bool {
return true
}
-func remoteIP(r *http.Request) string {
- host, _, err := net.SplitHostPort(r.RemoteAddr)
- if err != nil {
- return r.RemoteAddr
- }
- return host
-}
-
-// secureHeaders mirrors internal/notices's admin page: no inline script or
-// style, so the CSP can stay tight. script-src is 'none' here (stricter
-// than notices' 'self') since these pages ship no JS at all. Cache-Control
-// is stricter still: the mint result page's body is a bearer capability
-// (the share URL itself), so unlike the rest of the site it must never be
-// stored anywhere, not even by the requesting browser.
-func secureHeaders(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 'none'; img-src 'self'; form-action 'self'; base-uri 'none'")
- hdr.Set("Cache-Control", "no-store")
-}
-
-// sameOrigin blocks a cross-site POST even if the session cookie would
-// otherwise be sent. A valid gsg_user cookie proves the browser is logged
-// in; 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
-}
-
-func absoluteURL(r *http.Request, path string) string {
- scheme := "http"
- if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
- scheme = "https"
- }
- return scheme + "://" + r.Host + path
-}
-
func humanDuration(d time.Duration) string {
switch {
case d <= 0:
diff --git a/internal/site/request.go b/internal/site/request.go
new file mode 100644
index 0000000..cec7a5c
--- /dev/null
+++ b/internal/site/request.go
@@ -0,0 +1,28 @@
+package site
+
+import (
+ "net/http"
+ "strings"
+
+ "gsg/internal/core"
+)
+
+// CompareRequest parses {range} into the base and head refs. The refs are
+// still resolved by core through gitx.Repo.Resolve; this only splits text.
+func CompareRequest(r *http.Request) (core.Request, error) {
+ rng := r.PathValue("range")
+ // Check three dots first: "a...b" also contains "..".
+ if base, head, ok := strings.Cut(rng, "..."); ok {
+ return core.Request{
+ Ref: head,
+ Range: &core.Range{Base: base, ThreeDot: true},
+ }, nil
+ }
+ if base, head, ok := strings.Cut(rng, ".."); ok {
+ return core.Request{
+ Ref: head,
+ Range: &core.Range{Base: base},
+ }, nil
+ }
+ return core.Request{}, core.Errorf(http.StatusBadRequest, "compare needs base..head")
+}
diff --git a/internal/site/request_test.go b/internal/site/request_test.go
new file mode 100644
index 0000000..8ef4afe
--- /dev/null
+++ b/internal/site/request_test.go
@@ -0,0 +1,71 @@
+package site
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCompareRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ rangeVal string
+ wantRef string
+ wantBase string
+ threeDot bool
+ wantErr bool
+ }{
+ {name: "two dot", rangeVal: "main..feature", wantRef: "feature", wantBase: "main"},
+ {name: "three dot", rangeVal: "main...feature", wantRef: "feature", wantBase: "main", threeDot: true},
+ {name: "refs with dots", rangeVal: "v1.0..v2.0", wantRef: "v2.0", wantBase: "v1.0"},
+ {name: "missing separator", rangeVal: "main", wantErr: true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/demo/compare/"+tc.rangeVal, nil)
+ req.SetPathValue("range", tc.rangeVal)
+ got, err := CompareRequest(req)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("CompareRequest: %v", err)
+ }
+ if got.Ref != tc.wantRef || got.Range == nil || got.Range.Base != tc.wantBase || got.Range.ThreeDot != tc.threeDot {
+ t.Fatalf("CompareRequest = %+v, want ref=%q base=%q threeDot=%v", got, tc.wantRef, tc.wantBase, tc.threeDot)
+ }
+ })
+ }
+}
+
+// The compare route must use the parser; the paginated routes must declare
+// their page size. Otherwise buildCtx silently falls back to defaults.
+func TestRoutesDeclareParsingMetadata(t *testing.T) {
+ routes := Routes()
+ byName := make(map[string]struct {
+ request bool
+ pageSize int
+ })
+ for _, rt := range routes {
+ byName[rt.Name] = struct {
+ request bool
+ pageSize int
+ }{
+ request: rt.Request != nil,
+ pageSize: rt.PageSize,
+ }
+ }
+
+ if !byName["compare"].request {
+ t.Error("compare route has no Request parser")
+ }
+ if byName["log"].pageSize == 0 {
+ t.Error("log route has no PageSize")
+ }
+ if byName["graph"].pageSize == 0 {
+ t.Error("graph route has no PageSize")
+ }
+}
diff --git a/internal/site/routes.go b/internal/site/routes.go
index ee96fb2..9d62e3d 100644
--- a/internal/site/routes.go
+++ b/internal/site/routes.go
@@ -36,8 +36,9 @@ func Routes() []core.Route {
Render: core.View{Template: "tree.html"}},
{Name: "log", NavLabel: "log",
- Pattern: "GET /{repo}/log/{rest...}",
- Render: core.View{Template: "log.html"}},
+ Pattern: "GET /{repo}/log/{rest...}",
+ Render: core.View{Template: "log.html"},
+ PageSize: core.LogPageSize},
{Name: "blob",
Pattern: "GET /{repo}/blob/{rest...}",
@@ -52,8 +53,9 @@ func Routes() []core.Route {
// 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/{rest...}",
- Render: core.View{Template: "graph.html"}},
+ Pattern: "GET /{repo}/graph/{rest...}",
+ Render: core.View{Template: "graph.html"},
+ PageSize: core.GraphPageSize},
{Name: "refs", NavLabel: "refs",
Pattern: "GET /{repo}/refs/{rest...}",
@@ -62,7 +64,8 @@ func Routes() []core.Route {
// Arbitrary two-commit diff: /demo/compare/main...feature
{Name: "compare",
Pattern: "GET /{repo}/compare/{range}",
- Render: core.View{Template: "compare.html"}},
+ Render: core.View{Template: "compare.html"},
+ Request: CompareRequest},
// SHA-addressed: content can never change, so cache forever.
{Name: "commit", Immutable: true,
diff --git a/main.go b/main.go
index fdf3431..7f45c48 100644
--- a/main.go
+++ b/main.go
@@ -24,11 +24,10 @@ import (
"log/slog"
"net"
"net/http"
- "net/url"
"os"
- "strings"
"time"
+ "gsg/internal/authn"
"gsg/internal/core"
_ "gsg/internal/modules" // registers the built-in modules
"gsg/internal/notices"
@@ -99,9 +98,15 @@ func run(o options) error {
// one. A restart invalidates every outstanding session -- acceptable
// for a login this lightweight.
sessions := session.NewManager()
+ authnHandlers, err := authn.NewHandlers(authn.Config{
+ Sessions: sessions,
+ UserHeader: "X-Authentik-Username",
+ })
+ if err != nil {
+ return err
+ }
var tfs fs.FS
- var err error
if o.tmplDir != "" {
tfs = os.DirFS(o.tmplDir)
} else if tfs, err = fs.Sub(templateFS, "templates"); err != nil {
@@ -126,13 +131,9 @@ func run(o options) error {
// Logout only clears gsg's own cookie -- it needs no round trip through
// Authentik, so it lives on the public listener, not behind
// forward_auth like /auth/login. It's hand-mounted here rather than
- // through site.Routes() for the same reason /auth/login is hand-mounted
- // on the admin mux below: core.Route's Renderer always builds a body
- // from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape.
- mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, sessions.Clear())
- http.Redirect(w, r, "/", http.StatusSeeOther)
- })
+ // through site.Routes() because core.Route's Renderer always builds a
+ // body from a Ctx, and a redirect-plus-Set-Cookie doesn't fit that shape.
+ mux.Handle("GET /auth/logout", authnHandlers.Logout)
// Share links: minting authorizes off the gsg_user cookie, which only
// this (public) listener ever verifies -- the admin listener's whole
@@ -144,7 +145,7 @@ func run(o options) error {
VisibleHiddenRepos: func(user string) []share.RepoOption {
var out []share.RepoOption
for _, h := range siteCfg.RepoList() {
- if h.Info.Hidden && h.Info.CanView(user) {
+ if h.Info.AccessPolicy().CanShare(user) {
out = append(out, share.RepoOption{
Slug: h.Info.Slug,
Title: h.Info.Title,
@@ -199,15 +200,7 @@ func run(o options) error {
// separate /auth/callback -- one hop does the whole job. There is
// also no link to this URL anywhere in the UI; people who need it
// are told out of band.
- amux.HandleFunc("GET /auth/login", func(w http.ResponseWriter, r *http.Request) {
- user := r.Header.Get("X-Authentik-Username")
- if user == "" {
- http.Error(w, "missing identity header", http.StatusUnauthorized)
- return
- }
- http.SetCookie(w, sessions.Issue(user))
- http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
- })
+ amux.Handle("GET /auth/login", authnHandlers.Login)
// Notices editing is a separate opt-in on top of the admin
// listener: -admin-addr alone is enough to enable login.
@@ -255,20 +248,6 @@ func run(o options) error {
return <-errc
}
-// safeNext returns next if it is a same-site relative path, else "/". next
-// comes from a query parameter on a login redirect; without this check it
-// would be an open redirect (?next=https://evil.example) off a trusted
-// domain, right after proving who the user is.
-func safeNext(next string) string {
- if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
- return "/"
- }
- if u, err := url.Parse(next); err != nil || u.Scheme != "" || u.Host != "" {
- return "/"
- }
- return next
-}
-
// 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 {
diff --git a/templates/layouts/base.html b/templates/layouts/base.html
index 3ada5b5..031ca7b 100644
--- a/templates/layouts/base.html
+++ b/templates/layouts/base.html
@@ -18,7 +18,7 @@
<div class="footer-right">
<!--<span>served from bare repositories</span>
<span style="color:var(--line);margin:0 .5rem;">•</span>-->
- <span>{{ .Site.Name }} v1.2</span>
+ <span>{{ .Site.Name }} v1.3</span>
</div>
</footer>
{{ block "scripts" . }}{{ end }}