Files
ReactorcoremeltdownandClaude Opus 5.5 4ffc434b1b Keep server data in an encrypted database, and harden every part
Storage. The server keeps its data in SQLite (a file in the data
directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or
-database). Every row, the KDBX versions included, is sealed with
AES-256-GCM under a key derived from the master key and named by an HMAC.
The master key is required to start and a different one is refused. A data
directory from 0.10 or earlier is imported on first start. Several servers
may share one PostgreSQL database. Go goes to 1.26 for the pure-Go
drivers (modernc.org/sqlite, jackc/pgx).

Hardening, after an audit of the server, both clients and the deployment:
- Single sign-on approves a device only after an explicit Approve in the
  browser that started it, bound by a cookie and PKCE; identities are never
  linked by username, by verified email only when allowed, never to admins.
- Sign-in attempts are limited per address and per account, passwords are
  checked outside the store lock with uniform timing, one-time codes work
  once, and starting two-factor needs the password.
- Merge vaults bound KDF parameters, payload size and XML shape, check
  pushes before storing them, and run at most two merges at once. A version
  is written in the same transaction as its metadata.
- Tokens in URLs only on the event stream, which ends on revocation; CSP,
  frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases
  without access; new trusted_proxies.
- Clients open only the server's approval page, keep case-variant and
  retired file names apart, never overwrite a save made during a pull,
  require https except locally, send the stream token in a header, bound
  their reads and escape notifications and terminal output.
- The .deb creates the master key, systemd is sandboxed further, nginx sets
  modern TLS and HSTS, and CI deploys from main only.

Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:38:32 +01:00

849 lines
24 KiB
Go

