Files
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

100 lines
2.8 KiB
Go

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)
}