// Package share implements anonymous, stateless share links: a logged-in // user picks a set of their hidden repos and mints a signed URL that grants // view access to exactly those repos, no login required, optionally gated // by a password. Everything needed to verify a link travels inside it -- // there is no server-side record of what's been shared, only the one // revocation lever gsg.share-revoked-after provides (see internal/core). // // This package is deliberately decoupled from gsg/internal/core, the same // way internal/notices is: HandlerConfig takes plain data and callbacks, // not core.Site or core.RepoInfo, so main.go is the only place that bridges // the two. package share import ( "embed" "fmt" "html/template" "log/slog" "net" "net/http" "net/url" "sort" "strings" "sync" "time" "gsg/internal/session" ) //go:embed new.html view.html var templatesFS embed.FS // maxSlugs caps how many repos a single share token may name. The token // travels as a URL, and an unbounded slug list eventually exceeds practical // length limits in browsers, mail clients, and proxies (roughly 2000 // characters). const maxSlugs = 64 const ( rateLimitWindow = time.Minute rateLimitMax = 10 ) // RepoOption is one hidden repo a user could choose to share. type RepoOption struct { Slug string Title string MaxAge time.Duration // gsg.share-max-age: the ceiling/default for this repo } // HandlerConfig is everything the share surface needs that it cannot // discover for itself. type HandlerConfig struct { Sessions *session.Manager // VisibleHiddenRepos returns the hidden repos user is currently allowed // to see (and therefore may share). Used both to populate the mint form // and to re-filter a mint submission -- a submitted slug is never // trusted just because the form included it. VisibleHiddenRepos func(user string) []RepoOption // RevokedAfter returns a repo's gsg.share-revoked-after (zero if unset) // and whether the repo still exists at all, for filtering a token's // slugs at redemption time. RevokedAfter func(slug string) (revokedAfter time.Time, exists bool) SiteName string Logger *slog.Logger } type handler struct { cfg HandlerConfig newTmpl *template.Template viewTmpl *template.Template limiter *rateLimiter } // NewHandler builds the share mux: minting (requires a gsg_user session) // and redemption (does not). Both belong on the public listener (see // main.go) and are hand-mounted directly on its mux rather than expressed // as a core.Route -- core.Route's Renderer always builds a body from a Ctx, // and a mint result page or a redirect-plus-Set-Cookie don't fit that // shape. func NewHandler(cfg HandlerConfig) (http.Handler, error) { if cfg.Sessions == nil { return nil, fmt.Errorf("share: no session manager") } if cfg.VisibleHiddenRepos == nil || cfg.RevokedAfter == nil { return nil, fmt.Errorf("share: missing required callback") } if cfg.Logger == nil { cfg.Logger = slog.Default() } funcs := template.FuncMap{"humanDuration": humanDuration} newTmpl, err := template.New("new.html").Funcs(funcs).ParseFS(templatesFS, "new.html") if err != nil { return nil, fmt.Errorf("share: new.html: %w", err) } viewTmpl, err := template.New("view.html").Funcs(funcs).ParseFS(templatesFS, "view.html") if err != nil { return nil, fmt.Errorf("share: view.html: %w", err) } h := &handler{cfg: cfg, newTmpl: newTmpl, viewTmpl: viewTmpl, limiter: newRateLimiter()} mux := http.NewServeMux() mux.HandleFunc("GET /share/new", h.newForm) mux.HandleFunc("POST /share/new", h.newSubmit) mux.HandleFunc("GET /share/view/{token}", h.view) mux.HandleFunc("POST /share/view/{token}", h.redeem) return mux, nil } type formView struct { Site string Error string Result string Repos []RepoOption Selected map[string]bool } func (h *handler) newForm(w http.ResponseWriter, r *http.Request) { user := h.cfg.Sessions.Verify(r) if user == "" { http.Redirect(w, r, "/auth/login?next=/share/new", http.StatusSeeOther) return } h.renderForm(w, http.StatusOK, user, "", nil) } func (h *handler) renderForm(w http.ResponseWriter, status int, user, errMsg string, selected map[string]bool) { repos := h.cfg.VisibleHiddenRepos(user) sort.Slice(repos, func(i, j int) bool { return repos[i].Slug < repos[j].Slug }) secureHeaders(w) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := h.newTmpl.Execute(w, formView{ Site: h.cfg.SiteName, Error: errMsg, Repos: repos, Selected: selected, }); err != nil { h.cfg.Logger.Error("share new render", "err", err) } } func (h *handler) newSubmit(w http.ResponseWriter, r *http.Request) { user := h.cfg.Sessions.Verify(r) if user == "" { http.Error(w, "not logged in", http.StatusUnauthorized) return } // Same-origin check on the POST: minting persists nothing server-side, // 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) { 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")) http.Error(w, "forbidden", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, 1<<16) if err := r.ParseForm(); err != nil { h.renderForm(w, http.StatusBadRequest, user, "bad form submission", nil) return } allowed := h.cfg.VisibleHiddenRepos(user) byslug := make(map[string]RepoOption, len(allowed)) for _, o := range allowed { byslug[o.Slug] = o } submitted := r.PostForm["repo"] selected := make(map[string]bool, len(submitted)) for _, s := range submitted { selected[s] = true } // Never trust the submitted slug list: only repos byslug (this user's // own current visibility) can end up in the token, however the form // was tampered with. var slugs []string var ceiling time.Duration for _, s := range submitted { o, ok := byslug[s] if !ok { continue } slugs = append(slugs, o.Slug) if ceiling == 0 || o.MaxAge < ceiling { ceiling = o.MaxAge } } if len(slugs) == 0 { h.renderForm(w, http.StatusBadRequest, user, "select at least one repository", selected) return } if len(slugs) > maxSlugs { h.renderForm(w, http.StatusBadRequest, user, fmt.Sprintf("a single link can name at most %d repositories", maxSlugs), selected) return } sort.Strings(slugs) // The user may always ask for less than the ceiling, never more; an // unparsable or too-long value falls back to the ceiling rather than // rejecting the submission outright, since the ceiling is always a // valid choice. ttl := ceiling if v := strings.TrimSpace(r.PostForm.Get("ttl")); v != "" { if d, err := time.ParseDuration(v); err == nil && d > 0 && d <= ceiling { ttl = d } } password := r.PostForm.Get("password") tok, claims := h.cfg.Sessions.NewShareToken(slugs, ttl, password) h.cfg.Logger.Info("share link minted", "user", user, "repos", len(slugs), "password", password != "", "exp", time.Unix(claims.Exp, 0).Format(time.RFC3339)) secureHeaders(w) 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), }); err != nil { h.cfg.Logger.Error("share new render", "err", err) } } type viewData struct { Site string Error string Action string } // view is the landing page for a share link. A token that requires no // password redeems immediately; one that does gets a password prompt // first. PeekShareToken is unverified and only decides which of those two // to show -- redeem always re-derives the real answer via VerifyShareToken. func (h *handler) view(w http.ResponseWriter, r *http.Request) { tok := r.PathValue("token") claims, ok := session.PeekShareToken(tok) if !ok { http.Error(w, "invalid share link", http.StatusNotFound) return } if !claims.Pwd { h.redeem(w, r) return } h.renderPassword(w, http.StatusOK, tok, "") } func (h *handler) renderPassword(w http.ResponseWriter, status int, tok, errMsg string) { secureHeaders(w) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := h.viewTmpl.Execute(w, viewData{ Site: h.cfg.SiteName, Error: errMsg, Action: "/share/view/" + tok, }); err != nil { h.cfg.Logger.Error("share view render", "err", err) } } // redeem verifies the token (with whatever password was submitted, "" if // none), filters its slugs against live revocation state, and mints the // gsg_share cookie. It also serves the no-password GET path via view above, // where the submitted password is always "". func (h *handler) redeem(w http.ResponseWriter, r *http.Request) { tok := r.PathValue("token") r.Body = http.MaxBytesReader(w, r.Body, 1<<16) if err := r.ParseForm(); err != nil { http.Error(w, "bad form submission", http.StatusBadRequest) return } password := r.PostForm.Get("password") // Only rate-limit actual guesses against a password-protected link -- // there is nothing to guess on an unprotected one. Keyed on the source // IP alone, not IP+token: PeekShareToken doesn't verify a signature, so // keying on the token string would let an unauthenticated caller mint // unbounded distinct keys (one per invented token) for free. IP-only // 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)) { http.Error(w, "too many attempts, try again later", http.StatusTooManyRequests) return } } claims, ok := h.cfg.Sessions.VerifyShareToken(tok, password) if !ok { if peek, peekOK := session.PeekShareToken(tok); peekOK && peek.Pwd { h.renderPassword(w, http.StatusUnauthorized, tok, "incorrect password") return } http.Error(w, "invalid or expired share link", http.StatusNotFound) return } // Narrow to what's still live: a repo that no longer exists, or whose // gsg.share-revoked-after postdates this grant, drops out here. This is // a courtesy at redemption time -- the authoritative check runs again // on every subsequent request in core.Server.buildCtx, since revocation // must reach a browser that already holds the cookie, not just block // new redemptions. kept := make([]string, 0, len(claims.Slugs)) for _, slug := range claims.Slugs { revokedAfter, exists := h.cfg.RevokedAfter(slug) if !exists { continue } if !revokedAfter.IsZero() && !claims.IssuedAt().After(revokedAfter) { continue } kept = append(kept, slug) } if len(kept) == 0 { http.Error(w, "this share link no longer grants access to any repository", http.StatusGone) return } claims.Slugs = kept http.SetCookie(w, h.cfg.Sessions.IssueShareCookie(claims)) http.Redirect(w, r, "/", http.StatusSeeOther) } // rateLimiter is a small in-memory, per-process fixed-window limiter -- // the same posture gsg already accepts elsewhere (see reposCache in // internal/modules/repos.go): it resets on restart, and, as a known // simplification, never evicts a key once created. Acceptable for guarding // a share link's password on a self-hosted, single-instance tool. type rateLimiter struct { mu sync.Mutex hits map[string][]time.Time } func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} } func (l *rateLimiter) allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() cutoff := now.Add(-rateLimitWindow) // Compact in place: safe because append never writes past the range // index it's reading from. kept := l.hits[key][:0] for _, t := range l.hits[key] { if t.After(cutoff) { kept = append(kept, t) } } if len(kept) >= rateLimitMax { l.hits[key] = kept return false } l.hits[key] = append(kept, now) 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: return "0" case d%(24*time.Hour) == 0: days := d / (24 * time.Hour) if days == 1 { return "1 day" } return fmt.Sprintf("%d days", days) case d%time.Hour == 0: hours := d / time.Hour if hours == 1 { return "1 hour" } return fmt.Sprintf("%d hours", hours) default: return d.String() } }