Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/share/share.go
d658fcc4ea9f3bb562fd00dce8f863934560c56c
· 12.4 KB · 388 lines
raw
| 1 | // Package share implements anonymous, stateless share links: a logged-in |
| 2 | // user picks a set of their hidden repos and mints a signed URL that grants |
| 3 | // view access to exactly those repos, no login required, optionally gated |
| 4 | // by a password. Everything needed to verify a link travels inside it -- |
| 5 | // there is no server-side record of what's been shared, only the one |
| 6 | // revocation lever gsg.share-revoked-after provides (see internal/core). |
| 7 | // |
| 8 | // This package is deliberately decoupled from gsg/internal/core, the same |
| 9 | // way internal/notices is: HandlerConfig takes plain data and callbacks, |
| 10 | // not core.Site or core.RepoInfo, so main.go is the only place that bridges |
| 11 | // the two. |
| 12 | package share |
| 13 | |
| 14 | import ( |
| 15 | "embed" |
| 16 | "fmt" |
| 17 | "html/template" |
| 18 | "log/slog" |
| 19 | "net/http" |
| 20 | "sort" |
| 21 | "strings" |
| 22 | "sync" |
| 23 | "time" |
| 24 | |
| 25 | "gsg/internal/access" |
| 26 | "gsg/internal/httpx" |
| 27 | "gsg/internal/session" |
| 28 | ) |
| 29 | |
| 30 | //go:embed new.html view.html |
| 31 | var templatesFS embed.FS |
| 32 | |
| 33 | // maxSlugs caps how many repos a single share token may name. The token |
| 34 | // travels as a URL, and an unbounded slug list eventually exceeds practical |
| 35 | // length limits in browsers, mail clients, and proxies (roughly 2000 |
| 36 | // characters). |
| 37 | const maxSlugs = 64 |
| 38 | |
| 39 | const ( |
| 40 | rateLimitWindow = time.Minute |
| 41 | rateLimitMax = 10 |
| 42 | ) |
| 43 | |
| 44 | // RepoOption is one hidden repo a user could choose to share. |
| 45 | type RepoOption struct { |
| 46 | Slug string |
| 47 | Title string |
| 48 | MaxAge time.Duration // gsg.share-max-age: the ceiling/default for this repo |
| 49 | } |
| 50 | |
| 51 | // HandlerConfig is everything the share surface needs that it cannot |
| 52 | // discover for itself. |
| 53 | type HandlerConfig struct { |
| 54 | Sessions *session.Manager |
| 55 | |
| 56 | // VisibleHiddenRepos returns the hidden repos user is currently allowed |
| 57 | // to see (and therefore may share). Used both to populate the mint form |
| 58 | // and to re-filter a mint submission -- a submitted slug is never |
| 59 | // trusted just because the form included it. |
| 60 | VisibleHiddenRepos func(user string) []RepoOption |
| 61 | |
| 62 | // RevokedAfter returns a repo's gsg.share-revoked-after (zero if unset) |
| 63 | // and whether the repo still exists at all, for filtering a token's |
| 64 | // slugs at redemption time. |
| 65 | RevokedAfter func(slug string) (revokedAfter time.Time, exists bool) |
| 66 | |
| 67 | SiteName string |
| 68 | Logger *slog.Logger |
| 69 | } |
| 70 | |
| 71 | type handler struct { |
| 72 | cfg HandlerConfig |
| 73 | newTmpl *template.Template |
| 74 | viewTmpl *template.Template |
| 75 | limiter *rateLimiter |
| 76 | } |
| 77 | |
| 78 | // NewHandler builds the share mux: minting (requires a gsg_user session) |
| 79 | // and redemption (does not). Both belong on the public listener (see |
| 80 | // main.go) and are hand-mounted directly on its mux rather than expressed |
| 81 | // as a core.Route -- core.Route's Renderer always builds a body from a Ctx, |
| 82 | // and a mint result page or a redirect-plus-Set-Cookie don't fit that |
| 83 | // shape. |
| 84 | func NewHandler(cfg HandlerConfig) (http.Handler, error) { |
| 85 | if cfg.Sessions == nil { |
| 86 | return nil, fmt.Errorf("share: no session manager") |
| 87 | } |
| 88 | if cfg.VisibleHiddenRepos == nil || cfg.RevokedAfter == nil { |
| 89 | return nil, fmt.Errorf("share: missing required callback") |
| 90 | } |
| 91 | if cfg.Logger == nil { |
| 92 | cfg.Logger = slog.Default() |
| 93 | } |
| 94 | |
| 95 | funcs := template.FuncMap{"humanDuration": humanDuration} |
| 96 | newTmpl, err := template.New("new.html").Funcs(funcs).ParseFS(templatesFS, "new.html") |
| 97 | if err != nil { |
| 98 | return nil, fmt.Errorf("share: new.html: %w", err) |
| 99 | } |
| 100 | viewTmpl, err := template.New("view.html").Funcs(funcs).ParseFS(templatesFS, "view.html") |
| 101 | if err != nil { |
| 102 | return nil, fmt.Errorf("share: view.html: %w", err) |
| 103 | } |
| 104 | |
| 105 | h := &handler{cfg: cfg, newTmpl: newTmpl, viewTmpl: viewTmpl, limiter: newRateLimiter()} |
| 106 | |
| 107 | mux := http.NewServeMux() |
| 108 | mux.HandleFunc("GET /share/new", h.newForm) |
| 109 | mux.HandleFunc("POST /share/new", h.newSubmit) |
| 110 | mux.HandleFunc("GET /share/view/{token}", h.view) |
| 111 | mux.HandleFunc("POST /share/view/{token}", h.redeem) |
| 112 | return mux, nil |
| 113 | } |
| 114 | |
| 115 | type formView struct { |
| 116 | Site string |
| 117 | Error string |
| 118 | Result string |
| 119 | Repos []RepoOption |
| 120 | Selected map[string]bool |
| 121 | } |
| 122 | |
| 123 | func (h *handler) newForm(w http.ResponseWriter, r *http.Request) { |
| 124 | user := h.cfg.Sessions.Verify(r) |
| 125 | if user == "" { |
| 126 | http.Redirect(w, r, "/auth/login?next=/share/new", http.StatusSeeOther) |
| 127 | return |
| 128 | } |
| 129 | h.renderForm(w, http.StatusOK, user, "", nil) |
| 130 | } |
| 131 | |
| 132 | func (h *handler) renderForm(w http.ResponseWriter, status int, user, errMsg string, selected map[string]bool) { |
| 133 | repos := h.cfg.VisibleHiddenRepos(user) |
| 134 | sort.Slice(repos, func(i, j int) bool { return repos[i].Slug < repos[j].Slug }) |
| 135 | |
| 136 | httpx.Secure(w, httpx.SecureOptions{NoStore: true}) |
| 137 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 138 | w.WriteHeader(status) |
| 139 | if err := h.newTmpl.Execute(w, formView{ |
| 140 | Site: h.cfg.SiteName, Error: errMsg, Repos: repos, Selected: selected, |
| 141 | }); err != nil { |
| 142 | h.cfg.Logger.Error("share new render", "err", err) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func (h *handler) newSubmit(w http.ResponseWriter, r *http.Request) { |
| 147 | user := h.cfg.Sessions.Verify(r) |
| 148 | if user == "" { |
| 149 | http.Error(w, "not logged in", http.StatusUnauthorized) |
| 150 | return |
| 151 | } |
| 152 | // Same-origin check on the POST: minting persists nothing server-side, |
| 153 | // but it's still an authenticated action worth CSRF-class protection -- |
| 154 | // a cross-site page shouldn't be able to make a logged-in visitor's |
| 155 | // browser silently mint share links. |
| 156 | if !httpx.SameOrigin(r) { |
| 157 | h.cfg.Logger.Warn("share new: origin rejected", |
| 158 | "host", r.Host, "origin", r.Header.Get("Origin"), |
| 159 | "sec-fetch-site", r.Header.Get("Sec-Fetch-Site")) |
| 160 | http.Error(w, "forbidden", http.StatusForbidden) |
| 161 | return |
| 162 | } |
| 163 | r.Body = http.MaxBytesReader(w, r.Body, 1<<16) |
| 164 | if err := r.ParseForm(); err != nil { |
| 165 | h.renderForm(w, http.StatusBadRequest, user, "bad form submission", nil) |
| 166 | return |
| 167 | } |
| 168 | |
| 169 | allowed := h.cfg.VisibleHiddenRepos(user) |
| 170 | byslug := make(map[string]RepoOption, len(allowed)) |
| 171 | for _, o := range allowed { |
| 172 | byslug[o.Slug] = o |
| 173 | } |
| 174 | |
| 175 | submitted := r.PostForm["repo"] |
| 176 | selected := make(map[string]bool, len(submitted)) |
| 177 | for _, s := range submitted { |
| 178 | selected[s] = true |
| 179 | } |
| 180 | |
| 181 | // Never trust the submitted slug list: only repos byslug (this user's |
| 182 | // own current visibility) can end up in the token, however the form |
| 183 | // was tampered with. |
| 184 | var slugs []string |
| 185 | var ceiling time.Duration |
| 186 | for _, s := range submitted { |
| 187 | o, ok := byslug[s] |
| 188 | if !ok { |
| 189 | continue |
| 190 | } |
| 191 | slugs = append(slugs, o.Slug) |
| 192 | if ceiling == 0 || o.MaxAge < ceiling { |
| 193 | ceiling = o.MaxAge |
| 194 | } |
| 195 | } |
| 196 | if len(slugs) == 0 { |
| 197 | h.renderForm(w, http.StatusBadRequest, user, "select at least one repository", selected) |
| 198 | return |
| 199 | } |
| 200 | if len(slugs) > maxSlugs { |
| 201 | h.renderForm(w, http.StatusBadRequest, user, |
| 202 | fmt.Sprintf("a single link can name at most %d repositories", maxSlugs), selected) |
| 203 | return |
| 204 | } |
| 205 | sort.Strings(slugs) |
| 206 | |
| 207 | // The user may always ask for less than the ceiling, never more; an |
| 208 | // unparsable or too-long value falls back to the ceiling rather than |
| 209 | // rejecting the submission outright, since the ceiling is always a |
| 210 | // valid choice. |
| 211 | ttl := ceiling |
| 212 | if v := strings.TrimSpace(r.PostForm.Get("ttl")); v != "" { |
| 213 | if d, err := time.ParseDuration(v); err == nil && d > 0 && d <= ceiling { |
| 214 | ttl = d |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | password := r.PostForm.Get("password") |
| 219 | tok, claims := h.cfg.Sessions.NewShareToken(slugs, ttl, password) |
| 220 | |
| 221 | h.cfg.Logger.Info("share link minted", |
| 222 | "user", user, "repos", len(slugs), "password", password != "", |
| 223 | "exp", time.Unix(claims.Exp, 0).Format(time.RFC3339)) |
| 224 | |
| 225 | // The result page's body is a bearer capability (the share URL itself), |
| 226 | // so unlike the rest of the site it must never be stored anywhere. |
| 227 | httpx.Secure(w, httpx.SecureOptions{NoStore: true}) |
| 228 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 229 | if err := h.newTmpl.Execute(w, formView{ |
| 230 | Site: h.cfg.SiteName, Result: httpx.AbsoluteURL(r, "/share/view/"+tok), |
| 231 | }); err != nil { |
| 232 | h.cfg.Logger.Error("share new render", "err", err) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | type viewData struct { |
| 237 | Site string |
| 238 | Error string |
| 239 | Action string |
| 240 | } |
| 241 | |
| 242 | // view is the landing page for a share link. A token that requires no |
| 243 | // password redeems immediately; one that does gets a password prompt |
| 244 | // first. PeekShareToken is unverified and only decides which of those two |
| 245 | // to show -- redeem always re-derives the real answer via VerifyShareToken. |
| 246 | func (h *handler) view(w http.ResponseWriter, r *http.Request) { |
| 247 | tok := r.PathValue("token") |
| 248 | claims, ok := session.PeekShareToken(tok) |
| 249 | if !ok { |
| 250 | http.Error(w, "invalid share link", http.StatusNotFound) |
| 251 | return |
| 252 | } |
| 253 | if !claims.Pwd { |
| 254 | h.redeem(w, r) |
| 255 | return |
| 256 | } |
| 257 | h.renderPassword(w, http.StatusOK, tok, "") |
| 258 | } |
| 259 | |
| 260 | func (h *handler) renderPassword(w http.ResponseWriter, status int, tok, errMsg string) { |
| 261 | httpx.Secure(w, httpx.SecureOptions{NoStore: true}) |
| 262 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 263 | w.WriteHeader(status) |
| 264 | if err := h.viewTmpl.Execute(w, viewData{ |
| 265 | Site: h.cfg.SiteName, Error: errMsg, Action: "/share/view/" + tok, |
| 266 | }); err != nil { |
| 267 | h.cfg.Logger.Error("share view render", "err", err) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // redeem verifies the token (with whatever password was submitted, "" if |
| 272 | // none), filters its slugs against live revocation state, and mints the |
| 273 | // gsg_share cookie. It also serves the no-password GET path via view above, |
| 274 | // where the submitted password is always "". |
| 275 | func (h *handler) redeem(w http.ResponseWriter, r *http.Request) { |
| 276 | tok := r.PathValue("token") |
| 277 | r.Body = http.MaxBytesReader(w, r.Body, 1<<16) |
| 278 | if err := r.ParseForm(); err != nil { |
| 279 | http.Error(w, "bad form submission", http.StatusBadRequest) |
| 280 | return |
| 281 | } |
| 282 | password := r.PostForm.Get("password") |
| 283 | |
| 284 | // Only rate-limit actual guesses against a password-protected link -- |
| 285 | // there is nothing to guess on an unprotected one. Keyed on the source |
| 286 | // IP alone, not IP+token: PeekShareToken doesn't verify a signature, so |
| 287 | // keying on the token string would let an unauthenticated caller mint |
| 288 | // unbounded distinct keys (one per invented token) for free. IP-only |
| 289 | // also has the right semantics anyway -- one source gets a fixed |
| 290 | // budget of guesses per window, not a fixed budget per link it tries. |
| 291 | if peek, ok := session.PeekShareToken(tok); ok && peek.Pwd { |
| 292 | if !h.limiter.allow(httpx.RemoteIP(r)) { |
| 293 | http.Error(w, "too many attempts, try again later", http.StatusTooManyRequests) |
| 294 | return |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | claims, ok := h.cfg.Sessions.VerifyShareToken(tok, password) |
| 299 | if !ok { |
| 300 | if peek, peekOK := session.PeekShareToken(tok); peekOK && peek.Pwd { |
| 301 | h.renderPassword(w, http.StatusUnauthorized, tok, "incorrect password") |
| 302 | return |
| 303 | } |
| 304 | http.Error(w, "invalid or expired share link", http.StatusNotFound) |
| 305 | return |
| 306 | } |
| 307 | |
| 308 | // Narrow to what's still live: a repo that no longer exists, or whose |
| 309 | // gsg.share-revoked-after postdates this grant, drops out here. This is |
| 310 | // a courtesy at redemption time -- the authoritative check runs again |
| 311 | // on every subsequent request in core.Server.buildCtx, since revocation |
| 312 | // must reach a browser that already holds the cookie, not just block |
| 313 | // new redemptions. |
| 314 | kept := make([]string, 0, len(claims.Slugs)) |
| 315 | for _, slug := range claims.Slugs { |
| 316 | revokedAfter, exists := h.cfg.RevokedAfter(slug) |
| 317 | if !exists { |
| 318 | continue |
| 319 | } |
| 320 | if !access.ShareLive(revokedAfter, claims.IssuedAt()) { |
| 321 | continue |
| 322 | } |
| 323 | kept = append(kept, slug) |
| 324 | } |
| 325 | if len(kept) == 0 { |
| 326 | http.Error(w, "this share link no longer grants access to any repository", http.StatusGone) |
| 327 | return |
| 328 | } |
| 329 | claims.Slugs = kept |
| 330 | |
| 331 | http.SetCookie(w, h.cfg.Sessions.IssueShareCookie(claims)) |
| 332 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 333 | } |
| 334 | |
| 335 | // rateLimiter is a small in-memory, per-process fixed-window limiter -- |
| 336 | // the same posture gsg already accepts elsewhere (see reposCache in |
| 337 | // internal/modules/repos.go): it resets on restart, and, as a known |
| 338 | // simplification, never evicts a key once created. Acceptable for guarding |
| 339 | // a share link's password on a self-hosted, single-instance tool. |
| 340 | type rateLimiter struct { |
| 341 | mu sync.Mutex |
| 342 | hits map[string][]time.Time |
| 343 | } |
| 344 | |
| 345 | func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} } |
| 346 | |
| 347 | func (l *rateLimiter) allow(key string) bool { |
| 348 | l.mu.Lock() |
| 349 | defer l.mu.Unlock() |
| 350 | now := time.Now() |
| 351 | cutoff := now.Add(-rateLimitWindow) |
| 352 | |
| 353 | // Compact in place: safe because append never writes past the range |
| 354 | // index it's reading from. |
| 355 | kept := l.hits[key][:0] |
| 356 | for _, t := range l.hits[key] { |
| 357 | if t.After(cutoff) { |
| 358 | kept = append(kept, t) |
| 359 | } |
| 360 | } |
| 361 | if len(kept) >= rateLimitMax { |
| 362 | l.hits[key] = kept |
| 363 | return false |
| 364 | } |
| 365 | l.hits[key] = append(kept, now) |
| 366 | return true |
| 367 | } |
| 368 | |
| 369 | func humanDuration(d time.Duration) string { |
| 370 | switch { |
| 371 | case d <= 0: |
| 372 | return "0" |
| 373 | case d%(24*time.Hour) == 0: |
| 374 | days := d / (24 * time.Hour) |
| 375 | if days == 1 { |
| 376 | return "1 day" |
| 377 | } |
| 378 | return fmt.Sprintf("%d days", days) |
| 379 | case d%time.Hour == 0: |
| 380 | hours := d / time.Hour |
| 381 | if hours == 1 { |
| 382 | return "1 hour" |
| 383 | } |
| 384 | return fmt.Sprintf("%d hours", hours) |
| 385 | default: |
| 386 | return d.String() |
| 387 | } |
| 388 | } |