Phase 9: application services and admin completion
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 9. internal/appservice reads registration files (appservice.config_files), matches namespaces the way Synapse does (anchored at the start), and delivers events to each service as transactions read from the roomserver's event stream at a cursor kept in appservice_state. A transaction in flight is recorded, so after a restart it is resent under the same ID; failures back off from 2s to 5m, and a successful ping cuts the backoff short. In the Client-Server API an as_token acts as the service's sender or, with ?user_id=, as any existing user in its namespace. Services register and log in their users with m.login.application_service; exclusive user and alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown aliases and users in a namespace are asked of the service first. The ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when rate_limited is false, skip the rate limiter. Users can change their password (POST /account/password) and deactivate their account (POST /account/deactivate), both behind the password. Deactivation revokes tokens, deletes devices, optionally erases the profile and leaves every room through a new userapi.AccountObserver. The admin API is complete: user detail, the admin flag, deactivation and reactivation, password resets, devices, media quotas (media.max_user_storage_bytes plus per-user overrides), registration tokens with use limits and expiry (registration.require_token), server notices (internal/servernotices, server_notices.*), an editor for the federation allow/deny lists persisted in server_settings, a delivery view of the application services, and an audit log of every change. Migration 0011 adds the registration token, server notice room, media quota, audit log and appservice cursor tables. Policy lists are now returned sorted. The Complement entrypoint loads the registrations Complement mounts in /complement/appservice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b507753172
commit
a94794b866
69 files changed
+6922
-124
No files matched your search
@@ -8,21 +8,26 @@
|
||||
// with the admin flag, so the panel logs in the way any client does and
|
||||
// there is no second credential to manage.
|
||||
//
|
||||
// Phase 1 is the skeleton PLAN.md section 7 asks for: the two runtime
|
||||
// switches, user listing and creation, and the dashboard counters. Full user
|
||||
// management, registration tokens, the federation allow/deny list editor and
|
||||
// the audit log arrive in Phase 9.
|
||||
// Phase 1 built the skeleton PLAN.md section 7 asks for: the two runtime
|
||||
// switches, user listing and creation, and the dashboard counters. Phase 9
|
||||
// completed it: full user management, registration tokens, server notices,
|
||||
// media quotas, the federation allow/deny list editor, the application
|
||||
// service status view, and an audit log of every change made through it.
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/appservice"
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
"github.com/dariuszjaniak/warp/server/internal/servernotices"
|
||||
"github.com/dariuszjaniak/warp/server/internal/settings"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
@@ -41,6 +46,13 @@ type AdminAPI struct {
|
||||
database storage.Database
|
||||
fedStatus FederationStatus
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
media *mediaapi.API
|
||||
appservices *appservice.API
|
||||
notices *servernotices.API
|
||||
tokens *userapi.RegistrationTokens
|
||||
adminStore storage.AdminStore
|
||||
}
|
||||
|
||||
// Options configure the component.
|
||||
@@ -60,11 +72,31 @@ type Options struct {
|
||||
// without one, and the destinations endpoints then say so.
|
||||
FederationStatus FederationStatus
|
||||
|
||||
// Database is used for the driver name the dashboard shows and for the
|
||||
// migration state; the admin API does not write to it directly.
|
||||
// Database is used for the driver name the dashboard shows, and its
|
||||
// admin store for the audit log; nothing else here writes to it
|
||||
// directly.
|
||||
Database storage.Database
|
||||
|
||||
// Media is the media repository, for the quota endpoints. Nil leaves
|
||||
// them answering 404.
|
||||
Media *mediaapi.API
|
||||
|
||||
// AppServices is the application service component, for the status
|
||||
// view. Nil means none are registered.
|
||||
AppServices *appservice.API
|
||||
|
||||
// Notices sends server notices. Nil means server notices are not
|
||||
// configured, and the endpoint says so.
|
||||
Notices *servernotices.API
|
||||
|
||||
// RegistrationTokens manages the tokens made through the admin API.
|
||||
// Nil builds one on the Database's admin store.
|
||||
RegistrationTokens *userapi.RegistrationTokens
|
||||
|
||||
Logger *slog.Logger
|
||||
|
||||
// Now is the clock, for the audit log.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New builds the component.
|
||||
@@ -83,15 +115,41 @@ func New(opts Options) (*AdminAPI, error) {
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = slog.Default()
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
var adminStore storage.AdminStore
|
||||
if opts.Database != nil {
|
||||
adminStore = opts.Database.Admin()
|
||||
}
|
||||
if opts.RegistrationTokens == nil {
|
||||
now := opts.Now
|
||||
opts.RegistrationTokens = userapi.NewRegistrationTokens(userapi.RegistrationTokensOptions{
|
||||
Store: adminStore,
|
||||
NowMS: func() int64 { return now().UnixMilli() },
|
||||
})
|
||||
}
|
||||
if opts.AppServices == nil {
|
||||
var err error
|
||||
if opts.AppServices, err = appservice.New(appservice.Options{Logger: opts.Logger}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &AdminAPI{
|
||||
serverName: opts.ServerName,
|
||||
users: opts.Users,
|
||||
rooms: opts.Rooms,
|
||||
settings: opts.Settings,
|
||||
federation: opts.Federation,
|
||||
database: opts.Database,
|
||||
fedStatus: opts.FederationStatus,
|
||||
log: opts.Logger,
|
||||
serverName: opts.ServerName,
|
||||
users: opts.Users,
|
||||
rooms: opts.Rooms,
|
||||
settings: opts.Settings,
|
||||
federation: opts.Federation,
|
||||
database: opts.Database,
|
||||
fedStatus: opts.FederationStatus,
|
||||
log: opts.Logger,
|
||||
now: opts.Now,
|
||||
media: opts.Media,
|
||||
appservices: opts.AppServices,
|
||||
notices: opts.Notices,
|
||||
tokens: opts.RegistrationTokens,
|
||||
adminStore: adminStore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -103,14 +161,39 @@ func (a *AdminAPI) RegisterRoutes(r *httputil.Router) {
|
||||
a.handle(r, "POST "+Prefix+"/federation/destinations/{serverName}/reset", a.handleResetDestination)
|
||||
a.handle(r, "GET "+Prefix+"/registration", a.handleGetRegistration)
|
||||
a.handle(r, "PUT "+Prefix+"/registration", a.handlePutRegistration)
|
||||
a.handle(r, "PUT "+Prefix+"/federation/lists", a.handlePutFederationLists)
|
||||
a.handle(r, "DELETE "+Prefix+"/federation/lists", a.handleDeleteFederationLists)
|
||||
a.handle(r, "GET "+Prefix+"/users", a.handleGetUsers)
|
||||
a.handle(r, "POST "+Prefix+"/users", a.handlePostUsers)
|
||||
a.handle(r, "GET "+Prefix+"/stats", a.handleGetStats)
|
||||
|
||||
// One user. {userID} is a full user ID, escaped in the path.
|
||||
a.handle(r, "GET "+Prefix+"/users/{userID}", a.handleGetUser)
|
||||
a.handle(r, "PUT "+Prefix+"/users/{userID}/admin", a.handlePutUserAdmin)
|
||||
a.handle(r, "POST "+Prefix+"/users/{userID}/deactivate", a.handleDeactivateUser)
|
||||
a.handle(r, "POST "+Prefix+"/users/{userID}/reactivate", a.handleReactivateUser)
|
||||
a.handle(r, "POST "+Prefix+"/users/{userID}/password", a.handleResetPassword)
|
||||
a.handle(r, "GET "+Prefix+"/users/{userID}/devices", a.handleGetUserDevices)
|
||||
a.handle(r, "DELETE "+Prefix+"/users/{userID}/devices", a.handleDeleteUserDevices)
|
||||
a.handle(r, "DELETE "+Prefix+"/users/{userID}/devices/{deviceID}", a.handleDeleteUserDevice)
|
||||
a.handle(r, "GET "+Prefix+"/users/{userID}/media", a.handleGetUserMedia)
|
||||
a.handle(r, "PUT "+Prefix+"/users/{userID}/media/quota", a.handlePutMediaQuota)
|
||||
a.handle(r, "DELETE "+Prefix+"/users/{userID}/media/quota", a.handleDeleteMediaQuota)
|
||||
|
||||
a.handle(r, "GET "+Prefix+"/rooms", a.handleGetRooms)
|
||||
a.handle(r, "GET "+Prefix+"/rooms/{roomID}", a.handleGetRoom)
|
||||
a.handle(r, "DELETE "+Prefix+"/rooms/{roomID}", a.handleDeleteRoom)
|
||||
a.handle(r, "POST "+Prefix+"/rooms/{roomID}/force_leave", a.handleForceLeave)
|
||||
|
||||
a.handle(r, "GET "+Prefix+"/registration_tokens", a.handleGetRegistrationTokens)
|
||||
a.handle(r, "POST "+Prefix+"/registration_tokens", a.handlePostRegistrationToken)
|
||||
a.handle(r, "GET "+Prefix+"/registration_tokens/{token}", a.handleGetRegistrationToken)
|
||||
a.handle(r, "PUT "+Prefix+"/registration_tokens/{token}", a.handlePutRegistrationToken)
|
||||
a.handle(r, "DELETE "+Prefix+"/registration_tokens/{token}", a.handleDeleteRegistrationToken)
|
||||
|
||||
a.handle(r, "POST "+Prefix+"/server_notices", a.handlePostServerNotice)
|
||||
a.handle(r, "GET "+Prefix+"/appservices", a.handleGetAppServices)
|
||||
a.handle(r, "GET "+Prefix+"/audit_log", a.handleGetAuditLog)
|
||||
}
|
||||
|
||||
// adminHandler is a handler that has already been shown to be an admin.
|
||||
@@ -214,6 +297,10 @@ type federationResponse struct {
|
||||
switchResponse
|
||||
Allowlist []string `json:"allowlist"`
|
||||
Denylist []string `json:"denylist"`
|
||||
|
||||
// ListsEdited is true when the lists were set through the admin API
|
||||
// and replace the config file's.
|
||||
ListsEdited bool `json:"lists_edited"`
|
||||
}
|
||||
|
||||
// handleGetFederation implements GET /_warp/admin/v1/federation.
|
||||
@@ -224,14 +311,65 @@ func (a *AdminAPI) handleGetFederation(w http.ResponseWriter, _ *http.Request, _
|
||||
Enabled: a.settings.FederationEnabled(),
|
||||
Toggleable: a.settings.FederationToggleable(),
|
||||
},
|
||||
// Editing these is Phase 9; they are reported now because the
|
||||
// dashboard has to show which servers a running Warp will talk to.
|
||||
Allowlist: allowlist,
|
||||
Denylist: denylist,
|
||||
Allowlist: allowlist,
|
||||
Denylist: denylist,
|
||||
ListsEdited: a.settings.FederationLists().Edited,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// federationListsRequest is the PUT /federation/lists body.
|
||||
type federationListsRequest struct {
|
||||
Allowlist *[]string `json:"allowlist"`
|
||||
Denylist *[]string `json:"denylist"`
|
||||
}
|
||||
|
||||
// handlePutFederationLists implements PUT /_warp/admin/v1/federation/lists.
|
||||
//
|
||||
// Both lists are replaced together, and both have to be in the body: a
|
||||
// panel that sent one and had the other silently kept would be one bug
|
||||
// away from leaving a server it meant to cut off on the allowlist. They
|
||||
// take effect at once, on the next request in either direction, and are
|
||||
// kept across restarts in place of the config file's.
|
||||
func (a *AdminAPI) handlePutFederationLists(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
var req federationListsRequest
|
||||
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Allowlist == nil || req.Denylist == nil {
|
||||
return httputil.MissingParam("allowlist and denylist are both required (send [] for an empty list)")
|
||||
}
|
||||
for _, list := range [][]string{*req.Allowlist, *req.Denylist} {
|
||||
for _, name := range list {
|
||||
if _, err := federation.ParseServerName(name); err != nil {
|
||||
return httputil.InvalidParam("%q is not a server name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.settings.SetFederationLists(r.Context(), *req.Allowlist, *req.Denylist); err != nil {
|
||||
return err
|
||||
}
|
||||
lists := a.settings.FederationLists()
|
||||
a.federation.Policy().Replace(lists.Allowlist, lists.Denylist)
|
||||
a.audit(r.Context(), auth, auditFederationLists, "", map[string]any{
|
||||
"allowlist": lists.Allowlist, "denylist": lists.Denylist,
|
||||
})
|
||||
return a.handleGetFederation(w, r, auth)
|
||||
}
|
||||
|
||||
// handleDeleteFederationLists implements DELETE
|
||||
// /_warp/admin/v1/federation/lists: forget the edited lists and go back to
|
||||
// the config file's.
|
||||
func (a *AdminAPI) handleDeleteFederationLists(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
lists, err := a.settings.ResetFederationLists(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.federation.Policy().Replace(lists.Allowlist, lists.Denylist)
|
||||
a.audit(r.Context(), auth, auditFederationReset, "", nil)
|
||||
return a.handleGetFederation(w, r, auth)
|
||||
}
|
||||
|
||||
// handlePutFederation implements PUT /_warp/admin/v1/federation, the switch
|
||||
// PLAN.md section 3 describes: no restart, the gate picks it up on the next
|
||||
// request.
|
||||
@@ -243,8 +381,7 @@ func (a *AdminAPI) handlePutFederation(w http.ResponseWriter, r *http.Request, a
|
||||
if err := a.settings.SetFederationEnabled(r.Context(), enabled); err != nil {
|
||||
return toggleError(err, "federation")
|
||||
}
|
||||
a.log.Info("federation switch changed through the admin API",
|
||||
"enabled", enabled, "by", auth.UserID)
|
||||
a.audit(r.Context(), auth, auditFederationSwitch, "", map[string]any{"enabled": enabled})
|
||||
return a.handleGetFederation(w, r, auth)
|
||||
}
|
||||
|
||||
@@ -266,8 +403,7 @@ func (a *AdminAPI) handlePutRegistration(w http.ResponseWriter, r *http.Request,
|
||||
if err := a.settings.SetRegistrationEnabled(r.Context(), enabled); err != nil {
|
||||
return toggleError(err, "registration")
|
||||
}
|
||||
a.log.Info("registration switch changed through the admin API",
|
||||
"enabled", enabled, "by", auth.UserID)
|
||||
a.audit(r.Context(), auth, auditRegistrationSwitch, "", map[string]any{"enabled": enabled})
|
||||
return a.handleGetRegistration(w, r, auth)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/appservice"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// appServiceNamespace is one namespace in the status view.
|
||||
type appServiceNamespace struct {
|
||||
Regex string `json:"regex"`
|
||||
Exclusive bool `json:"exclusive"`
|
||||
}
|
||||
|
||||
// appServiceDelivery is a service's delivery health. It is absent for a
|
||||
// service without a URL, which is sent nothing.
|
||||
type appServiceDelivery struct {
|
||||
StreamPos int64 `json:"stream_pos"`
|
||||
Behind int64 `json:"behind"`
|
||||
TxnID int64 `json:"txn_id"`
|
||||
EventsDelivered int64 `json:"events_delivered"`
|
||||
Failures int `json:"failures"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastSuccessMS int64 `json:"last_success_ms"`
|
||||
LastFailureMS int64 `json:"last_failure_ms"`
|
||||
RetryAtMS int64 `json:"retry_at_ms"`
|
||||
}
|
||||
|
||||
// appServiceResponse is one service in GET /appservices.
|
||||
type appServiceResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Sender string `json:"sender"`
|
||||
RateLimited bool `json:"rate_limited"`
|
||||
Protocols []string `json:"protocols"`
|
||||
Namespaces map[string][]appServiceNamespace `json:"namespaces"`
|
||||
Delivery *appServiceDelivery `json:"delivery,omitempty"`
|
||||
}
|
||||
|
||||
// handleGetAppServices implements GET /_warp/admin/v1/appservices: what is
|
||||
// registered, and how delivery to each is going. Registrations are read
|
||||
// from files at startup, so this is a view, not an editor; the tokens are
|
||||
// never shown.
|
||||
func (a *AdminAPI) handleGetAppServices(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
|
||||
var top int64
|
||||
if a.rooms != nil {
|
||||
var err error
|
||||
if top, err = a.rooms.StreamTop(r.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
statuses := map[string]appServiceDelivery{}
|
||||
for _, s := range a.appservices.Statuses() {
|
||||
statuses[s.ID] = appServiceDelivery{
|
||||
StreamPos: s.StreamPos, Behind: max(top-s.StreamPos, 0), TxnID: s.TxnID,
|
||||
EventsDelivered: s.EventsDelivered, Failures: s.Failures, LastError: s.LastError,
|
||||
LastSuccessMS: s.LastSuccessMS, LastFailureMS: s.LastFailureMS, RetryAtMS: s.RetryAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
out := []appServiceResponse{}
|
||||
for _, svc := range a.appservices.Registry().Services() {
|
||||
resp := appServiceResponse{
|
||||
ID: svc.ID,
|
||||
URL: svc.URL,
|
||||
Sender: svc.SenderUserID(),
|
||||
RateLimited: svc.RateLimitedUsers(),
|
||||
Protocols: svc.Protocols,
|
||||
Namespaces: map[string][]appServiceNamespace{
|
||||
"users": namespacesOf(svc.Namespaces.Users),
|
||||
"aliases": namespacesOf(svc.Namespaces.Aliases),
|
||||
"rooms": namespacesOf(svc.Namespaces.Rooms),
|
||||
},
|
||||
}
|
||||
if resp.Protocols == nil {
|
||||
resp.Protocols = []string{}
|
||||
}
|
||||
if d, ok := statuses[svc.ID]; ok {
|
||||
resp.Delivery = &d
|
||||
}
|
||||
out = append(out, resp)
|
||||
}
|
||||
httputil.NoCache(w)
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{"appservices": out})
|
||||
return nil
|
||||
}
|
||||
|
||||
func namespacesOf(list []appservice.Namespace) []appServiceNamespace {
|
||||
out := make([]appServiceNamespace, 0, len(list))
|
||||
for _, ns := range list {
|
||||
out = append(out, appServiceNamespace{Regex: ns.Regex, Exclusive: ns.Exclusive})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// The actions the audit log records, one per kind of change the admin API
|
||||
// can make. They are part of the API: the panel filters on them.
|
||||
const (
|
||||
auditFederationSwitch = "federation.switch"
|
||||
auditFederationLists = "federation.lists"
|
||||
auditFederationReset = "federation.lists_reset"
|
||||
auditDestinationReset = "federation.destination_reset"
|
||||
auditRegistrationSwitch = "registration.switch"
|
||||
auditUserCreate = "user.create"
|
||||
auditUserAdmin = "user.admin"
|
||||
auditUserDeactivate = "user.deactivate"
|
||||
auditUserReactivate = "user.reactivate"
|
||||
auditUserPassword = "user.password"
|
||||
auditUserDeviceDelete = "user.device_delete"
|
||||
auditUserMediaQuota = "user.media_quota"
|
||||
auditRoomPurge = "room.purge"
|
||||
auditRoomForceLeave = "room.force_leave"
|
||||
auditTokenCreate = "registration_token.create"
|
||||
auditTokenUpdate = "registration_token.update"
|
||||
auditTokenDelete = "registration_token.delete"
|
||||
auditServerNotice = "server_notice.send"
|
||||
)
|
||||
|
||||
// audit records a change made through the admin API, after it has been
|
||||
// made.
|
||||
//
|
||||
// A failure to write the entry is logged and does not fail the request:
|
||||
// the change has happened, and answering 500 for something that worked
|
||||
// would have the panel retry it. The log line carries the same
|
||||
// information, so nothing is lost but the convenience of the table.
|
||||
func (a *AdminAPI) audit(ctx context.Context, auth userapi.Authenticated, action, target string, details map[string]any) {
|
||||
raw := []byte("{}")
|
||||
if len(details) > 0 {
|
||||
var err error
|
||||
if raw, err = json.Marshal(details); err != nil {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
}
|
||||
a.log.Info("admin action", "action", action, "target", target, "by", auth.UserID, "details", string(raw))
|
||||
if a.adminStore == nil {
|
||||
return
|
||||
}
|
||||
if _, err := a.adminStore.AppendAudit(context.WithoutCancel(ctx), storage.AuditEntry{
|
||||
TSMS: a.now().UnixMilli(),
|
||||
Actor: auth.UserID,
|
||||
Action: action,
|
||||
Target: target,
|
||||
Details: raw,
|
||||
}); err != nil {
|
||||
a.log.Error("could not write the audit log", "action", action, "target", target, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// auditEntry is one entry as the API shows it.
|
||||
type auditEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
TSMS int64 `json:"ts_ms"`
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
}
|
||||
|
||||
type auditResponse struct {
|
||||
Entries []auditEntry `json:"entries"`
|
||||
|
||||
// NextBefore is the ?before= of the next page, absent on the last.
|
||||
NextBefore int64 `json:"next_before,omitempty"`
|
||||
}
|
||||
|
||||
// defaultAuditPageLimit is what a request without ?limit= gets, and
|
||||
// maxAuditPageLimit the most it may ask for.
|
||||
const (
|
||||
defaultAuditPageLimit = 50
|
||||
maxAuditPageLimit = 1000
|
||||
)
|
||||
|
||||
// handleGetAuditLog implements GET /_warp/admin/v1/audit_log.
|
||||
//
|
||||
// It pages backwards from the newest entry by ID rather than by offset:
|
||||
// the log grows at the top while an admin reads it, and an offset would
|
||||
// shift under them.
|
||||
func (a *AdminAPI) handleGetAuditLog(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
|
||||
if a.adminStore == nil {
|
||||
return httputil.NotFound("This server keeps no audit log")
|
||||
}
|
||||
query := r.URL.Query()
|
||||
limit, err := intParam(query.Get("limit"), defaultAuditPageLimit)
|
||||
if err != nil || limit < 0 {
|
||||
return httputil.InvalidParam("limit must be a non-negative number")
|
||||
}
|
||||
// Clamped here rather than left to the store, so that "a full page"
|
||||
// below means the same thing the store does.
|
||||
if limit == 0 {
|
||||
limit = defaultAuditPageLimit
|
||||
}
|
||||
limit = min(limit, maxAuditPageLimit)
|
||||
var before int64
|
||||
if raw := query.Get("before"); raw != "" {
|
||||
if before, err = strconv.ParseInt(raw, 10, 64); err != nil || before < 0 {
|
||||
return httputil.InvalidParam("before must be an entry ID")
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := a.adminStore.AuditLog(r.Context(), storage.AuditFilter{
|
||||
Before: before,
|
||||
Limit: limit,
|
||||
Actor: query.Get("actor"),
|
||||
Action: query.Get("action"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := auditResponse{Entries: make([]auditEntry, 0, len(entries))}
|
||||
for _, e := range entries {
|
||||
resp.Entries = append(resp.Entries, auditEntry{
|
||||
ID: e.ID, TSMS: e.TSMS, Actor: e.Actor, Action: e.Action, Target: e.Target,
|
||||
Details: json.RawMessage(e.Details),
|
||||
})
|
||||
}
|
||||
// A full page may have more behind it; a short one cannot.
|
||||
if len(entries) == limit {
|
||||
resp.NextBefore = entries[len(entries)-1].ID
|
||||
}
|
||||
httputil.NoCache(w)
|
||||
httputil.WriteJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (a *AdminAPI) handleResetDestination(w http.ResponseWriter, r *http.Request
|
||||
if err := a.fedStatus.ResetBackoff(r.Context(), serverName); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Info("federation backoff reset through the admin API", "server_name", serverName, "by", auth.UserID)
|
||||
a.audit(r.Context(), auth, auditDestinationReset, serverName, nil)
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/servernotices"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// serverNoticeRequest is the POST /server_notices body.
|
||||
type serverNoticeRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// Content is an m.room.message content. A body is required; a missing
|
||||
// msgtype becomes m.text.
|
||||
Content map[string]any `json:"content"`
|
||||
}
|
||||
|
||||
// handlePostServerNotice implements POST /_warp/admin/v1/server_notices:
|
||||
// send a message from the server to one of its users.
|
||||
//
|
||||
// The notice goes into the user's server notice room, which is made the
|
||||
// first time and which the user is invited back to if they left it. The
|
||||
// user does not have to be online or to accept anything first: the
|
||||
// invite and the message wait for them.
|
||||
func (a *AdminAPI) handlePostServerNotice(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
if a.notices == nil {
|
||||
return httputil.NewError(http.StatusConflict, httputil.ErrCodeForbidden,
|
||||
"Server notices are not configured (server_notices.enabled is false, or the server has no signing key)")
|
||||
}
|
||||
var req serverNoticeRequest
|
||||
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.UserID == "" {
|
||||
return httputil.MissingParam("user_id is required")
|
||||
}
|
||||
if body, _ := req.Content["body"].(string); body == "" {
|
||||
return httputil.MissingParam("content.body is required")
|
||||
}
|
||||
localpart, err := a.users.ParseLocalUserID(req.UserID)
|
||||
if err != nil {
|
||||
return httputil.InvalidParam("%s is not a user of this server", req.UserID)
|
||||
}
|
||||
|
||||
roomID, eventID, err := a.notices.Send(r.Context(), localpart, req.Content)
|
||||
switch {
|
||||
case errors.Is(err, servernotices.ErrNoSuchUser):
|
||||
return httputil.NotFound("No such user")
|
||||
case errors.Is(err, servernotices.ErrDeactivated):
|
||||
return httputil.Forbidden("%s is deactivated", req.UserID)
|
||||
case err != nil:
|
||||
return roomError(err)
|
||||
}
|
||||
a.audit(r.Context(), auth, auditServerNotice, a.users.UserID(localpart), map[string]any{
|
||||
"room_id": roomID, "event_id": eventID,
|
||||
})
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]string{"room_id": roomID, "event_id": eventID})
|
||||
return nil
|
||||
}
|
||||
@@ -141,8 +141,7 @@ func (a *AdminAPI) handleDeleteRoom(w http.ResponseWriter, r *http.Request, auth
|
||||
if err := a.rooms.PurgeRoom(r.Context(), roomID); err != nil {
|
||||
return roomError(err)
|
||||
}
|
||||
a.log.Warn("room purged through the admin API",
|
||||
"room_id", roomID, "by", auth.UserID)
|
||||
a.audit(r.Context(), auth, auditRoomPurge, roomID, nil)
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
@@ -176,8 +175,7 @@ func (a *AdminAPI) handleForceLeave(w http.ResponseWriter, r *http.Request, auth
|
||||
if err := a.rooms.ForceLeave(r.Context(), roomID, req.UserID); err != nil {
|
||||
return roomError(err)
|
||||
}
|
||||
a.log.Warn("user removed from a room through the admin API",
|
||||
"room_id", roomID, "user_id", req.UserID, "by", auth.UserID)
|
||||
a.audit(r.Context(), auth, auditRoomForceLeave, roomID, map[string]any{"user_id": req.UserID})
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// registrationToken is a token as the API shows it.
|
||||
type registrationToken struct {
|
||||
Token string `json:"token"`
|
||||
|
||||
// UsesAllowed is null for a token with no use limit.
|
||||
UsesAllowed *int `json:"uses_allowed"`
|
||||
Completed int `json:"completed"`
|
||||
|
||||
// ExpiryTimeMS is null for a token that never expires.
|
||||
ExpiryTimeMS *int64 `json:"expiry_time_ms"`
|
||||
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
|
||||
// Valid is whether a registration could use the token now.
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func (a *AdminAPI) registrationTokenOf(t storage.RegistrationToken) registrationToken {
|
||||
out := registrationToken{
|
||||
Token: t.Token,
|
||||
UsesAllowed: t.UsesAllowed,
|
||||
Completed: t.Completed,
|
||||
CreatedAtMS: t.CreatedAtMS,
|
||||
Valid: t.Usable(a.now().UnixMilli()),
|
||||
}
|
||||
if t.ExpiresAtMS != 0 {
|
||||
expiry := t.ExpiresAtMS
|
||||
out.ExpiryTimeMS = &expiry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleGetRegistrationTokens implements GET /_warp/admin/v1/registration_tokens.
|
||||
//
|
||||
// The tokens of the config file are not listed. They are in the config
|
||||
// file, and listing them here would be the one place a secret from it is
|
||||
// served over HTTP.
|
||||
func (a *AdminAPI) handleGetRegistrationTokens(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
|
||||
tokens, err := a.tokens.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := make([]registrationToken, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
out = append(out, a.registrationTokenOf(t))
|
||||
}
|
||||
httputil.NoCache(w)
|
||||
httputil.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"registration_tokens": out,
|
||||
// Whether the sign-up flow asks for a token at all. A token made
|
||||
// on a server that does not ask for one is never needed.
|
||||
"required": a.tokens.Required(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTokenRequest is the POST /registration_tokens body.
|
||||
type createTokenRequest struct {
|
||||
// Token is the token to make; empty generates one of Length
|
||||
// characters, 16 if that is absent too.
|
||||
Token string `json:"token"`
|
||||
Length int `json:"length"`
|
||||
|
||||
UsesAllowed *int `json:"uses_allowed"`
|
||||
ExpiryTimeMS *int64 `json:"expiry_time_ms"`
|
||||
}
|
||||
|
||||
// handlePostRegistrationToken implements POST /_warp/admin/v1/registration_tokens.
|
||||
func (a *AdminAPI) handlePostRegistrationToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
var req createTokenRequest
|
||||
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
var expiry int64
|
||||
if req.ExpiryTimeMS != nil {
|
||||
expiry = *req.ExpiryTimeMS
|
||||
}
|
||||
created, err := a.tokens.CreateRegistrationToken(r.Context(), userapi.NewRegistrationToken{
|
||||
Token: req.Token,
|
||||
Length: req.Length,
|
||||
UsesAllowed: req.UsesAllowed,
|
||||
ExpiresAtMS: expiry,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, userapi.ErrInvalidRegistrationToken):
|
||||
return httputil.InvalidParam("%s", err.Error())
|
||||
case errors.Is(err, userapi.ErrRegistrationTokenExists):
|
||||
return httputil.InvalidParam("That registration token already exists")
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
a.audit(r.Context(), auth, auditTokenCreate, created.Token, map[string]any{
|
||||
"uses_allowed": created.UsesAllowed, "expiry_time_ms": created.ExpiresAtMS,
|
||||
})
|
||||
httputil.WriteJSON(w, http.StatusCreated, a.registrationTokenOf(created))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetRegistrationToken implements GET /_warp/admin/v1/registration_tokens/{token}.
|
||||
func (a *AdminAPI) handleGetRegistrationToken(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
|
||||
t, err := a.tokens.RegistrationToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
return tokenError(err)
|
||||
}
|
||||
httputil.NoCache(w)
|
||||
httputil.WriteJSON(w, http.StatusOK, a.registrationTokenOf(t))
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateTokenRequest is the PUT /registration_tokens/{token} body. Each
|
||||
// field is raw so that "absent" (keep it) can be told from null (remove
|
||||
// the limit).
|
||||
type updateTokenRequest struct {
|
||||
UsesAllowed json.RawMessage `json:"uses_allowed"`
|
||||
ExpiryTimeMS json.RawMessage `json:"expiry_time_ms"`
|
||||
}
|
||||
|
||||
// handlePutRegistrationToken implements PUT /_warp/admin/v1/registration_tokens/{token}.
|
||||
//
|
||||
// A field left out keeps its value; a field set to null removes the
|
||||
// limit. Raising uses_allowed on a used-up token makes it usable again.
|
||||
func (a *AdminAPI) handlePutRegistrationToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
token := r.PathValue("token")
|
||||
current, err := a.tokens.RegistrationToken(r.Context(), token)
|
||||
if err != nil {
|
||||
return tokenError(err)
|
||||
}
|
||||
var req updateTokenRequest
|
||||
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uses, expiry := current.UsesAllowed, current.ExpiresAtMS
|
||||
if len(req.UsesAllowed) > 0 {
|
||||
if string(req.UsesAllowed) == "null" {
|
||||
uses = nil
|
||||
} else {
|
||||
var n int
|
||||
if err := json.Unmarshal(req.UsesAllowed, &n); err != nil {
|
||||
return httputil.InvalidParam("uses_allowed must be a number or null")
|
||||
}
|
||||
uses = &n
|
||||
}
|
||||
}
|
||||
if len(req.ExpiryTimeMS) > 0 {
|
||||
if string(req.ExpiryTimeMS) == "null" {
|
||||
expiry = 0
|
||||
} else if err := json.Unmarshal(req.ExpiryTimeMS, &expiry); err != nil {
|
||||
return httputil.InvalidParam("expiry_time_ms must be a number or null")
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := a.tokens.Update(r.Context(), token, uses, expiry)
|
||||
if err != nil {
|
||||
return tokenError(err)
|
||||
}
|
||||
a.audit(r.Context(), auth, auditTokenUpdate, token, map[string]any{
|
||||
"uses_allowed": updated.UsesAllowed, "expiry_time_ms": updated.ExpiresAtMS,
|
||||
})
|
||||
httputil.WriteJSON(w, http.StatusOK, a.registrationTokenOf(updated))
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDeleteRegistrationToken implements DELETE
|
||||
// /_warp/admin/v1/registration_tokens/{token}. Accounts registered with
|
||||
// the token are not affected.
|
||||
func (a *AdminAPI) handleDeleteRegistrationToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
token := r.PathValue("token")
|
||||
if err := a.tokens.Delete(r.Context(), token); err != nil {
|
||||
return tokenError(err)
|
||||
}
|
||||
a.audit(r.Context(), auth, auditTokenDelete, token, nil)
|
||||
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func tokenError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, userapi.ErrNoSuchRegistrationToken):
|
||||
return httputil.NotFound("No such registration token")
|
||||
case errors.Is(err, userapi.ErrInvalidRegistrationToken):
|
||||
return httputil.InvalidParam("%s", err.Error())
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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)
|
||||
}
|
||||
@@ -128,8 +128,8 @@ func (a *AdminAPI) handlePostUsers(w http.ResponseWriter, r *http.Request, auth
|
||||
return err
|
||||
}
|
||||
|
||||
a.log.Info("account created through the admin API",
|
||||
"user_id", a.users.UserID(account.Localpart), "admin", account.Admin, "by", auth.UserID)
|
||||
a.audit(r.Context(), auth, auditUserCreate, a.users.UserID(account.Localpart),
|
||||
map[string]any{"admin": account.Admin})
|
||||
|
||||
httputil.WriteJSON(w, http.StatusCreated, adminUser{
|
||||
UserID: a.users.UserID(account.Localpart),
|
||||
|
||||
Reference in new issue
Block a user