package server
import (
"errors"
"net/http"
"sort"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
)
var errForbidden = errors.New("forbidden")
// Every administrative change in this file is a function of the state and the
// caller; the JSON handlers below and the HTML forms in webadmin.go are thin
// wrappers over them. The API and the web interface have to agree about who
// may do what, and the only durable way to make them agree is for both to
// call the same function.
// denyAuth answers a request that failed to re-prove its credentials. The
// cases stay distinct so a client knows to prompt for the factor it is
// missing rather than reporting a flat refusal.
func denyAuth(w http.ResponseWriter, err error) {
if writeThrottled(w, err) {
return
}
switch {
case errors.Is(err, errNoDatabase):
fail(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "your one-time code is required for this")
case errors.Is(err, errReauth):
fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password is required for this")
case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials",
"your own account password or one-time code is wrong")
default:
deny(w, err)
}
}
// deny maps a domain error onto a response.
func deny(w http.ResponseWriter, err error) {
switch {
case err == nil:
return
case errors.Is(err, errNoDatabase):
fail(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, errForbidden):
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
default:
fail(w, http.StatusBadRequest, "bad_request", err.Error())
}
}
// ---- visibility ----
// resolveUser finds a user by id or by username, trying each reference in
// turn, so a caller may pass whichever of the two it has.
func resolveUser(st *store.State, refs ...string) *store.User {
for _, ref := range refs {
if ref == "" {
continue
}
if u := st.Users[ref]; u != nil {
return u
}
if u := st.UserByName(ref); u != nil {
return u
}
}
return nil
}
// visibleGroups is the set of groups the user may know about: the ones they
// administer, the ones whose grants reach them, and everything below a group
// they administer. A server administrator sees the whole tree.
func visibleGroups(st *store.State, u *store.User) map[string]bool {
out := map[string]bool{}
reach := st.EffectiveGroups(u.ID)
for id := range st.Groups {
if u.ServerAdmin || reach[id] || st.IsGroupAdmin(u.ID, id) {
out[id] = true
continue
}
for _, anc := range st.Ancestors(id) {
if st.IsGroupAdmin(u.ID, anc) {
out[id] = true
break
}
}
}
return out
}
// sharesAdministeredGroup reports whether admin administers any group the
// target belongs to, which is what makes the target visible to them.
func sharesAdministeredGroup(st *store.State, adminID, targetID string) bool {
for _, m := range st.Memberships {
if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) {
return true
}
}
return false
}
// visibleUsers lists the accounts the caller may see, by username.
func visibleUsers(st *store.State, u *store.User) []*store.User {
out := []*store.User{}
for id, target := range st.Users {
if u.ServerAdmin || id == u.ID || sharesAdministeredGroup(st, u.ID, id) {
out = append(out, target)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// ---- groups and membership ----
func createGroup(st *store.State, u *store.User, name, parentID string) (*store.Group, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name is required")
}
if parentID == "" {
// A group with no parent is a new root, which reorganizes the whole
// server, so only a server administrator may make one.
if !u.ServerAdmin {
return nil, errors.New("pick a parent group")
}
} else {
if st.Groups[parentID] == nil {
return nil, errors.New("unknown parent group")
}
if !st.IsGroupAdmin(u.ID, parentID) {
return nil, errForbidden
}
}
g := &store.Group{ID: secret.ID(), Name: name, ParentID: parentID, CreatedAt: time.Now()}
st.Groups[g.ID] = g
return g, nil
}
func deleteGroup(st *store.State, u *store.User, id string) error {
if st.Groups[id] == nil {
return errors.New("no such group")
}
if !st.IsGroupAdmin(u.ID, id) {
return errForbidden
}
if len(st.Descendants(id)) > 1 {
return errors.New("the group still has child groups")
}
for _, db := range st.Databases {
if db.OwnerGroupID == id {
return errors.New("the group still owns databases")
}
}
for _, m := range st.Memberships {
if m.GroupID == id {
return errors.New("the group still has members")
}
}
delete(st.Groups, id)
st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id })
return nil
}
// setMember adds a user to a group, or changes the role they already have.
func setMember(st *store.State, u *store.User, groupID string, role store.Role, refs ...string) error {
if st.Groups[groupID] == nil {
return errors.New("no such group")
}
if !st.IsGroupAdmin(u.ID, groupID) {
return errForbidden
}
target := resolveUser(st, refs...)
if target == nil {
return errors.New("no such user")
}
if role == "" {
role = store.RoleMember
}
if !role.Valid() {
return errors.New("role must be member or admin")
}
for i := range st.Memberships {
if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID {
st.Memberships[i].Role = role
return nil
}
}
st.Memberships = append(st.Memberships,
store.Membership{UserID: target.ID, GroupID: groupID, Role: role})
return nil
}
func removeMember(st *store.State, u *store.User, groupID string, refs ...string) error {
if st.Groups[groupID] == nil {
return errors.New("no such group")
}
if !st.IsGroupAdmin(u.ID, groupID) {
return errForbidden
}
target := resolveUser(st, refs...)
if target == nil {
return errors.New("no such user")
}
out := st.Memberships[:0]
for _, m := range st.Memberships {
if m.UserID == target.ID && m.GroupID == groupID {
continue
}
out = append(out, m)
}
st.Memberships = out
return nil
}
// ---- grants ----
// adminsDatabase checks that u administers a database. One without any
// access cannot tell a database that is not theirs from one that does not
// exist; one who can see it is told it is not theirs to change.
func adminsDatabase(st *store.State, u *store.User, dbID string) error {
perm := st.DatabasePerm(u.ID, dbID)
switch {
case st.Databases[dbID] == nil || perm == store.PermNone:
return errNoDatabase
case perm != store.PermAdmin:
return errForbidden
}
return nil
}
func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error {
if err := adminsDatabase(st, u, dbID); err != nil {
return err
}
if st.Groups[groupID] == nil {
return errors.New("no such group")
}
if !perm.Valid() {
return errors.New("perm must be read, write or admin")
}
for i := range st.Grants {
if st.Grants[i].GroupID == groupID && st.Grants[i].DatabaseID == dbID {
st.Grants[i].Perm = perm
return nil
}
}
st.Grants = append(st.Grants, store.Grant{GroupID: groupID, DatabaseID: dbID, Perm: perm})
return nil
}
func removeGrant(st *store.State, u *store.User, groupID, dbID string) error {
if err := adminsDatabase(st, u, dbID); err != nil {
return err
}
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
return g.GroupID != groupID || g.DatabaseID != dbID
})
return nil
}
// ---- invites ----
// defaultInviteTTLHours is how long an invite lasts when nobody says.
const defaultInviteTTLHours = 168
func createInvite(st *store.State, u *store.User, groupID string, role store.Role,
ttlHours, maxUses int) (*store.Invite, error) {
if role == "" {
role = store.RoleMember
}
if !role.Valid() {
return nil, errors.New("role must be member or admin")
}
if groupID == "" {
// An invite into no group creates an account with no access at all,
// which is only ever useful to a server administrator.
if !u.ServerAdmin {
return nil, errors.New("pick the group the new account joins")
}
} else {
if st.Groups[groupID] == nil {
return nil, errors.New("unknown group")
}
if !st.IsGroupAdmin(u.ID, groupID) {
return nil, errForbidden
}
}
if ttlHours <= 0 {
ttlHours = defaultInviteTTLHours
}
if maxUses == 0 {
maxUses = 1
}
inv := &store.Invite{
Token: secret.Token(), GroupID: groupID, Role: role, CreatedBy: u.ID,
CreatedAt: time.Now(), MaxUses: maxUses,
ExpiresAt: time.Now().Add(time.Duration(ttlHours) * time.Hour),
}
st.Invites[inv.Token] = inv
return inv, nil
}
// mayManageInvite decides both who sees an invite and who may revoke it: the
// person who made it, an administrator of the group it leads into, and any
// server administrator.
func mayManageInvite(st *store.State, u *store.User, inv *store.Invite) bool {
return u.ServerAdmin || inv.CreatedBy == u.ID ||
(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID))
}
func revokeInvite(st *store.State, u *store.User, token string) error {
inv := st.Invites[token]
if inv == nil {
return errors.New("no such invite")
}
if !mayManageInvite(st, u, inv) {
return errForbidden
}
delete(st.Invites, token)
return nil
}
// ---- accounts ----
// accountAction is a change to an account rather than to its access.
type accountAction string
const (
accountDisable accountAction = "disable"
accountEnable accountAction = "enable"
accountPromote accountAction = "promote"
accountDemote accountAction = "demote"
accountClearTOTP accountAction = "clear-totp"
accountSignOut accountAction = "sign-out"
accountDelete accountAction = "delete"
)
// adminAccount applies an account change. Only a server administrator may:
// a group administrator decides who reaches which vault, which is a matter
// of access, while disabling an account, clearing somebody's second factor
// or deleting them outright reaches beyond the group they happen to share.
//
// The caller's own account is protected from the three changes that would
// lock them out, because a server with no reachable administrator can only
// be repaired from a shell on the host.
func adminAccount(st *store.State, u *store.User, ref string, act accountAction) (*store.User, error) {
if !u.ServerAdmin {
return nil, errForbidden
}
target := resolveUser(st, ref)
if target == nil {
return nil, errors.New("no such user")
}
if target.ID == u.ID {
switch act {
case accountDisable, accountDemote, accountDelete:
return nil, errors.New("that would lock you out, so it is refused on your own account")
}
}
switch act {
case accountDisable:
target.Disabled = true
revokeSessions(st, target.ID)
case accountEnable:
target.Disabled = false
case accountPromote:
target.ServerAdmin = true
case accountDemote:
target.ServerAdmin = false
case accountClearTOTP:
target.TOTPSecret, target.TOTPConfirmed = "", false
case accountSignOut:
// For a lost phone or laptop: every device must sign in again,
// and the account stays usable.
revokeSessions(st, target.ID)
case accountDelete:
delete(st.Users, target.ID)
revokeSessions(st, target.ID)
out := st.Memberships[:0]
for _, m := range st.Memberships {
if m.UserID != target.ID {
out = append(out, m)
}
}
st.Memberships = out
default:
return nil, errors.New("unknown action")
}
return target, nil
}
// revokeSessions drops every session a user holds, on any device.
func revokeSessions(st *store.State, userID string) {
for token, sess := range st.Sessions {
if sess.UserID == userID {
delete(st.Sessions, token)
}
}
}
// ---- databases ----
func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
MergeStrategy store.Strategy `json:"merge_strategy"`
}
if !readJSON(w, r, &req) {
return
}
var out dbView
err := s.st.Update(func(st *store.State) error {
if strings.TrimSpace(req.Name) == "" {
return errors.New("name is required")
}
if st.Groups[req.OwnerGroupID] == nil {
return errors.New("unknown owner group")
}
if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) {
return errForbidden
}
strategy := req.MergeStrategy
if strategy == "" {
strategy = store.StrategyServerWins
}
if !strategy.Valid() {
return errors.New("unknown merge strategy")
}
db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()}
st.Databases[db.ID] = db
out = toView(db, store.PermAdmin)
return nil
})
if err != nil {
deny(w, err)
return
}
s.notifyList(out.ID)
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
var req struct {
Name *string `json:"name"`
MergeStrategy *store.Strategy `json:"merge_strategy"`
}
if !readJSON(w, r, &req) {
return
}
var out dbView
err := s.st.Update(func(st *store.State) error {
if err := adminsDatabase(st, u, id); err != nil {
return err
}
db := st.Databases[id]
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
return errors.New("name may not be empty")
}
db.Name = strings.TrimSpace(*req.Name)
}
if req.MergeStrategy != nil {
if !req.MergeStrategy.Valid() {
return errors.New("unknown merge strategy")
}
if req.MergeStrategy.NeedsKey() && db.WrappedKey == "" {
return errors.New("merging needs the vault password: store it with " +
"`keeporg db -id <id> -store-password` first")
}
db.MergeStrategy = *req.MergeStrategy
}
out = toView(db, store.PermAdmin)
return nil
})
if err != nil {
deny(w, err)
return
}
s.notifyList(id)
writeJSON(w, http.StatusOK, out)
}
// errConfirmName is the answer when an account with no password of its own
// did not type the name of the thing it is about to destroy.
var errConfirmName = errors.New("type the name exactly to confirm")
// errNoDatabase is a database that does not exist or that the caller does
// not administer; the two are not told apart, so neither answer says that a
// database exists.
var errNoDatabase = errors.New("no such database")
// deleteDatabase removes a database, its grants and every stored version of
// the file. Clients keep whatever they already have on disk: the server can
// stop tracking a vault, but it has no business reaching into somebody's
// home directory.
//
// It re-proves who the caller is first. Holding a session is enough to read
// and write a vault; it is not enough to destroy every version of one,
// because a session is what an unlocked laptop leaves lying around. The
// password is checked like a sign-in — outside the store's lock and against
// the attempt limits — and only for a caller who administers the database,
// so it is not a password oracle for anyone else.
//
// An account that signs in only through an identity provider has no password
// to type. Refusing those accounts would mean a deployment where nobody can
// delete anything, so they confirm by typing the exact name instead. That is
// a weaker gate and it is deliberately the fallback, not the rule.
func (s *Server) deleteDatabase(r *http.Request, u *store.User, id, password, totpCode, typed string) (string, error) {
var (
name string
hasPassword bool
)
s.st.Read(func(st *store.State) {
if db := st.Databases[id]; db != nil && st.DatabasePerm(u.ID, id) == store.PermAdmin {
name = db.Name
}
if cur := st.Users[u.ID]; cur != nil {
hasPassword = cur.PassHash != ""
}
})
if name == "" {
return "", errNoDatabase
}
var watchers []string
remove := func(st *store.State, _ *store.User) error {
db := st.Databases[id]
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errNoDatabase
}
name = db.Name
// Taken before the delete: the people to tell are the ones who are
// about to stop having access.
watchers = st.Watchers(id)
delete(st.Databases, id)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
return nil
}
var err error
if !hasPassword {
if strings.TrimSpace(typed) != name {
return "", errConfirmName
}
err = s.st.Update(func(st *store.State) error { return remove(st, u) })
} else {
err = s.reconfirm(r, u, password, totpCode, remove)
}
if err != nil {
return "", err
}
if ferr := s.blobs.Forget(id); ferr != nil {
s.log.Printf("delete blobs for %s: %v", id, ferr)
}
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id})
s.log.Printf("%s deleted database %q (%s) and every stored version", u.Username, name, id)
return name, nil
}
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Password string `json:"password"`
TOTP string `json:"totp"`
Confirm string `json:"confirm_name"`
}
if r.ContentLength > 0 && !readJSON(w, r, &req) {
return
}
if _, err := s.deleteDatabase(r, u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
denyAuth(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---- groups and membership: API ----
type groupView struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id,omitempty"`
Admin bool `json:"admin"`
Members int `json:"members"`
}
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) {
out := []groupView{}
s.st.Read(func(st *store.State) {
counts := map[string]int{}
for _, m := range st.Memberships {
counts[m.GroupID]++
}
for id := range visibleGroups(st, u) {
g := st.Groups[id]
out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID,
Admin: st.IsGroupAdmin(u.ID, id), Members: counts[id]})
}
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
}
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if !readJSON(w, r, &req) {
return
}
var out groupView
err := s.st.Update(func(st *store.State) error {
g, err := createGroup(st, u, req.Name, req.ParentID)
if err != nil {
return err
}
out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true}
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
id := r.PathValue("id")
if err := s.st.Update(func(st *store.State) error {
return deleteGroup(st, u, id)
}); err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID := r.PathValue("id")
var req struct {
Username string `json:"username"`
UserID string `json:"user_id"`
Role store.Role `json:"role"`
}
if !readJSON(w, r, &req) {
return
}
if err := s.st.Update(func(st *store.State) error {
return setMember(st, u, groupID, req.Role, req.UserID, req.Username)
}); err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID, userRef := r.PathValue("id"), r.PathValue("user")
if err := s.st.Update(func(st *store.State) error {
return removeMember(st, u, groupID, userRef)
}); err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
// ---- grants: API ----
func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
GroupID string `json:"group_id"`
DatabaseID string `json:"database_id"`
Perm store.Perm `json:"perm"`
}
if !readJSON(w, r, &req) {
return
}
var watchers []string
err := s.st.Update(func(st *store.State) error {
if err := setGrant(st, u, req.GroupID, req.DatabaseID, req.Perm); err != nil {
return err
}
watchers = st.Watchers(req.DatabaseID)
return nil
})
if err != nil {
deny(w, err)
return
}
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
groupID := r.URL.Query().Get("group_id")
dbID := r.URL.Query().Get("database_id")
var watchers []string
err := s.st.Update(func(st *store.State) error {
// The watcher list is taken before the grant goes, because the people
// losing access are exactly the ones who must be told.
watchers = st.Watchers(dbID)
return removeGrant(st, u, groupID, dbID)
})
if err != nil {
deny(w, err)
return
}
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---- invites: API ----
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
GroupID string `json:"group_id"`
Role store.Role `json:"role"`
TTLHours int `json:"ttl_hours"`
MaxUses int `json:"max_uses"`
}
if !readJSON(w, r, &req) {
return
}
var inv *store.Invite
err := s.st.Update(func(st *store.State) error {
var err error
inv, err = createInvite(st, u, req.GroupID, req.Role, req.TTLHours, req.MaxUses)
return err
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"token": inv.Token,
"url": s.inviteURL(inv.Token),
"expires_at": inv.ExpiresAt,
"max_uses": inv.MaxUses,
"group_id": inv.GroupID,
"role": inv.Role,
})
}
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) {
type inviteView struct {
Token string `json:"token"`
URL string `json:"url"`
GroupID string `json:"group_id,omitempty"`
Role store.Role `json:"role"`
Uses int `json:"uses"`
MaxUses int `json:"max_uses"`
ExpiresAt time.Time `json:"expires_at"`
}
out := []inviteView{}
s.st.Read(func(st *store.State) {
for _, inv := range st.Invites {
if !mayManageInvite(st, u, inv) {
continue
}
out = append(out, inviteView{Token: inv.Token, URL: s.inviteURL(inv.Token),
GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses,
ExpiresAt: inv.ExpiresAt})
}
})
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
writeJSON(w, http.StatusOK, map[string]any{"invites": out})
}
func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
if err := s.st.Update(func(st *store.State) error {
return revokeInvite(st, u, r.PathValue("token"))
}); err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// inviteURL is the link an invited person follows.
func (s *Server) inviteURL(token string) string {
return s.cfg.ExternalURL + "/invite/" + token
}
// ---- users: API ----
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) {
out := []userView{}
s.st.Read(func(st *store.State) {
for _, target := range visibleUsers(st, u) {
v := view(target)
if !u.ServerAdmin && target.ID != u.ID {
v.Email, v.TOTPEnabled, v.ServerAdmin = "", false, false
}
out = append(out, v)
}
})
writeJSON(w, http.StatusOK, map[string]any{"users": out})
}
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Action accountAction `json:"action"`
}
if !readJSON(w, r, &req) {
return
}
var out userView
err := s.st.Update(func(st *store.State) error {
target, err := adminAccount(st, u, r.PathValue("id"), req.Action)
if err != nil {
return err
}
out = view(target)
return nil
})
if err != nil {
deny(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// notifyList tells everyone with access that the database list changed.
func (s *Server) notifyList(dbID string) {
var watchers []string
s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) })
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
}
func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant {
out := in[:0]
for _, g := range in {
if keep(g) {
out = append(out, g)
}
}
return out
}