// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package users is a file-backed set of admin users stored as a TOML // [[users]] array. The first account is created by the admin first-run // wizard, never by an implicit identity in the configuration. package users import ( "errors" "fmt" "log/slog" "os" "slices" "sync" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/volumen/internal/i18n" "sourcedock.dev/petrbalvin/volumen/internal/identifiers" "sourcedock.dev/petrbalvin/volumen/internal/password" "sourcedock.dev/petrbalvin/volumen/internal/tomlfile" "sourcedock.dev/petrbalvin/volumen/internal/web" ) // Roles accepted for user accounts. var Roles = []string{"admin", "author"} // The reasons a user change can be refused, so a caller can report which // one happened rather than guessing from a nil result. var ( // ErrNameTaken is returned when another account already has the name. ErrNameTaken = errors.New("that username is taken") // ErrEmptyName is returned when the name is blank. ErrEmptyName = errors.New("the username must not be empty") // ErrNoSuchUser is returned when the account is not in the file. ErrNoSuchUser = errors.New("no such user") // ErrLastAdmin is returned when the change would remove the last // admin, or the last account. ErrLastAdmin = errors.New("the last admin cannot be removed or demoted") // ErrUsersExist is returned by AddFirst when the file already // holds accounts: the first-run wizard may create exactly one. ErrUsersExist = errors.New("accounts already exist") ) // DefaultRole is assigned when an unknown role is requested. const DefaultRole = "author" // User is one admin/author account. type User struct { Username string PasswordHash string Role string Name string FediverseCreator string // Orcid is the account's ORCID iD, the author identity that // pre-fills a post's orcid field and names the person in citations. Orcid string Photo string // Language is the admin interface language the account picked // ("en" or "cs"); empty inherits the site language. Language string // Theme is the admin colour scheme the account picked; empty // inherits the default scheme. Theme string // TotpSecret is the account's authenticator secret, base32; empty // means the second factor is off. TotpStep is the highest time // step already answered, the replay floor. TotpSecret string TotpStep int64 // Recovery holds the SHA-256 digests of the one-time codes that // open the account when the authenticator is lost. Recovery []string } // Users is the file-backed collection of admin/authors. Read-modify- // write transactions are serialised with an internal lock; an mtime // snapshot of the file invalidates the cache on out-of-band edits. type Users struct { path string mu sync.Mutex cached []*User snapshot fileSnapshot haveCache bool lock sync.Mutex } type fileSnapshot struct { present bool mtime int64 size int64 } // New opens the users file. func New(path string) *Users { return &Users{path: path} } // All returns every account. A missing file holds no accounts; a file // that exists but cannot be read or parsed yields no accounts either, // which fails closed: the server never serves an identity the operator // cannot account for. func (u *Users) All() []*User { u.mu.Lock() defer u.mu.Unlock() loaded, err := u.loadLocked() if err != nil { slog.Error("users: cannot read the users file", "path", u.path, "error", err) return nil } return cloneList(loaded) } // Any reports whether at least one user exists. func (u *Users) Any() bool { return len(u.All()) > 0 } // Find returns a copy of the user with the given username, or nil. func (u *Users) Find(username string) *User { for _, user := range u.All() { if user.Username == username { return user } } return nil } // Authenticate verifies the password and returns the user on success. // An unknown username is verified against a dummy hash as well, so the // response time does not reveal whether the account exists. func (u *Users) Authenticate(username, secret string) *User { user := u.Find(username) if user == nil { password.Verify(secret, password.Dummy()) return nil } if password.Verify(secret, user.PasswordHash) { return user } return nil } // Add creates a user. It fails with ErrNameTaken, ErrEmptyName or the // error the write returned, so a caller can say which of the three // happened rather than repeating a guess. func (u *Users) Add(username, secret, role string) (*User, error) { u.lock.Lock() defer u.lock.Unlock() users, err := u.reload() if err != nil { slog.Error("users: refusing to add, the users file is unreadable", "path", u.path, "error", err) return nil, err } if username == "" { return nil, ErrEmptyName } if findIn(users, username) != nil { return nil, ErrNameTaken } if !slices.Contains(Roles, role) { role = DefaultRole } hash, err := password.Hash(secret) if err != nil { slog.Warn("users: cannot hash password", "error", err) return nil, err } user := &User{Username: username, PasswordHash: hash, Role: role} if err := u.persist(append(cloneList(users), user)); err != nil { return nil, err } return user, nil } // AddFirst creates the very first admin account in one write: the // language and the theme the first-run wizard picked are stored with // it, so the founding record never exists half-set-up. It is refused // with ErrUsersExist once any account exists, and the check sits under // the store lock, so two claims arriving at once cannot both open an // identity: one wins, the other is refused and goes to sign in. func (u *Users) AddFirst(username, secret, language, theme, name string) (*User, error) { u.lock.Lock() defer u.lock.Unlock() users, err := u.reload() if err != nil { slog.Error("users: refusing the first account, the users file is unreadable", "path", u.path, "error", err) return nil, err } if len(users) > 0 { return nil, ErrUsersExist } if username == "" { return nil, ErrEmptyName } if findIn(users, username) != nil { return nil, ErrNameTaken } hash, err := password.Hash(secret) if err != nil { slog.Warn("users: cannot hash password", "error", err) return nil, err } user := &User{ Username: username, PasswordHash: hash, Role: "admin", Language: language, Theme: theme, Name: name, } if err := u.persist([]*User{user}); err != nil { return nil, err } return user, nil } // UpdateName sets the display name (empty clears it). func (u *Users) UpdateName(username, name string) (*User, error) { return u.mutate(username, func(user *User) { if name == "" { user.Name = "" return } user.Name = name }) } // UpdateFediverseCreator sets the fediverse handle (empty clears it). func (u *Users) UpdateFediverseCreator(username, value string) (*User, error) { return u.mutate(username, func(user *User) { if value == "" { user.FediverseCreator = "" return } user.FediverseCreator = value }) } // UpdatePhoto sets the profile photo URL (empty clears it). func (u *Users) UpdatePhoto(username, photo string) (*User, error) { return u.mutate(username, func(user *User) { user.Photo = photo }) } // UpdateOrcid sets the account's ORCID iD (empty clears it). Only a // well-formed iD with a correct check digit is accepted; anything else // is refused with an error rather than stored broken. func (u *Users) UpdateOrcid(username, value string) (*User, error) { value = identifiers.NormalizeORCID(value) if value != "" && !identifiers.ValidORCID(value) { return nil, fmt.Errorf("invalid ORCID %q", value) } return u.mutate(username, func(user *User) { user.Orcid = value }) } // UpdateLanguage stores the admin interface language the account // picked. Only the shipped languages are accepted; anything else is // refused with an error rather than silently resetting to the default. func (u *Users) UpdateLanguage(username, lang string) (*User, error) { if !i18n.Valid(lang) { return nil, fmt.Errorf("unsupported language %q", lang) } return u.mutate(username, func(user *User) { user.Language = lang }) } // UpdateTheme stores the admin colour scheme the account picked. Only // the shipped schemes are accepted; anything else is refused with an // error rather than silently resetting to the default. func (u *Users) UpdateTheme(username, theme string) (*User, error) { if !web.ValidTheme(theme) { return nil, fmt.Errorf("unsupported colour scheme %q", theme) } return u.mutate(username, func(user *User) { user.Theme = theme }) } // UpdatePassword re-hashes and stores a new password. func (u *Users) UpdatePassword(username, secret string) (*User, error) { hash, err := password.Hash(secret) if err != nil { slog.Warn("users: cannot hash password", "error", err) return nil, err } return u.mutate(username, func(user *User) { user.PasswordHash = hash }) } // Rename changes the username. It fails with ErrEmptyName, ErrNameTaken // or ErrNoSuchUser when the new name is blank, taken or the current // account is missing, and with the write error when the file cannot be // written. func (u *Users) Rename(currentName, newName string) (*User, error) { if newName == "" { return nil, ErrEmptyName } u.lock.Lock() defer u.lock.Unlock() users, err := u.reload() if err != nil { slog.Error("users: refusing to rename, the users file is unreadable", "path", u.path, "error", err) return nil, err } if findIn(users, newName) != nil { return nil, ErrNameTaken } users = cloneList(users) user := findIn(users, currentName) if user == nil { return nil, ErrNoSuchUser } user.Username = newName if err := u.persist(users); err != nil { return nil, err } return user, nil } // cloneList deep-copies the user list so mutations never write to // objects a concurrent reader may hold. func cloneList(users []*User) []*User { out := make([]*User, len(users)) for i, user := range users { clone := *user out[i] = &clone } return out } // SetRole changes the role, refusing to demote the last admin. func (u *Users) SetRole(username, role string) (*User, error) { if !slices.Contains(Roles, role) { return nil, fmt.Errorf("unknown role %q", role) } u.lock.Lock() defer u.lock.Unlock() users, err := u.reload() if err != nil { slog.Error("users: refusing to change a role, the users file is unreadable", "path", u.path, "error", err) return nil, err } users = cloneList(users) user := findIn(users, username) if user == nil { return nil, ErrNoSuchUser } if user.Role == "admin" && role != "admin" && adminCount(users) <= 1 { return nil, ErrLastAdmin } user.Role = role if err := u.persist(users); err != nil { return nil, err } return user, nil } // Delete removes a user, refusing to remove the last user or the last // admin. Returns the removed username, or "". func (u *Users) Delete(username string) (string, error) { u.lock.Lock() defer u.lock.Unlock() users, err := u.reload() if err != nil { slog.Error("users: refusing to delete, the users file is unreadable", "path", u.path, "error", err) return "", err } users = cloneList(users) target := findIn(users, username) if target == nil { return "", ErrNoSuchUser } if len(users) <= 1 || (target.Role == "admin" && adminCount(users) <= 1) { return "", ErrLastAdmin } remaining := make([]*User, 0, len(users)-1) for _, user := range users { if user.Username != username { remaining = append(remaining, user) } } if err := u.persist(remaining); err != nil { return "", err } return username, nil } func (u *Users) mutate(username string, apply func(*User)) (*User, error) { u.lock.Lock() defer u.lock.Unlock() users, err := u.reload() if err != nil { slog.Error("users: refusing to update, the users file is unreadable", "path", u.path, "error", err) return nil, err } users = cloneList(users) user := findIn(users, username) if user == nil { return nil, ErrNoSuchUser } apply(user) if err := u.persist(users); err != nil { return nil, err } return user, nil } func findIn(users []*User, username string) *User { for _, user := range users { if user.Username == username { return user } } return nil } func adminCount(users []*User) int { count := 0 for _, user := range users { if user.Role == "admin" { count++ } } return count } // Health reports why the users file cannot be read, or nil when it is // fine or absent. A diagnostic uses it to say that accounts are // unreachable rather than reporting a count of zero. func (u *Users) Health() error { u.mu.Lock() defer u.mu.Unlock() if _, err := u.loadLocked(); err != nil { return err } return nil } // Invalidate drops the cache so the next read re-reads the file. func (u *Users) Invalidate() { u.mu.Lock() defer u.mu.Unlock() u.cached = nil u.snapshot = fileSnapshot{} u.haveCache = false } // reload re-reads the file under u.mu, dropping the cache first so the // writer works from what is on disk right now. The caller must hold // u.lock; taking u.mu inside is the same order persist uses, so the two // locks never invert. func (u *Users) reload() ([]*User, error) { u.mu.Lock() defer u.mu.Unlock() u.cached = nil u.snapshot = fileSnapshot{} u.haveCache = false return u.loadLocked() } // loadLocked returns the cached account list, rebuilding it when the // file changed. It returns an error only when the file exists and // cannot be read or parsed; a missing file is not an error. The caller // must hold u.mu. func (u *Users) loadLocked() ([]*User, error) { snapshot := u.buildSnapshot() if u.haveCache && snapshot == u.snapshot { return u.cached, nil } users, err := u.readFile() if err != nil { return nil, err } u.snapshot = snapshot u.cached = users u.haveCache = true return users, nil } func (u *Users) buildSnapshot() fileSnapshot { info, err := os.Stat(u.path) if err != nil { return fileSnapshot{} } return fileSnapshot{present: true, mtime: info.ModTime().UnixNano(), size: info.Size()} } // readFile parses the users file. A missing file yields no users and no // error; an unreadable or unparsable file yields an error, which the // caller must surface rather than treat as "no users". func (u *Users) readFile() ([]*User, error) { raw, err := os.ReadFile(u.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, fmt.Errorf("read %s: %w", u.path, err) } data, err := interpres.ParseMap(raw) if err != nil { return nil, fmt.Errorf("parse %s: %w", u.path, err) } var result []*User for _, entry := range tomlfile.Tables(data["users"]) { username := tomlfile.String(entry["username"]) hash := tomlfile.String(entry["password_hash"]) if username == "" { continue } role := tomlfile.String(entry["role"]) if role == "" { role = DefaultRole } result = append(result, &User{ Username: username, PasswordHash: hash, Role: role, Name: tomlfile.String(entry["name"]), FediverseCreator: tomlfile.String(entry["fediverse_creator"]), Orcid: tomlfile.String(entry["orcid"]), Photo: tomlfile.String(entry["photo"]), Language: tomlfile.String(entry["language"]), Theme: tomlfile.String(entry["theme"]), TotpSecret: tomlfile.String(entry["totp_secret"]), TotpStep: tomlfile.Int64(entry["totp_step"], 0), Recovery: readTotpRecovery(entry), }) } for _, user := range result { if password.NeedsRehash(user.PasswordHash) { slog.Warn("users: stored hash uses parameters the login rejects; the password must be reset out of band", "username", user.Username) } } return result, nil } func (u *Users) persist(users []*User) error { entries := make([]map[string]any, 0, len(users)) for _, user := range users { entry := map[string]any{ "username": user.Username, "password_hash": user.PasswordHash, "role": user.Role, } if user.Name != "" { entry["name"] = user.Name } if user.FediverseCreator != "" { entry["fediverse_creator"] = user.FediverseCreator } if user.Orcid != "" { entry["orcid"] = user.Orcid } if user.Photo != "" { entry["photo"] = user.Photo } if user.Language != "" { entry["language"] = user.Language } if user.Theme != "" { entry["theme"] = user.Theme } if user.TotpSecret != "" { entry["totp_secret"] = user.TotpSecret entry["totp_step"] = user.TotpStep } if len(user.Recovery) > 0 { recovery := make([]any, len(user.Recovery)) for i, hash := range user.Recovery { recovery[i] = hash } entry["recovery"] = recovery } entries = append(entries, entry) } if err := tomlfile.Write(u.path, "users", entries); err != nil { slog.Error("users: cannot persist", "path", u.path, "error", err) return err } u.mu.Lock() u.cached = nil u.snapshot = fileSnapshot{} u.haveCache = false u.mu.Unlock() return nil }