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>
488 lines
18 KiB
Go
488 lines
18 KiB
Go
// Package adminapi serves /_warp/admin/v1: the API behind the admin panel.
|
|
//
|
|
// It is deliberately not part of the Matrix spec. Everything a client needs
|
|
// goes through the Client-Server API; this is the operator's surface, and
|
|
// the only thing in Warp that is ours rather than Matrix's.
|
|
//
|
|
// Authentication is a normal Matrix access token belonging to an account
|
|
// with the admin flag, so the panel logs in the way any client does and
|
|
// there is no second credential to manage.
|
|
//
|
|
// 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"
|
|
)
|
|
|
|
// Prefix is the path every admin endpoint lives under.
|
|
const Prefix = "/_warp/admin/v1"
|
|
|
|
// AdminAPI serves the admin endpoints.
|
|
type AdminAPI struct {
|
|
serverName string
|
|
users *userapi.API
|
|
rooms *roomserver.API
|
|
settings *settings.Service
|
|
federation *federation.Gate
|
|
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.
|
|
type Options struct {
|
|
ServerName string
|
|
Users *userapi.API
|
|
|
|
// Rooms is the roomserver component. A server that started without a
|
|
// signing key has none, and the room endpoints then report that rather
|
|
// than failing on the first call.
|
|
Rooms *roomserver.API
|
|
|
|
Settings *settings.Service
|
|
Federation *federation.Gate
|
|
|
|
// FederationStatus reports on the federation module. Nil on a server
|
|
// without one, and the destinations endpoints then say so.
|
|
FederationStatus FederationStatus
|
|
|
|
// 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.
|
|
func New(opts Options) (*AdminAPI, error) {
|
|
switch {
|
|
case opts.ServerName == "":
|
|
return nil, errors.New("adminapi: ServerName must not be empty")
|
|
case opts.Users == nil:
|
|
return nil, errors.New("adminapi: Users must not be nil")
|
|
case opts.Settings == nil:
|
|
return nil, errors.New("adminapi: Settings must not be nil")
|
|
}
|
|
if opts.Federation == nil {
|
|
opts.Federation = federation.NewGate(nil, nil)
|
|
}
|
|
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,
|
|
now: opts.Now,
|
|
media: opts.Media,
|
|
appservices: opts.AppServices,
|
|
notices: opts.Notices,
|
|
tokens: opts.RegistrationTokens,
|
|
adminStore: adminStore,
|
|
}, nil
|
|
}
|
|
|
|
// RegisterRoutes adds the admin endpoints to the router.
|
|
func (a *AdminAPI) RegisterRoutes(r *httputil.Router) {
|
|
a.handle(r, "GET "+Prefix+"/federation", a.handleGetFederation)
|
|
a.handle(r, "PUT "+Prefix+"/federation", a.handlePutFederation)
|
|
a.handle(r, "GET "+Prefix+"/federation/destinations", a.handleGetDestinations)
|
|
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.
|
|
type adminHandler func(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error
|
|
|
|
// handle registers an admin-only endpoint.
|
|
//
|
|
// Admin endpoints are not rate limited. They are reachable only with an
|
|
// admin token, and an operator bulk-loading users through the panel is
|
|
// exactly the traffic a limiter would get in the way of.
|
|
func (a *AdminAPI) handle(r *httputil.Router, pattern string, h adminHandler) {
|
|
r.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) error {
|
|
auth, err := a.requireAdmin(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return h(w, req, auth)
|
|
})
|
|
}
|
|
|
|
// requireAdmin resolves the access token and insists on the admin flag.
|
|
//
|
|
// A non-admin token gets M_FORBIDDEN rather than a 404: the caller is a
|
|
// legitimate user of this server who is not allowed here, and pretending
|
|
// the endpoint does not exist would only make the panel harder to debug.
|
|
func (a *AdminAPI) requireAdmin(r *http.Request) (userapi.Authenticated, error) {
|
|
token := accessTokenFrom(r)
|
|
if token == "" {
|
|
return userapi.Authenticated{}, httputil.MissingToken()
|
|
}
|
|
|
|
auth, err := a.users.Authenticate(r.Context(), token)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, userapi.ErrNoSuchToken):
|
|
return userapi.Authenticated{}, httputil.UnknownToken(false)
|
|
case errors.Is(err, userapi.ErrTokenExpired):
|
|
return userapi.Authenticated{}, httputil.UnknownToken(true)
|
|
case errors.Is(err, userapi.ErrAccountDeactivated):
|
|
return userapi.Authenticated{}, httputil.UserDeactivated()
|
|
default:
|
|
return userapi.Authenticated{}, err
|
|
}
|
|
|
|
if !auth.Admin {
|
|
return userapi.Authenticated{}, httputil.Forbidden("This endpoint requires a server administrator")
|
|
}
|
|
return auth, nil
|
|
}
|
|
|
|
// accessTokenFrom reads the bearer token. The admin panel is a normal
|
|
// Matrix client, so it sends the header form; the query parameter is not
|
|
// accepted here, because an admin token in a URL ends up in access logs.
|
|
func accessTokenFrom(r *http.Request) string {
|
|
header := r.Header.Get("Authorization")
|
|
const prefix = "Bearer "
|
|
if len(header) <= len(prefix) || !equalFoldASCII(header[:len(prefix)], prefix) {
|
|
return ""
|
|
}
|
|
return header[len(prefix):]
|
|
}
|
|
|
|
// equalFoldASCII compares two ASCII strings case-insensitively. The scheme
|
|
// of an Authorization header is case-insensitive per RFC 9110.
|
|
func equalFoldASCII(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range len(a) {
|
|
x, y := a[i], b[i]
|
|
if 'A' <= x && x <= 'Z' {
|
|
x += 'a' - 'A'
|
|
}
|
|
if 'A' <= y && y <= 'Z' {
|
|
y += 'a' - 'A'
|
|
}
|
|
if x != y {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// switchResponse is the shape both runtime switches report.
|
|
type switchResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// Toggleable is false when the config file pinned the setting, in
|
|
// which case the panel shows the switch greyed out rather than letting
|
|
// an operator flip something that will bounce.
|
|
Toggleable bool `json:"toggleable"`
|
|
}
|
|
|
|
// switchRequest is the body of either PUT.
|
|
type switchRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
// federationResponse adds the allow and deny lists to the switch.
|
|
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.
|
|
func (a *AdminAPI) handleGetFederation(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
|
|
allowlist, denylist := a.federation.Policy().Lists()
|
|
httputil.WriteJSON(w, http.StatusOK, federationResponse{
|
|
switchResponse: switchResponse{
|
|
Enabled: a.settings.FederationEnabled(),
|
|
Toggleable: a.settings.FederationToggleable(),
|
|
},
|
|
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.
|
|
func (a *AdminAPI) handlePutFederation(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
enabled, err := decodeSwitch(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.settings.SetFederationEnabled(r.Context(), enabled); err != nil {
|
|
return toggleError(err, "federation")
|
|
}
|
|
a.audit(r.Context(), auth, auditFederationSwitch, "", map[string]any{"enabled": enabled})
|
|
return a.handleGetFederation(w, r, auth)
|
|
}
|
|
|
|
// handleGetRegistration implements GET /_warp/admin/v1/registration.
|
|
func (a *AdminAPI) handleGetRegistration(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
|
|
httputil.WriteJSON(w, http.StatusOK, switchResponse{
|
|
Enabled: a.settings.RegistrationEnabled(),
|
|
Toggleable: a.settings.RegistrationToggleable(),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// handlePutRegistration implements PUT /_warp/admin/v1/registration.
|
|
func (a *AdminAPI) handlePutRegistration(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
enabled, err := decodeSwitch(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.settings.SetRegistrationEnabled(r.Context(), enabled); err != nil {
|
|
return toggleError(err, "registration")
|
|
}
|
|
a.audit(r.Context(), auth, auditRegistrationSwitch, "", map[string]any{"enabled": enabled})
|
|
return a.handleGetRegistration(w, r, auth)
|
|
}
|
|
|
|
// decodeSwitch reads {"enabled": bool}. The field is a pointer so that a
|
|
// body without it is a missing parameter rather than a silent "false".
|
|
func decodeSwitch(w http.ResponseWriter, r *http.Request) (bool, error) {
|
|
var req switchRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return false, err
|
|
}
|
|
if req.Enabled == nil {
|
|
return false, httputil.MissingParam("enabled is required")
|
|
}
|
|
return *req.Enabled, nil
|
|
}
|
|
|
|
// toggleError turns a refused toggle into the right answer: a setting the
|
|
// config file pinned is a 409, because the request was well formed and the
|
|
// server's configuration is what stands in the way.
|
|
func toggleError(err error, name string) error {
|
|
if errors.Is(err, settings.ErrNotToggleable) {
|
|
return httputil.NewError(http.StatusConflict, httputil.ErrCodeForbidden,
|
|
"The %s switch is pinned by the configuration file (%s.allow_runtime_toggle is false)",
|
|
name, name)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// statsResponse is the dashboard payload: PLAN.md section 8.1 lists users,
|
|
// rooms, events, federation status, storage driver and version as the first
|
|
// thing the panel shows. The room and event counters join it in Phase 3,
|
|
// when there is something to count.
|
|
type statsResponse struct {
|
|
ServerName string `json:"server_name"`
|
|
Version string `json:"version"`
|
|
Database string `json:"database"`
|
|
|
|
Users int `json:"users"`
|
|
Admins int `json:"admins"`
|
|
Deactivated int `json:"deactivated"`
|
|
Devices int `json:"devices"`
|
|
|
|
Rooms int `json:"rooms"`
|
|
PublishedRooms int `json:"published_rooms"`
|
|
Events int `json:"events"`
|
|
|
|
FederationEnabled bool `json:"federation_enabled"`
|
|
RegistrationEnabled bool `json:"registration_enabled"`
|
|
}
|
|
|
|
// handleGetStats implements GET /_warp/admin/v1/stats.
|
|
func (a *AdminAPI) handleGetStats(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
|
|
stats, err := a.users.Stats(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp := statsResponse{
|
|
ServerName: a.serverName,
|
|
Version: buildinfo.Version,
|
|
Users: stats.Users,
|
|
Admins: stats.Admins,
|
|
Deactivated: stats.Deactivated,
|
|
Devices: stats.Devices,
|
|
FederationEnabled: a.settings.FederationEnabled(),
|
|
RegistrationEnabled: a.settings.RegistrationEnabled(),
|
|
}
|
|
if a.database != nil {
|
|
resp.Database = a.database.Driver()
|
|
}
|
|
if a.rooms != nil {
|
|
roomStats, err := a.rooms.Stats(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.Rooms = roomStats.Rooms
|
|
resp.PublishedRooms = roomStats.Published
|
|
resp.Events = roomStats.Events
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|