package web import ( "context" "errors" "log/slog" "net/http" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) func init() { registerErrorKey(store.ErrClaimed, "claim.err.claimed") } // ClaimData is the one-time key delivery page. type ClaimData struct { Secret string Gone bool Message string ExpiresAt string BaseURL string Version string Now string } // Claims is the store half of the claim flow. type Claims interface { PeekClaim(ctx context.Context, token string) (store.Claim, error) ConsumeClaim(ctx context.Context, token string) (string, error) } // Claim serves the one-time link an operator sends to a new user. // // GET shows a button and consumes nothing; POST reveals the key and burns the // link. That split is not politeness — chat clients, mail scanners and link // previewers fetch URLs the moment they are posted, so a GET that consumed the // claim would mean the first thing to unfurl the link destroys the key before // anyone reads it. // // The page is served without a key, because the person opening it is the one // who does not have one yet. What protects it is the token: 256 random bits, // single use, and short-lived. func Claim(claims Claims, version string, log *slog.Logger) http.HandlerFunc { if log == nil { log = slog.Default() } return func(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") d := ClaimData{ BaseURL: baseURL(r), Version: version, Now: time.Now().UTC().Format(time.RFC3339), } // A key on a page is not something to leave in a proxy or a browser // cache, and not something search engines should ever see. w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Robots-Tag", "noindex, nofollow") if token == "" { gone(w, r, &d) return } if r.Method == http.MethodGet { c, err := claims.PeekClaim(r.Context(), token) if err != nil { if !errors.Is(err, store.ErrClaimed) { log.Warn("could not read a claim", "err", err) } gone(w, r, &d) return } d.ExpiresAt = langOf(r).Format(c.ExpiresAt, time.RFC1123) render(w, r, "claim", d) return } secret, err := claims.ConsumeClaim(r.Context(), token) if err != nil { if !errors.Is(err, store.ErrClaimed) { log.Warn("could not consume a claim", "err", err) } gone(w, r, &d) return } log.Info("a key was collected") d.Secret = secret render(w, r, "claim", d) } } // gone renders the one answer given for a used link, an expired one, and a // token that was never real. Distinguishing them would confirm that a token was // once valid. func gone(w http.ResponseWriter, r *http.Request, d *ClaimData) { d.Gone = true d.Message = langOf(r).Err(store.ErrClaimed) renderStatus(w, r, http.StatusGone, "claim", *d) }