Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/share/share.go

83aa601b98007ead72afeadb131a93d5694e04ca · 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 }