Migration
This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs.
Contact me
internal/share/share.go
4bda28df3ab3dbfcc789dcbbb184cabc8cac9398
· 13.7 KB · 435 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" |
| 20 | "net/http" |
| 21 | "net/url" |
| 22 | "sort" |
| 23 | "strings" |
| 24 | "sync" |
| 25 | "time" |
| 26 | |
| 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 | secureHeaders(w) |
| 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 !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 | secureHeaders(w) |
| 226 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 227 | if err := h.newTmpl.Execute(w, formView{ |
| 228 | Site: h.cfg.SiteName, Result: absoluteURL(r, "/share/view/"+tok), |
| 229 | }); err != nil { |
| 230 | h.cfg.Logger.Error("share new render", "err", err) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | type viewData struct { |
| 235 | Site string |
| 236 | Error string |
| 237 | Action string |
| 238 | } |
| 239 | |
| 240 | // view is the landing page for a share link. A token that requires no |
| 241 | // password redeems immediately; one that does gets a password prompt |
| 242 | // first. PeekShareToken is unverified and only decides which of those two |
| 243 | // to show -- redeem always re-derives the real answer via VerifyShareToken. |
| 244 | func (h *handler) view(w http.ResponseWriter, r *http.Request) { |
| 245 | tok := r.PathValue("token") |
| 246 | claims, ok := session.PeekShareToken(tok) |
| 247 | if !ok { |
| 248 | http.Error(w, "invalid share link", http.StatusNotFound) |
| 249 | return |
| 250 | } |
| 251 | if !claims.Pwd { |
| 252 | h.redeem(w, r) |
| 253 | return |
| 254 | } |
| 255 | h.renderPassword(w, http.StatusOK, tok, "") |
| 256 | } |
| 257 | |
| 258 | func (h *handler) renderPassword(w http.ResponseWriter, status int, tok, errMsg string) { |
| 259 | secureHeaders(w) |
| 260 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 261 | w.WriteHeader(status) |
| 262 | if err := h.viewTmpl.Execute(w, viewData{ |
| 263 | Site: h.cfg.SiteName, Error: errMsg, Action: "/share/view/" + tok, |
| 264 | }); err != nil { |
| 265 | h.cfg.Logger.Error("share view render", "err", err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // redeem verifies the token (with whatever password was submitted, "" if |
| 270 | // none), filters its slugs against live revocation state, and mints the |
| 271 | // gsg_share cookie. It also serves the no-password GET path via view above, |
| 272 | // where the submitted password is always "". |
| 273 | func (h *handler) redeem(w http.ResponseWriter, r *http.Request) { |
| 274 | tok := r.PathValue("token") |
| 275 | r.Body = http.MaxBytesReader(w, r.Body, 1<<16) |
| 276 | if err := r.ParseForm(); err != nil { |
| 277 | http.Error(w, "bad form submission", http.StatusBadRequest) |
| 278 | return |
| 279 | } |
| 280 | password := r.PostForm.Get("password") |
| 281 | |
| 282 | // Only rate-limit actual guesses against a password-protected link -- |
| 283 | // there is nothing to guess on an unprotected one. Keyed on the source |
| 284 | // IP alone, not IP+token: PeekShareToken doesn't verify a signature, so |
| 285 | // keying on the token string would let an unauthenticated caller mint |
| 286 | // unbounded distinct keys (one per invented token) for free. IP-only |
| 287 | // also has the right semantics anyway -- one source gets a fixed |
| 288 | // budget of guesses per window, not a fixed budget per link it tries. |
| 289 | if peek, ok := session.PeekShareToken(tok); ok && peek.Pwd { |
| 290 | if !h.limiter.allow(remoteIP(r)) { |
| 291 | http.Error(w, "too many attempts, try again later", http.StatusTooManyRequests) |
| 292 | return |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | claims, ok := h.cfg.Sessions.VerifyShareToken(tok, password) |
| 297 | if !ok { |
| 298 | if peek, peekOK := session.PeekShareToken(tok); peekOK && peek.Pwd { |
| 299 | h.renderPassword(w, http.StatusUnauthorized, tok, "incorrect password") |
| 300 | return |
| 301 | } |
| 302 | http.Error(w, "invalid or expired share link", http.StatusNotFound) |
| 303 | return |
| 304 | } |
| 305 | |
| 306 | // Narrow to what's still live: a repo that no longer exists, or whose |
| 307 | // gsg.share-revoked-after postdates this grant, drops out here. This is |
| 308 | // a courtesy at redemption time -- the authoritative check runs again |
| 309 | // on every subsequent request in core.Server.buildCtx, since revocation |
| 310 | // must reach a browser that already holds the cookie, not just block |
| 311 | // new redemptions. |
| 312 | kept := make([]string, 0, len(claims.Slugs)) |
| 313 | for _, slug := range claims.Slugs { |
| 314 | revokedAfter, exists := h.cfg.RevokedAfter(slug) |
| 315 | if !exists { |
| 316 | continue |
| 317 | } |
| 318 | if !revokedAfter.IsZero() && !claims.IssuedAt().After(revokedAfter) { |
| 319 | continue |
| 320 | } |
| 321 | kept = append(kept, slug) |
| 322 | } |
| 323 | if len(kept) == 0 { |
| 324 | http.Error(w, "this share link no longer grants access to any repository", http.StatusGone) |
| 325 | return |
| 326 | } |
| 327 | claims.Slugs = kept |
| 328 | |
| 329 | http.SetCookie(w, h.cfg.Sessions.IssueShareCookie(claims)) |
| 330 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 331 | } |
| 332 | |
| 333 | // rateLimiter is a small in-memory, per-process fixed-window limiter -- |
| 334 | // the same posture gsg already accepts elsewhere (see reposCache in |
| 335 | // internal/modules/repos.go): it resets on restart, and, as a known |
| 336 | // simplification, never evicts a key once created. Acceptable for guarding |
| 337 | // a share link's password on a self-hosted, single-instance tool. |
| 338 | type rateLimiter struct { |
| 339 | mu sync.Mutex |
| 340 | hits map[string][]time.Time |
| 341 | } |
| 342 | |
| 343 | func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} } |
| 344 | |
| 345 | func (l *rateLimiter) allow(key string) bool { |
| 346 | l.mu.Lock() |
| 347 | defer l.mu.Unlock() |
| 348 | now := time.Now() |
| 349 | cutoff := now.Add(-rateLimitWindow) |
| 350 | |
| 351 | // Compact in place: safe because append never writes past the range |
| 352 | // index it's reading from. |
| 353 | kept := l.hits[key][:0] |
| 354 | for _, t := range l.hits[key] { |
| 355 | if t.After(cutoff) { |
| 356 | kept = append(kept, t) |
| 357 | } |
| 358 | } |
| 359 | if len(kept) >= rateLimitMax { |
| 360 | l.hits[key] = kept |
| 361 | return false |
| 362 | } |
| 363 | l.hits[key] = append(kept, now) |
| 364 | return true |
| 365 | } |
| 366 | |
| 367 | func remoteIP(r *http.Request) string { |
| 368 | host, _, err := net.SplitHostPort(r.RemoteAddr) |
| 369 | if err != nil { |
| 370 | return r.RemoteAddr |
| 371 | } |
| 372 | return host |
| 373 | } |
| 374 | |
| 375 | // secureHeaders mirrors internal/notices's admin page: no inline script or |
| 376 | // style, so the CSP can stay tight. script-src is 'none' here (stricter |
| 377 | // than notices' 'self') since these pages ship no JS at all. Cache-Control |
| 378 | // is stricter still: the mint result page's body is a bearer capability |
| 379 | // (the share URL itself), so unlike the rest of the site it must never be |
| 380 | // stored anywhere, not even by the requesting browser. |
| 381 | func secureHeaders(w http.ResponseWriter) { |
| 382 | hdr := w.Header() |
| 383 | hdr.Set("X-Content-Type-Options", "nosniff") |
| 384 | hdr.Set("X-Frame-Options", "DENY") |
| 385 | hdr.Set("Content-Security-Policy", |
| 386 | "default-src 'none'; style-src 'self'; script-src 'none'; img-src 'self'; form-action 'self'; base-uri 'none'") |
| 387 | hdr.Set("Cache-Control", "no-store") |
| 388 | } |
| 389 | |
| 390 | // sameOrigin blocks a cross-site POST even if the session cookie would |
| 391 | // otherwise be sent. A valid gsg_user cookie proves the browser is logged |
| 392 | // in; it does not prove the request came from our own page. |
| 393 | func sameOrigin(r *http.Request) bool { |
| 394 | switch r.Header.Get("Sec-Fetch-Site") { |
| 395 | case "", "same-origin", "none": |
| 396 | default: |
| 397 | return false |
| 398 | } |
| 399 | if o := r.Header.Get("Origin"); o != "" { |
| 400 | u, err := url.Parse(o) |
| 401 | if err != nil || !strings.EqualFold(u.Host, r.Host) { |
| 402 | return false |
| 403 | } |
| 404 | } |
| 405 | return true |
| 406 | } |
| 407 | |
| 408 | func absoluteURL(r *http.Request, path string) string { |
| 409 | scheme := "http" |
| 410 | if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { |
| 411 | scheme = "https" |
| 412 | } |
| 413 | return scheme + "://" + r.Host + path |
| 414 | } |
| 415 | |
| 416 | func humanDuration(d time.Duration) string { |
| 417 | switch { |
| 418 | case d <= 0: |
| 419 | return "0" |
| 420 | case d%(24*time.Hour) == 0: |
| 421 | days := d / (24 * time.Hour) |
| 422 | if days == 1 { |
| 423 | return "1 day" |
| 424 | } |
| 425 | return fmt.Sprintf("%d days", days) |
| 426 | case d%time.Hour == 0: |
| 427 | hours := d / time.Hour |
| 428 | if hours == 1 { |
| 429 | return "1 hour" |
| 430 | } |
| 431 | return fmt.Sprintf("%d hours", hours) |
| 432 | default: |
| 433 | return d.String() |
| 434 | } |
| 435 | } |