package adminapi import ( "errors" "net/http" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // The endpoints about one user, addressed by full user ID. // userDetail is GET /users/{userID}: the list row and what it takes a // count to know. type userDetail struct { adminUser Devices int `json:"devices"` // JoinedRooms is absent on a server without rooms. JoinedRooms *int `json:"joined_rooms,omitempty"` // Media is absent on a server without a media repository. Media *mediaResponse `json:"media,omitempty"` } // mediaResponse is a user's uploads against their quota. type mediaResponse struct { Uploads int `json:"uploads"` Bytes int64 `json:"bytes"` // QuotaBytes is the quota in force, 0 for no limit; CustomQuota says // whether it was set for this user rather than taken from the config. QuotaBytes int64 `json:"quota_bytes"` CustomQuota bool `json:"custom_quota"` } // pathUser reads the {userID} path value: a user of this server. func (a *AdminAPI) pathUser(r *http.Request) (string, error) { raw := r.PathValue("userID") localpart, err := a.users.ParseLocalUserID(raw) switch { case errors.Is(err, userapi.ErrForeignServer): return "", httputil.InvalidParam("%s is not a user of this server", raw) case err != nil: return "", httputil.InvalidParam("%s is not a valid user ID", raw) } return localpart, nil } // pathAccount reads the {userID} path value and the account it names. func (a *AdminAPI) pathAccount(r *http.Request) (storage.Account, error) { localpart, err := a.pathUser(r) if err != nil { return storage.Account{}, err } account, err := a.users.Account(r.Context(), localpart) switch { case errors.Is(err, userapi.ErrNoSuchUser): return storage.Account{}, httputil.NotFound("No such user") case err != nil: return storage.Account{}, err } return account, nil } func (a *AdminAPI) adminUserOf(acc storage.Account) adminUser { return adminUser{ UserID: a.users.UserID(acc.Localpart), DisplayName: acc.DisplayName, AvatarURL: acc.AvatarURL, Admin: acc.Admin, Deactivated: acc.Deactivated, CreatedAtMS: acc.CreatedAtMS, } } // handleGetUser implements GET /_warp/admin/v1/users/{userID}. func (a *AdminAPI) handleGetUser(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } return a.writeUser(w, r, account) } // writeUser answers with a user's detail, which is also what every change // to a user answers with, so the panel can redraw from the response. func (a *AdminAPI) writeUser(w http.ResponseWriter, r *http.Request, account storage.Account) error { devices, err := a.users.Devices(r.Context(), account.Localpart) if err != nil { return err } resp := userDetail{adminUser: a.adminUserOf(account), Devices: len(devices)} userID := a.users.UserID(account.Localpart) if a.rooms != nil { joined, err := a.rooms.JoinedRooms(r.Context(), userID) if err != nil { return err } n := len(joined) resp.JoinedRooms = &n } if a.media != nil { quota, err := a.media.Quota(r.Context(), userID) if err != nil { return err } resp.Media = &mediaResponse{ Uploads: quota.Usage.Count, Bytes: quota.Usage.Bytes, QuotaBytes: quota.MaxBytes, CustomQuota: quota.Custom, } } httputil.NoCache(w) httputil.WriteJSON(w, http.StatusOK, resp) return nil } // refuseSelf stops an admin taking their own admin flag or deactivating // themselves. Both are one click from locking the last admin out of the // panel, and both are easy to do by accident on a table of users; another // admin can still do either. func refuseSelf(auth userapi.Authenticated, localpart, what string) error { if auth.Localpart == localpart { return httputil.Forbidden("You cannot %s your own account; ask another admin", what) } return nil } // adminFlagRequest is the PUT /users/{userID}/admin body. type adminFlagRequest struct { Admin *bool `json:"admin"` } // handlePutUserAdmin implements PUT /_warp/admin/v1/users/{userID}/admin. func (a *AdminAPI) handlePutUserAdmin(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } var req adminFlagRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if req.Admin == nil { return httputil.MissingParam("admin is required") } if !*req.Admin { if err := refuseSelf(auth, account.Localpart, "remove the admin flag from"); err != nil { return err } } if account.Admin != *req.Admin { if err := a.users.SetAdmin(r.Context(), account.Localpart, *req.Admin); err != nil { return err } account.Admin = *req.Admin a.audit(r.Context(), auth, auditUserAdmin, a.users.UserID(account.Localpart), map[string]any{"admin": *req.Admin}) } return a.writeUser(w, r, account) } // deactivateRequest is the POST /users/{userID}/deactivate body. type deactivateRequest struct { // Erase forgets the profile as well. Erase bool `json:"erase"` } // handleDeactivateUser implements POST /_warp/admin/v1/users/{userID}/deactivate. // // It does what the user's own POST /account/deactivate does: every token // stops working, the devices and their keys and pushers go, the user // leaves every room, and with erase the display name and avatar are // cleared first so the leave events carry neither. The name stays taken. // Deactivating a deactivated account changes nothing. func (a *AdminAPI) handleDeactivateUser(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } var req deactivateRequest if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil { return err } if err := refuseSelf(auth, account.Localpart, "deactivate"); err != nil { return err } if !account.Deactivated { if err := a.users.DeactivateAccount(r.Context(), account.Localpart, req.Erase); err != nil { return err } a.audit(r.Context(), auth, auditUserDeactivate, a.users.UserID(account.Localpart), map[string]any{"erase": req.Erase}) } if account, err = a.users.Account(r.Context(), account.Localpart); err != nil { return err } return a.writeUser(w, r, account) } // handleReactivateUser implements POST /_warp/admin/v1/users/{userID}/reactivate. // // The account can log in again, with its old password unless an admin // sets a new one. Nothing else comes back: the rooms were left and the // devices are gone. func (a *AdminAPI) handleReactivateUser(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } if account.Deactivated { if err := a.users.ReactivateAccount(r.Context(), account.Localpart); err != nil { return err } account.Deactivated = false a.audit(r.Context(), auth, auditUserReactivate, a.users.UserID(account.Localpart), nil) } return a.writeUser(w, r, account) } // resetPasswordRequest is the POST /users/{userID}/password body. type resetPasswordRequest struct { Password string `json:"password"` // LogoutDevices defaults to true: an admin resetting a password is // usually doing it because somebody else has the account. LogoutDevices *bool `json:"logout_devices"` } // handleResetPassword implements POST /_warp/admin/v1/users/{userID}/password. func (a *AdminAPI) handleResetPassword(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } var req resetPasswordRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if req.Password == "" { return httputil.MissingParam("password is required") } if len(req.Password) > userapi.MaxPasswordLength { return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeWeakPassword, "Password must be at most %d bytes", userapi.MaxPasswordLength) } logout := req.LogoutDevices == nil || *req.LogoutDevices if err := a.users.ChangePassword(r.Context(), account.Localpart, req.Password, logout, ""); err != nil { return err } a.audit(r.Context(), auth, auditUserPassword, a.users.UserID(account.Localpart), map[string]any{"logout_devices": logout}) return a.writeUser(w, r, account) } // adminDevice is one device in the device list. type adminDevice struct { DeviceID string `json:"device_id"` DisplayName string `json:"display_name,omitempty"` CreatedAtMS int64 `json:"created_at_ms"` LastSeenMS int64 `json:"last_seen_ms"` LastSeenIP string `json:"last_seen_ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` } // handleGetUserDevices implements GET /_warp/admin/v1/users/{userID}/devices. func (a *AdminAPI) handleGetUserDevices(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } devices, err := a.users.Devices(r.Context(), account.Localpart) if err != nil { return err } out := make([]adminDevice, 0, len(devices)) for _, d := range devices { out = append(out, adminDevice{ DeviceID: d.ID, DisplayName: d.DisplayName, CreatedAtMS: d.CreatedAtMS, LastSeenMS: d.LastSeenMS, LastSeenIP: d.LastSeenIP, UserAgent: d.UserAgent, }) } httputil.NoCache(w) httputil.WriteJSON(w, http.StatusOK, map[string]any{"devices": out}) return nil } // handleDeleteUserDevice implements // DELETE /_warp/admin/v1/users/{userID}/devices/{deviceID}: log one // session out, taking its keys and pushers with it. func (a *AdminAPI) handleDeleteUserDevice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } deviceID := r.PathValue("deviceID") if _, err := a.users.Device(r.Context(), account.Localpart, deviceID); errors.Is(err, userapi.ErrNoSuchDevice) { return httputil.NotFound("No such device") } else if err != nil { return err } if err := a.users.DeleteDevices(r.Context(), account.Localpart, []string{deviceID}); err != nil { return err } a.audit(r.Context(), auth, auditUserDeviceDelete, a.users.UserID(account.Localpart), map[string]any{"device_id": deviceID}) httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // handleDeleteUserDevices implements // DELETE /_warp/admin/v1/users/{userID}/devices: log every session out. func (a *AdminAPI) handleDeleteUserDevices(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { account, err := a.pathAccount(r) if err != nil { return err } if err := a.users.DeleteAllDevices(r.Context(), account.Localpart); err != nil { return err } a.audit(r.Context(), auth, auditUserDeviceDelete, a.users.UserID(account.Localpart), map[string]any{"all": true}) httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // requireMedia refuses the media endpoints on a server without a media // repository. func (a *AdminAPI) requireMedia() error { if a.media == nil { return httputil.NotFound("This server has no media repository") } return nil } // handleGetUserMedia implements GET /_warp/admin/v1/users/{userID}/media. func (a *AdminAPI) handleGetUserMedia(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { if err := a.requireMedia(); err != nil { return err } account, err := a.pathAccount(r) if err != nil { return err } return a.writeMedia(w, r, account) } func (a *AdminAPI) writeMedia(w http.ResponseWriter, r *http.Request, account storage.Account) error { quota, err := a.media.Quota(r.Context(), a.users.UserID(account.Localpart)) if err != nil { return err } httputil.NoCache(w) httputil.WriteJSON(w, http.StatusOK, mediaResponse{ Uploads: quota.Usage.Count, Bytes: quota.Usage.Bytes, QuotaBytes: quota.MaxBytes, CustomQuota: quota.Custom, }) return nil } // quotaRequest is the PUT /users/{userID}/media/quota body. type quotaRequest struct { MaxBytes *int64 `json:"max_bytes"` } // handlePutMediaQuota implements PUT /_warp/admin/v1/users/{userID}/media/quota. // // A quota below what the user already has does not delete anything; it // stops further uploads until they are back under it. func (a *AdminAPI) handlePutMediaQuota(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { if err := a.requireMedia(); err != nil { return err } account, err := a.pathAccount(r) if err != nil { return err } var req quotaRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if req.MaxBytes == nil { return httputil.MissingParam("max_bytes is required (0 for no limit)") } if *req.MaxBytes < 0 { return httputil.InvalidParam("max_bytes must not be negative") } userID := a.users.UserID(account.Localpart) if err := a.media.SetQuota(r.Context(), userID, *req.MaxBytes); err != nil { return err } a.audit(r.Context(), auth, auditUserMediaQuota, userID, map[string]any{"max_bytes": *req.MaxBytes}) return a.writeMedia(w, r, account) } // handleDeleteMediaQuota implements DELETE // /_warp/admin/v1/users/{userID}/media/quota: back to the configured // default. func (a *AdminAPI) handleDeleteMediaQuota(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { if err := a.requireMedia(); err != nil { return err } account, err := a.pathAccount(r) if err != nil { return err } userID := a.users.UserID(account.Localpart) if err := a.media.ResetQuota(r.Context(), userID); err != nil { return err } a.audit(r.Context(), auth, auditUserMediaQuota, userID, map[string]any{"max_bytes": nil}) return a.writeMedia(w, r, account) }