package adminapi import ( "errors" "net/http" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/matrix/id" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // adminRoom is one row of the room list. Like the user list it is a flat // shape, because the panel shows it as a table. type adminRoom struct { RoomID string `json:"room_id"` RoomVersion string `json:"room_version"` Creator string `json:"creator"` Name string `json:"name,omitempty"` Topic string `json:"topic,omitempty"` CanonicalAlias string `json:"canonical_alias,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` RoomType string `json:"room_type,omitempty"` JoinRule string `json:"join_rule,omitempty"` HistoryVisibility string `json:"history_visibility,omitempty"` GuestAccess string `json:"guest_access,omitempty"` Published bool `json:"published"` JoinedMembers int `json:"joined_members"` CreatedAtMS int64 `json:"created_at_ms"` } type roomsResponse struct { Rooms []adminRoom `json:"rooms"` // Total is the number of rooms matching the query, not the number // returned, so the panel can page. Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // defaultRoomPageLimit is what a request without ?limit= gets. const defaultRoomPageLimit = 50 // handleGetRooms implements GET /_warp/admin/v1/rooms. func (a *AdminAPI) handleGetRooms(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { if err := a.requireRooms(); err != nil { return err } query := r.URL.Query() limit, err := intParam(query.Get("limit"), defaultRoomPageLimit) if err != nil { return httputil.InvalidParam("limit must be a number") } offset, err := intParam(query.Get("offset"), 0) if err != nil { return httputil.InvalidParam("offset must be a number") } if limit < 0 || offset < 0 { return httputil.InvalidParam("limit and offset must not be negative") } summaries, total, err := a.rooms.ListRooms(r.Context(), storage.RoomFilter{ Search: query.Get("search"), Limit: limit, Offset: offset, }) if err != nil { return err } resp := roomsResponse{ Rooms: make([]adminRoom, 0, len(summaries)), Total: total, Limit: limit, Offset: offset, } for _, summary := range summaries { resp.Rooms = append(resp.Rooms, adminRoomOf(summary)) } httputil.WriteJSON(w, http.StatusOK, resp) return nil } // roomDetailResponse is GET /_warp/admin/v1/rooms/{roomID}: the list row // plus what only a count can answer. type roomDetailResponse struct { adminRoom Members int `json:"members"` InvitedMembers int `json:"invited_members"` Aliases []string `json:"aliases"` // ForwardExtremities is where the room's DAG currently ends. There is // normally exactly one; more than one means the room has forked, which // is worth an operator seeing. ForwardExtremities []string `json:"forward_extremities"` } // handleGetRoom implements GET /_warp/admin/v1/rooms/{roomID}. func (a *AdminAPI) handleGetRoom(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error { roomID, err := a.pathRoomID(r) if err != nil { return err } details, err := a.rooms.RoomDetails(r.Context(), roomID) if err != nil { return roomError(err) } httputil.WriteJSON(w, http.StatusOK, roomDetailResponse{ adminRoom: adminRoomOf(details.RoomSummary), Members: details.Members, InvitedMembers: details.InvitedMembers, Aliases: details.Aliases, ForwardExtremities: details.ForwardExtremities, }) return nil } // handleDeleteRoom implements DELETE /_warp/admin/v1/rooms/{roomID}: the // purge. // // It is a DELETE on the room rather than a POST to an action, because that // is what it is, and because the method makes a panel's confirm dialog // mandatory in a way a POST does not. There is no undo: the room's events // are deleted, and the members are not told, because the only way to tell // them would have been to add an event to the room that no longer exists. func (a *AdminAPI) handleDeleteRoom(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { roomID, err := a.pathRoomID(r) if err != nil { return err } if err := a.rooms.PurgeRoom(r.Context(), roomID); err != nil { return roomError(err) } a.audit(r.Context(), auth, auditRoomPurge, roomID, nil) httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // forceLeaveRequest is the POST /rooms/{roomID}/force_leave body. type forceLeaveRequest struct { UserID string `json:"user_id"` } // handleForceLeave implements POST /_warp/admin/v1/rooms/{roomID}/force_leave. // // It makes a user leave a room without asking the room's rules whether they // may. A user stuck in a room — one whose power levels are broken, or whose // last moderator has gone — is what this exists for, and in such a room the // rules are exactly what is in the way. It is still an ordinary membership // event, signed and added to the room, so the other members see what // happened. func (a *AdminAPI) handleForceLeave(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { roomID, err := a.pathRoomID(r) if err != nil { return err } var req forceLeaveRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if req.UserID == "" { return httputil.MissingParam("A user ID is required") } if err := a.rooms.ForceLeave(r.Context(), roomID, req.UserID); err != nil { return roomError(err) } a.audit(r.Context(), auth, auditRoomForceLeave, roomID, map[string]any{"user_id": req.UserID}) httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // adminRoomOf projects a stored room onto the list row. func adminRoomOf(summary storage.RoomSummary) adminRoom { return adminRoom{ RoomID: summary.ID, RoomVersion: summary.Version, Creator: summary.Creator, Name: summary.Name, Topic: summary.Topic, CanonicalAlias: summary.CanonicalAlias, AvatarURL: summary.AvatarURL, RoomType: summary.RoomType, JoinRule: summary.JoinRule, HistoryVisibility: summary.HistoryVisibility, GuestAccess: summary.GuestAccess, Published: summary.Published, JoinedMembers: summary.JoinedMembers, CreatedAtMS: summary.CreatedAtMS, } } // pathRoomID reads the {roomID} path value and insists there is a // roomserver to ask about it. func (a *AdminAPI) pathRoomID(r *http.Request) (string, error) { if err := a.requireRooms(); err != nil { return "", err } roomID := r.PathValue("roomID") if err := id.ValidateRoomID(roomID); err != nil { return "", httputil.InvalidParam("%s is not a valid room ID", roomID) } return roomID, nil } // requireRooms refuses a room endpoint on a server that started without a // signing key, and so without a roomserver. func (a *AdminAPI) requireRooms() error { if a.rooms == nil { return httputil.Unknown(http.StatusServiceUnavailable, "This server has no signing key, so it has no rooms") } return nil } // roomError maps a roomserver error onto a Matrix error. The admin API has // a shorter list than the Client-Server one, because an admin endpoint // never hits the authorisation rules. func roomError(err error) error { var matrixErr *httputil.MatrixError switch { case err == nil: return nil case errors.As(err, &matrixErr): return err case errors.Is(err, roomserver.ErrNoSuchRoom): return httputil.NotFound("Unknown room") case errors.Is(err, roomserver.ErrNoSuchEvent): return httputil.NotFound("Unknown event") case errors.Is(err, roomserver.ErrForbidden), errors.Is(err, roomserver.ErrRejected): return httputil.Forbidden("%s", err.Error()) case errors.Is(err, roomserver.ErrInvalidContent): return httputil.BadJSON("%s", err.Error()) default: return err } }