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 }