Files
llmgw/internal/web/claim.go
T

98 lines
2.7 KiB
Go

package web
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// 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, &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, &d)
return
}
d.ExpiresAt = c.ExpiresAt.Format(time.RFC1123)
render(w, "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, &d)
return
}
log.Info("a key was collected")
d.Secret = secret
render(w, "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, d *ClaimData) {
d.Gone = true
d.Message = store.ErrClaimed.Error()
renderStatus(w, http.StatusGone, "claim", *d)
}