Files
llmgw/internal/web/account.go
T
Ilker ManapandClaude Opus 5.5 2f54780802 G4: a page that makes 3D models, and ctl 3d
/3d beside /images, while some model makes meshes: a picture chosen, the
job asked for through the same path an API client's takes, its progress
shown (waiting for the card, n ahead; making it, the node and percent),
and the mesh in a viewer — model-viewer 4.4.0 (Apache 2.0, with three.js
and lit), served from the gateway since the page's policy loads scripts
from nowhere else — with a download and the model's licence. The mesh is
served to its owner from /3d/jobs/{id}/file; another project's person is
told there is no such job.

A job made from the portal acted for a key that does not exist: the
worker now rebuilds a session's principal from the account that made it,
while it is active and still in the project the job is charged to.

ctl 3d list|get|cancel, over /api/v1/client-jobs (/api/v1/jobs is the
control plane's own work, which ctl jobs shows).

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

786 lines
27 KiB
Go

package web
import (
"context"
"crypto/rand"
"encoding/base32"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/audit"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The errors from the account service and the store that these pages show a
// person as they are, in that person's language.
func init() {
registerErrorKey(account.ErrBadCredentials, "login.err.bad_credentials")
registerErrorKey(account.ErrNotApproved, "login.err.not_approved")
registerErrorKey(account.ErrSuspended, "login.err.suspended")
registerErrorKey(account.ErrTooManyTries, "login.err.too_many_tries")
registerErrorKey(account.ErrBusy, "login.err.busy")
registerErrorKey(account.ErrInvalidEmail, "signup.err.invalid_email")
registerErrorKey(account.ErrSamePassword, "profile.err.same_password")
// A key's state is also its CSS class, and a membership's scope is a
// word of the store; both are looked up by name.
registerKeys("profile.state.active", "profile.state.revoked", "profile.state.expired",
"profile.scope.org", "profile.scope.team", "profile.scope.project")
}
// SessionCookie is the name of the cookie a signed-in browser carries.
const SessionCookie = "llmgw_session"
// Accounts is what the pages need from the account service.
type Accounts interface {
SignUp(ctx context.Context, email, name, password string) error
SignIn(ctx context.Context, email, password string) (token, csrf string, err error)
Resolve(ctx context.Context, token string) (store.Session, store.User, error)
SignOut(ctx context.Context, token string) error
CheckCSRF(sess store.Session, submitted string) bool
SignupOpen() bool
ChangePassword(ctx context.Context, pw account.PasswordChanger,
u store.User, current, next string) error
OpenSession(ctx context.Context, userID string) (token, csrf string, err error)
}
// KeyOwner is the store half of the profile page.
type KeyOwner interface {
KeysForUser(ctx context.Context, userID string) ([]store.Key, error)
CreateUserKey(ctx context.Context, userID string, k store.Key) error
RevokeUserKey(ctx context.Context, userID, keyID string, at time.Time) error
Resolve(ctx context.Context, keyID string) (store.Effective, error)
LimitsFor(ctx context.Context, scope store.Scope, id string) (store.Limits, error)
// Where a person may make keys, and what applies there.
ProjectsFor(ctx context.Context, userID string) ([]store.ProjectPath, error)
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error)
ResolveFor(ctx context.Context, callerID, userID, projectID string) (store.Effective, error)
}
// AccountPages serves signup, sign-in and the profile.
type AccountPages struct {
accounts Accounts
keys KeyOwner
passwords account.PasswordChanger
version string
log *slog.Logger
// served, when set, names the models an endpoint is currently serving. The
// curl example on this page fills its model field from it: every other
// field in that snippet is real, so the one placeholder left in it was
// copied verbatim and sent as the literal string MODEL.
served func() []string
// modelFacts is what the catalog says of a model, for an editor.
modelFacts func(model string) ModelFacts
// hasMirror is set when /admin/mirror is registered, so the other admin
// pages can link to it and only then.
hasMirror bool
// hasQuotas is set when /admin/quotas is registered.
hasQuotas bool
// oidc is sign-in through an identity provider; nil when there is none.
oidc *oidcLogin
// audit records every change made through these pages; nil records none.
audit audit.Sink
// hasGuard is set when /admin/guard is registered, and guardLibrary
// with it: the policies in force, whose judges the mirror page serves.
hasGuard bool
// hasSettings is set when /admin/settings is registered.
hasSettings bool
// images says whether some model makes pictures; nil when the
// pictures page is not mounted.
images func() bool
meshes func() bool
guardLibrary func() guard.Config
// guardFor names the guardrail policies in force for someone, and
// whether they keep nothing; nil when there are none.
guardFor func(guard.Subject) (string, bool)
// capacity sets capacity quotas, from the quotas page; nil leaves them off.
// history is the metrics store, for what only it knows: the GPU time
// each owner held. Nil without one.
history History
capacity CapacityAdmin
// personalOff refuses personal keys; see SetPersonalOff.
personalOff bool
// personalDefaults is what personal use falls under by default.
personalDefaults *store.Limits
// orgs is set when /org is mounted, so the menu can offer it to managers.
orgs OrgViewer
// llamaVersion is the llama.cpp build nodes install, for judging whether a
// GGUF can be served before it is fetched or catalogued.
llamaVersion string
// priced says whether a budget can ever be spent against.
priced Priced
// registry browses a model registry, or is nil where there is no way out.
registry Registry
// kinds remembers what each held GGUF file is, by its address: a held
// version never changes, so it is read once.
kinds sync.Map
// catalog puts a mirrored model into service; fleet says whether anything
// could run it. Both nil where the mirror page is read-only.
catalog CatalogAdmin
fleet Source
// The stores behind the people page's three tabs — the tree and its
// people, the limits, the usage — each set when its part is mounted, so
// the page draws the parts that exist.
peopleStore AccountAdmin
quotaStore QuotaAdmin
usageStore UsageAdmin
usagePriced Priced
// resets, mailer and publicURL serve a forgotten password; see
// SetPasswordReset.
resets account.ResetStore
mailer Mailer
publicURL string
}
// ServedModels supplies the names the profile page's example should use.
//
// A setter rather than a constructor argument because only the router knows
// what is being served, and the page is useful without it — with no source the
// example falls back to a placeholder, which is what it always was.
func (p *AccountPages) ServedModels(f func() []string) { p.served = f }
// SetModelFacts supplies what an editor's configuration says of a model.
func (p *AccountPages) SetModelFacts(f func(model string) ModelFacts) { p.modelFacts = f }
// exampleModel picks the model the copy-pasteable example should name.
//
// A real model over a simulated one is not expressible here — this package does
// not know which is which — so it takes the first served name and relies on the
// router listing real models first.
func (p *AccountPages) exampleModel() string {
if p.served == nil {
return "<model-name>"
}
if names := p.served(); len(names) > 0 {
return names[0]
}
return "<model-name>"
}
// NewAccountPages builds the handlers.
func NewAccountPages(a Accounts, k KeyOwner, pw account.PasswordChanger,
version string, log *slog.Logger,
) *AccountPages {
if log == nil {
log = slog.Default()
}
return &AccountPages{accounts: a, keys: k, passwords: pw, version: version, log: log}
}
// Register mounts the pages. They are all browser surfaces and none of them
// takes an API key, so the caller must exempt these paths from key
// authentication.
func (p *AccountPages) Register(mux *http.ServeMux) {
if p.accounts.SignupOpen() {
mux.HandleFunc("GET /signup", p.signupForm)
// Each signup costs a password hash and a row: a few a minute from
// one address is a person, more is a script.
mux.HandleFunc("POST /signup", rationed(newRation(5, 10), p.signup))
}
mux.HandleFunc("GET /login", p.loginForm)
mux.HandleFunc("POST /login", rationed(newRation(30, 30), p.login))
mux.HandleFunc("POST /logout", p.logout)
p.registerReset(mux)
mux.HandleFunc("GET /profile", p.profile)
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
mux.HandleFunc("POST /profile/password", p.selfAudited(p.changePassword))
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
}
// --- signup ---
type signupData struct {
Email, Name, Error string
Done bool
MinPassword int
Version string
}
func (p *AccountPages) signupForm(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, r, "signup", signupData{MinPassword: account.MinPasswordLen, Version: p.version})
}
func (p *AccountPages) signup(w http.ResponseWriter, r *http.Request) {
noStore(w)
d := signupData{
Email: strings.TrimSpace(r.FormValue("email")),
Name: strings.TrimSpace(r.FormValue("name")),
MinPassword: account.MinPasswordLen,
Version: p.version,
}
l := langOf(r)
err := p.accounts.SignUp(r.Context(), d.Email, d.Name, r.FormValue("password"))
switch {
case err == nil:
// The same page whether or not the address was already taken. Anything
// else is an account enumerator that anyone can run.
d.Done = true
case errors.Is(err, account.ErrWeakPassword):
// Said here rather than registered: the sentence carries the length.
d.Error = l.T("signup.err.weak_password", account.MinPasswordLen)
case errors.Is(err, account.ErrInvalidEmail):
d.Error = l.Err(err)
default:
// Nothing of the database's words on the page; a reference that finds
// them in the log for whoever runs the gateway.
ref := errorRef()
p.log.Error("signup failed", "ref", ref, "err", err)
d.Error = l.T("signup.err.internal", ref)
}
render(w, r, "signup", d)
}
// --- sign in and out ---
// ssoLabel is what the sign-in button names, or "" without one.
func (p *AccountPages) ssoLabel(l *Lang) string {
if p.oidc == nil {
return ""
}
return or(p.oidc.cfg.Label, l.T("sso.default_label"))
}
type loginData struct {
Email, Error string
SignupOpen bool
// Forgot is whether /forgot is there to link to.
Forgot bool
Version string
// SSO is the identity provider's label, when sign-in through one is on.
SSO string
// CSRF is the form's token, matched by a cookie.
CSRF string
}
func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, r, "login", loginData{SSO: p.ssoLabel(langOf(r)), SignupOpen: p.accounts.SignupOpen(), Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)})
}
func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
noStore(w)
l := langOf(r)
email := strings.TrimSpace(r.FormValue("email"))
if !loginTokenOK(r) {
// Not this site's form: another one posting a visitor into an
// account of its choosing, or a form left open past its hour.
renderStatus(w, r, http.StatusBadRequest, "login", loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version,
Error: l.T("login.err.form_expired"), CSRF: setLoginToken(w, r)})
return
}
ctx := account.WithClient(r.Context(), clientAddr(r))
token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password"))
if err != nil {
d := loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)}
switch {
case errors.Is(err, account.ErrBadCredentials),
errors.Is(err, account.ErrNotApproved),
errors.Is(err, account.ErrSuspended),
errors.Is(err, account.ErrTooManyTries),
errors.Is(err, account.ErrBusy):
d.Error = l.Err(err)
default:
p.log.Error("sign-in failed", "err", err)
d.Error = l.T("login.err.try_again")
}
renderStatus(w, r, http.StatusUnauthorized, "login", d)
return
}
// Through the provider only, except an administrator's way in for when
// the provider itself is broken.
if p.oidc != nil && p.oidc.cfg.NoPasswords {
if _, u, err := p.accounts.Resolve(ctx, token); err != nil || !u.IsAdmin {
_ = p.accounts.SignOut(ctx, token)
renderStatus(w, r, http.StatusForbidden, "login", loginData{SSO: p.ssoLabel(l), Email: email,
SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r),
Error: l.T("login.err.passwords_off", p.ssoLabel(l))})
return
}
}
setSessionCookie(w, r, token)
setCSRFCookie(w, r, csrf)
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func (p *AccountPages) logout(w http.ResponseWriter, r *http.Request) {
noStore(w)
// Even signing out is a state change, so it needs its token: otherwise any
// page can sign a visitor out of this one.
sess, _, ok := p.session(r)
if ok && !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if c, err := r.Cookie(SessionCookie); err == nil {
if err := p.accounts.SignOut(r.Context(), c.Value); err != nil {
p.log.Warn("could not close a session", "err", err)
}
}
clearSessionCookie(w, r)
// What the pages kept in this browser goes with the session: the chat's
// conversation, htmx's copies of the pages visited. The chat keys its
// own by account as well, for a browser that ignores this header.
w.Header().Set("Clear-Site-Data", `"storage"`)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- profile ---
type limitRow struct{ Name, Value string }
// contextLimits is what applies to a person in one place they can spend.
type contextLimits struct {
Label string
Rows []limitRow
// Use is what the person spent here over the window, and Budgets how
// much is left of each budget that applies; both nil without usage.
Use *placeUse
Budgets []budgetBar
project string
chain []store.Limits
}
type profileData struct {
Nav Nav
// Contexts are where a key can be made: the person's projects, and
// themselves when personal use is on.
Contexts []ChatContext
// Where names each key's project by path; a personal key has none.
Where map[string]string
Memberships []store.Membership
LimitsBy []contextLimits
// Own is the person's use over the last thirty days; nil when it cannot
// be read.
Own *ownUse
User store.User
Keys []store.Key
Limits []limitRow
NewKey string
// Connect is an editor just connected: its key and configuration, shown
// once, like any new key.
Connect *connectView
// Tools and Models are what the connect form offers.
Tools []struct{ Key, Name string }
Models []string
Error string
PasswordChanged bool
PasswordError string
MinPassword int
CSRF string
BaseURL string
// ExampleModel is a model that is actually being served, so the example on
// the page can be copied and run as it stands.
ExampleModel string
Version string
Now string
}
func (p *AccountPages) profile(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
p.renderProfile(w, r, sess, user, "", "")
}
func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
l := langOf(r)
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
// Stored, and read by every tool that lists keys: not translated.
name = "untitled"
}
project := r.FormValue("context")
if project == personalValue {
if p.personalOff {
p.renderProfile(w, r, sess, user, "", l.T("profile.err.personal_off"))
return
}
project = ""
}
secret, k, err := store.NewKey(randomKeyID(), name, project)
if err != nil {
p.log.Error("could not mint a key", "err", err)
p.renderProfile(w, r, sess, user, "", l.T("profile.err.try_again"))
return
}
k.CreatedAt = time.Now()
if err := p.keys.CreateUserKey(r.Context(), user.ID, k); err != nil {
if errors.Is(err, store.ErrNotMember) {
p.renderProfile(w, r, sess, user, "", l.T("profile.err.not_member"))
return
}
p.log.Error("could not store a key", "err", err)
p.renderProfile(w, r, sess, user, "", l.T("profile.err.try_again"))
return
}
p.log.Info("a user created a key", "user", user.ID, "key", k.ID)
// Shown on this response rather than after a redirect: a redirect would
// have to carry the key in a URL or a flash, and both put it somewhere it
// does not belong.
p.renderProfile(w, r, sess, user, secret, "")
}
func (p *AccountPages) revokeKey(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Ownership is enforced in the statement, so a guessed key id from another
// account changes nothing.
err := p.keys.RevokeUserKey(r.Context(), user.ID, r.PathValue("id"), time.Now())
if err != nil && !errors.Is(err, store.ErrNotFound) {
p.log.Error("could not revoke a key", "err", err)
}
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
func (p *AccountPages) renderProfile(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, newKey, errMsg string,
) {
p.render(w, r, sess, user, profileData{NewKey: newKey, Error: errMsg})
}
func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, d profileData,
) {
d.User = user
d.Nav = p.navFor(r, user, "keys")
d.MinPassword = account.MinPasswordLen
d.BaseURL = baseURL(r)
d.ExampleModel = p.exampleModel()
d.Tools = editorTools
if p.served != nil {
d.Models = p.served()
}
d.Version = p.version
d.Now = time.Now().UTC().Format(time.RFC3339)
// The form token comes from the cookie, never from whatever the request
// happened to carry: echoing a submitted value back into the next form
// would let an attacker choose both halves of the comparison.
d.CSRF = csrfFor(r)
_ = sess
keys, err := p.keys.KeysForUser(r.Context(), user.ID)
if err != nil {
p.log.Error("could not list keys", "err", err)
}
d.Keys = keys
p.fillPlaces(r.Context(), langOf(r), user, &d)
render(w, r, "profile", d)
}
// personalValue is how a form names the person's own, project-less use.
const personalValue = "personal"
// SetGuardPolicies has the profile say which guardrail policies apply where.
func (p *AccountPages) SetGuardPolicies(f func(guard.Subject) (string, bool)) { p.guardFor = f }
// SetPersonalOff says personal keys are refused on this gateway.
func (p *AccountPages) SetPersonalOff(off bool) { p.personalOff = off }
// fillPlaces adds where the person can make keys, where their keys are, and
// what applies in each place, resolved the way a request is: the whole chain
// above a project, per-member limits, and the person's own.
func (p *AccountPages) fillPlaces(ctx context.Context, l *Lang, user store.User, d *profileData) {
d.Where = map[string]string{}
projects, err := p.keys.ProjectsFor(ctx, user.ID)
if err != nil {
p.log.Warn("could not list a person's projects", "err", err)
}
for _, pp := range projects {
d.Contexts = append(d.Contexts, ChatContext{Value: pp.ID, Label: pp.Path})
d.Where[pp.ID] = pp.Path
}
if !p.personalOff {
d.Contexts = append(d.Contexts, ChatContext{Value: personalValue, Label: l.T("profile.personal")})
}
if d.Memberships, err = p.keys.MembershipsOf(ctx, user.ID); err != nil {
p.log.Warn("could not list a person's memberships", "err", err)
}
for _, c := range d.Contexts {
project := c.Value
if project == personalValue {
project = ""
}
e, err := p.keys.ResolveFor(ctx, "session:"+user.ID, user.ID, project)
if err != nil {
continue
}
rows := limitRows(l, e)
if p.guardFor != nil {
names, keepNothing := p.guardFor(guard.Subject{UserID: user.ID, ProjectID: e.ProjectID,
TeamID: e.TeamID, OrgID: e.OrgID})
if names != "" {
v := strings.ReplaceAll(names, ",", ", ")
if keepNothing {
v = l.T("selfuse.limit.keep_nothing", v)
}
rows = append(rows, limitRow{l.T("selfuse.limit.guard"), v})
}
}
d.LimitsBy = append(d.LimitsBy, contextLimits{Label: c.Label, Rows: rows,
project: project, chain: e.Chain})
}
p.fillOwnUse(ctx, l, user, d)
}
func limitRows(l *Lang, e store.Effective) []limitRow {
var out []limitRow
if e.RPM != nil {
out = append(out, limitRow{l.T("selfuse.limit.rpm"), l.Int(int64(*e.RPM))})
}
if e.TPM != nil {
out = append(out, limitRow{l.T("selfuse.limit.tpm"), l.Int(int64(*e.TPM))})
}
if e.Budget != nil {
out = append(out, limitRow{l.T("selfuse.limit.budget"), budgetPer(l, l.Units(*e.Budget), e.BudgetPeriod)})
}
if e.AllowedModels != nil {
if len(e.AllowedModels) == 0 {
out = append(out, limitRow{l.T("selfuse.limit.models"), l.T("common.none")})
} else {
out = append(out, limitRow{l.T("selfuse.limit.models"), strings.Join(e.AllowedModels, ", ")})
}
}
return out
}
// budgetPer is a budget with the period it renews over: "50.00 per month".
func budgetPer(l *Lang, amount string, p store.Period) string {
switch p {
case store.PeriodDay:
return l.T("selfuse.limit.budget_day", amount)
case store.PeriodMonth:
return l.T("selfuse.limit.budget_month", amount)
case store.PeriodNone:
return l.T("selfuse.limit.budget_none", amount)
}
return l.T("selfuse.limit.budget_other", amount, string(p))
}
// --- helpers ---
func (p *AccountPages) session(r *http.Request) (store.Session, store.User, bool) {
c, err := r.Cookie(SessionCookie)
if err != nil {
return store.Session{}, store.User{}, false
}
sess, user, err := p.accounts.Resolve(r.Context(), c.Value)
if err != nil {
return store.Session{}, store.User{}, false
}
return sess, user, true
}
// csrfFor returns the token a form in this session must carry.
//
// The session stores only a hash, so the value itself has to travel with the
// browser. It rides the same cookie jar as the session under its own name,
// which is the double-submit pattern: an attacker's page can make the browser
// send our cookies but cannot read them to put one in a form field.
func csrfFor(r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil {
return c.Value
}
return ""
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, csrf string) {
// HttpOnly as well: the server renders it into forms, so no script needs to
// read it, and the attack this defends against works by making the browser
// send cookies it cannot read.
http.SetCookie(w, &http.Cookie{
Name: csrfCookie, Value: csrf, Path: "/",
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(store.SessionTTL.Seconds()),
})
}
func randomKeyID() string {
var buf [10]byte
_, _ = rand.Read(buf[:])
return "key-" + strings.ToLower(
base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
}
func itoa(v int) string { return strconv.Itoa(v) }
func units(micros int64) string {
return strconv.FormatFloat(float64(micros)/1e6, 'f', 2, 64)
}
const csrfCookie = "llmgw_csrf"
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie, Value: token, Path: "/",
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(store.SessionTTL.Seconds()),
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
for _, name := range []string{SessionCookie, csrfCookie} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/",
HttpOnly: name == SessionCookie, Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
}
// isHTTPS decides whether to mark cookies Secure.
//
// Behind a reverse proxy this process sees plain HTTP, so the forwarded header
// is the only thing that knows. Getting it wrong in the safe direction — marking
// Secure when the connection is plain — would break sign-in over http://, which
// is why it is not simply hardcoded.
func isHTTPS(r *http.Request) bool {
return clientip.Proto(r) == "https"
}
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
}
func (p *AccountPages) changePassword(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
err := p.accounts.ChangePassword(r.Context(), p.passwords, user,
r.FormValue("current"), r.FormValue("new"))
if err != nil {
l := langOf(r)
d := profileData{}
switch {
case errors.Is(err, account.ErrBadCredentials):
d.PasswordError = l.T("profile.err.wrong_password")
case errors.Is(err, account.ErrWeakPassword):
d.PasswordError = l.T("signup.err.weak_password", account.MinPasswordLen)
case errors.Is(err, account.ErrSamePassword),
errors.Is(err, account.ErrTooManyTries):
d.PasswordError = l.Err(err)
default:
p.log.Error("could not change a password", "err", err)
d.PasswordError = l.T("profile.err.try_again")
}
p.render(w, r, sess, user, d)
return
}
// The change closed every session including this one, which is the point.
// Open a new one for the browser in front of us, so the person who just
// proved their password is not thrown back to the sign-in page.
token, csrf, err := p.accounts.OpenSession(r.Context(), user.ID)
if err != nil {
p.log.Error("could not reopen a session", "err", err)
clearSessionCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
setSessionCookie(w, r, token)
setCSRFCookie(w, r, csrf)
// The new CSRF cookie is set on this response, so the forms rendered now
// must carry the same value rather than the one the request arrived with.
newSess, newUser, rerr := p.accounts.Resolve(r.Context(), token)
if rerr != nil {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
d := profileData{PasswordChanged: true}
d.CSRF = csrf
p.renderWithCSRF(w, r, newSess, newUser, d)
}
// renderWithCSRF is render for the one case where the token in the cookie jar
// is not yet the one the browser has: the response that sets a new one.
func (p *AccountPages) renderWithCSRF(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, d profileData,
) {
csrf := d.CSRF
d.User = user
d.MinPassword = account.MinPasswordLen
d.BaseURL = baseURL(r)
d.ExampleModel = p.exampleModel()
d.Version = p.version
d.Now = time.Now().UTC().Format(time.RFC3339)
d.CSRF = csrf
// The menu's sign-out form too: it is the first form on the page.
d.Nav = p.navFor(r, user, "keys")
d.Nav.CSRF = csrf
_ = sess
keys, err := p.keys.KeysForUser(r.Context(), user.ID)
if err != nil {
p.log.Error("could not list keys", "err", err)
}
d.Keys = keys
p.fillPlaces(r.Context(), langOf(r), user, &d)
render(w, r, "profile", d)
}
// SetLlamaCppVersion tells the pages which llama.cpp build the nodes run.
func (p *AccountPages) SetLlamaCppVersion(v string) { p.llamaVersion = v }
// errorRef is a short reference for an error a page does not explain, logged
// beside the error itself.
func errorRef() string {
var b [4]byte
_, _ = rand.Read(b[:])
return "err-" + hex.EncodeToString(b[:])
}
// SetHistory gives the pages the metrics store.
func (p *AccountPages) SetHistory(h History) { p.history = h }