C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
862 lines
30 KiB
Go
862 lines
30 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"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"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/vscodeext"
|
|
)
|
|
|
|
// 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
|
|
// directory is the label of the directory that checks passwords
|
|
// (auth.ldap); empty when there is none.
|
|
directory string
|
|
// 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
|
|
// hasMCP is set when /admin/mcp is registered.
|
|
hasMCP bool
|
|
// images says whether some model makes pictures; nil when the
|
|
// pictures page is not mounted.
|
|
// licenceChoices and licencePrefs offer the choice of licences on the
|
|
// profile page; nil offers none.
|
|
licenceChoices func() LicenceChoices
|
|
licencePrefs LicencePrefs
|
|
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
|
|
// orgEditor changes an organization from its page, for its admin.
|
|
orgEditor OrgEditor
|
|
// 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/licences", p.selfAudited(p.setLicences))
|
|
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
|
|
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
|
|
mux.HandleFunc("GET /setup/vscode/"+vscodeext.FileName, p.vsix)
|
|
}
|
|
|
|
// --- 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.
|
|
// SetDirectory says passwords are also checked by a directory, under this
|
|
// label, so the sign-in form takes a username as well as a mail.
|
|
func (p *AccountPages) SetDirectory(label string) { p.directory = label }
|
|
|
|
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
|
|
// Directory is the directory's label when passwords are checked by one
|
|
// (auth.ldap): the field then takes a username too, not only a mail.
|
|
Directory string
|
|
// CSRF is the form's token, matched by a cookie.
|
|
CSRF string
|
|
}
|
|
|
|
// loginPage is what every rendering of the sign-in form carries.
|
|
func (p *AccountPages) loginPage(w http.ResponseWriter, r *http.Request, l *Lang, email string) loginData {
|
|
return loginData{SSO: p.ssoLabel(l), Directory: p.directory, Email: email, SignupOpen: p.accounts.SignupOpen(),
|
|
Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)}
|
|
}
|
|
|
|
func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
render(w, r, "login", p.loginPage(w, r, langOf(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.
|
|
d := p.loginPage(w, r, l, email)
|
|
d.Error = l.T("login.err.form_expired")
|
|
renderStatus(w, r, http.StatusBadRequest, "login", d)
|
|
return
|
|
}
|
|
|
|
ctx := account.WithClient(r.Context(), clientAddr(r))
|
|
token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password"))
|
|
if err != nil {
|
|
d := p.loginPage(w, r, l, email)
|
|
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)
|
|
d := p.loginPage(w, r, l, email)
|
|
d.Error = l.T("login.err.passwords_off", p.ssoLabel(l))
|
|
renderStatus(w, r, http.StatusForbidden, "login", d)
|
|
return
|
|
}
|
|
}
|
|
|
|
setSessionCookie(w, r, token)
|
|
setCSRFCookie(w, r, csrf)
|
|
http.Redirect(w, r, afterSignIn(w, r), 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")) {
|
|
badForm(w, r)
|
|
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; Tool is the one it
|
|
// opens on, and State what VS Code's sign-in asked to have carried back.
|
|
Tools []struct{ Key, Name string }
|
|
Models []string
|
|
Tool string
|
|
State string
|
|
Error string
|
|
|
|
// Licences is the choice of licences not to use; nil when not offered.
|
|
Licences *LicenceChoices
|
|
LicencesSaved bool
|
|
LicenceError 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 {
|
|
// Back here after signing in, with what was asked: VS Code's
|
|
// sign-in carries its state in the query, and lost it otherwise.
|
|
if r.URL.RawQuery != "" {
|
|
http.SetCookie(w, &http.Cookie{Name: afterSignInCookie, Value: url.QueryEscape(r.URL.RawQuery),
|
|
Path: "/", HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, MaxAge: 600})
|
|
}
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
// VS Code's sign-in opens /profile?tool=vscode&state=…: the form opens
|
|
// on that tool, with the state ready to go back in the link.
|
|
q := r.URL.Query()
|
|
d := profileData{State: signInState(q.Get("state"))}
|
|
for _, t := range editorTools {
|
|
if t.Key == q.Get("tool") {
|
|
d.Tool = t.Key
|
|
}
|
|
}
|
|
p.render(w, r, sess, user, d)
|
|
}
|
|
|
|
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")) {
|
|
badForm(w, r)
|
|
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")) {
|
|
badForm(w, r)
|
|
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)
|
|
p.fillLicences(r.Context(), user.ID, &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")) {
|
|
badForm(w, r)
|
|
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 }
|
|
|
|
// afterSignInCookie holds, for ten minutes, the query /profile was asked
|
|
// with before signing in.
|
|
const afterSignInCookie = "llmgw_after"
|
|
|
|
// afterSignIn is where a sign-in goes: /profile, with the query it was
|
|
// first asked with — only ever /profile, so the cookie cannot send anyone
|
|
// elsewhere.
|
|
func afterSignIn(w http.ResponseWriter, r *http.Request) string {
|
|
c, err := r.Cookie(afterSignInCookie)
|
|
if err != nil {
|
|
return "/profile"
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: afterSignInCookie, Path: "/", MaxAge: -1})
|
|
q, err := url.QueryUnescape(c.Value)
|
|
if err != nil {
|
|
return "/profile"
|
|
}
|
|
if _, err := url.ParseQuery(q); err != nil || strings.ContainsAny(q, "\r\n") {
|
|
return "/profile"
|
|
}
|
|
return "/profile?" + q
|
|
}
|