Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
98 lines
2.7 KiB
Go
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)
|
|
}
|