package infra import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" ) // SynapseAdminClient wraps the Synapse Admin API (/_synapse/admin/...) for user and room management. type SynapseAdminClient struct { HomeserverURL string // e.g. https://matrix-af2f3d.organic-machine.com AdminToken string // access_token of a user with admin:true in Synapse HTTPClient *http.Client // optional; default 30s timeout } // NewSynapseAdminClient creates a client with sensible defaults. func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient { return &SynapseAdminClient{ HomeserverURL: homeserver, AdminToken: adminToken, HTTPClient: &http.Client{Timeout: 30 * time.Second}, } } // AdminUser represents a Synapse user as returned by the admin API. type AdminUser struct { UserID string `json:"name"` DisplayName string `json:"displayname"` AvatarURL string `json:"avatar_url"` Admin bool `json:"admin"` Deactivated bool `json:"deactivated"` IsGuest bool `json:"is_guest"` CreationTs int64 `json:"creation_ts"` LastSeenTs int64 `json:"last_seen_ts"` } // ListUsersFilter controls pagination and filtering for ListUsers. type ListUsersFilter struct { From int // pagination offset Limit int // default 100 SearchTerm string // filter by name / user_id Deactivated *bool // nil = both, true/false to filter Admins *bool // nil = both, true/false to filter } // ListUsersResult holds a page of users plus pagination metadata. type ListUsersResult struct { Users []AdminUser TotalCount int NextToken *int // nil if last page } // AdminRoom represents a Synapse room as returned by the admin API. type AdminRoom struct { RoomID string `json:"room_id"` Name string `json:"name"` CanonicalAlias string `json:"canonical_alias"` JoinedMembers int `json:"joined_members"` JoinedLocal int `json:"joined_local_members"` Version string `json:"version"` Encrypted bool `json:"encryption_enabled"` Federatable bool `json:"federatable"` Public bool `json:"public"` } // AdminDevice represents a device belonging to a Synapse user. type AdminDevice struct { DeviceID string `json:"device_id"` DisplayName string `json:"display_name"` LastSeenIP string `json:"last_seen_ip"` LastSeenTs int64 `json:"last_seen_ts"` } // synapseError is the error envelope returned by Synapse for 4xx/5xx responses. type synapseError struct { ErrCode string `json:"errcode"` ErrMsg string `json:"error"` } // client returns the HTTPClient, falling back to a 30-second default. func (c *SynapseAdminClient) client() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } return &http.Client{Timeout: 30 * time.Second} } // do executes an authenticated request and returns the raw response body. // Returns an error for HTTP >= 400, including the Synapse errcode when present. func (c *SynapseAdminClient) do(ctx context.Context, method, path string, body interface{}) ([]byte, error) { var bodyReader io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("synapse_admin: marshal request body: %w", err) } bodyReader = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.HomeserverURL+path, bodyReader) if err != nil { return nil, fmt.Errorf("synapse_admin: build request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.AdminToken) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.client().Do(req) if err != nil { return nil, fmt.Errorf("synapse_admin: http %s %s: %w", method, path, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("synapse_admin: read response: %w", err) } if resp.StatusCode >= 500 { var se synapseError if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" { return nil, fmt.Errorf("synapse_admin: synapse internal %d %s: %s", resp.StatusCode, se.ErrCode, se.ErrMsg) } return nil, fmt.Errorf("synapse_admin: synapse internal: %d", resp.StatusCode) } if resp.StatusCode >= 400 { var se synapseError if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" { return nil, fmt.Errorf("synapse_admin: %s %s → %d %s: %s", method, path, resp.StatusCode, se.ErrCode, se.ErrMsg) } return nil, fmt.Errorf("synapse_admin: %s %s → HTTP %d", method, path, resp.StatusCode) } return data, nil } // --- Users --- // ListUsers returns a page of users matching the given filter. // Use ListUsersResult.NextToken to paginate: set ListUsersFilter.From = *NextToken on the next call. func (c *SynapseAdminClient) ListUsers(ctx context.Context, f ListUsersFilter) (*ListUsersResult, error) { limit := f.Limit if limit <= 0 { limit = 100 } q := url.Values{} q.Set("from", strconv.Itoa(f.From)) q.Set("limit", strconv.Itoa(limit)) if f.SearchTerm != "" { q.Set("user_id", f.SearchTerm) } if f.Deactivated != nil { q.Set("deactivated", strconv.FormatBool(*f.Deactivated)) } if f.Admins != nil { q.Set("admins", strconv.FormatBool(*f.Admins)) } path := "/_synapse/admin/v2/users?" + q.Encode() data, err := c.do(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } var raw struct { Users []AdminUser `json:"users"` Total int `json:"total"` NextToken *int `json:"next_token"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("synapse_admin: ListUsers decode: %w", err) } return &ListUsersResult{ Users: raw.Users, TotalCount: raw.Total, NextToken: raw.NextToken, }, nil } // GetUser returns the admin view of a single user by their full Matrix ID (e.g. @user:server). func (c *SynapseAdminClient) GetUser(ctx context.Context, userID string) (*AdminUser, error) { path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) data, err := c.do(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } var u AdminUser if err := json.Unmarshal(data, &u); err != nil { return nil, fmt.Errorf("synapse_admin: GetUser decode: %w", err) } return &u, nil } // DeactivateUser deactivates a user account. // If erase=true, Synapse purges all user data — IRREVERSIBLE. func (c *SynapseAdminClient) DeactivateUser(ctx context.Context, userID string, erase bool) error { path := "/_synapse/admin/v1/deactivate/" + url.PathEscape(userID) _, err := c.do(ctx, http.MethodPost, path, map[string]bool{"erase": erase}) return err } // ResetPassword sets a new password for the given user. // If logoutDevices=true, all existing sessions are invalidated. func (c *SynapseAdminClient) ResetPassword(ctx context.Context, userID, newPassword string, logoutDevices bool) error { path := "/_synapse/admin/v1/reset_password/" + url.PathEscape(userID) body := map[string]interface{}{ "new_password": newPassword, "logout_devices": logoutDevices, } _, err := c.do(ctx, http.MethodPost, path, body) return err } // --- Rooms --- // ListRooms returns a page of rooms. // from and limit control pagination; searchTerm filters by room name/alias. func (c *SynapseAdminClient) ListRooms(ctx context.Context, from, limit int, searchTerm string) (rooms []AdminRoom, total int, nextToken *int, err error) { if limit <= 0 { limit = 100 } q := url.Values{} q.Set("from", strconv.Itoa(from)) q.Set("limit", strconv.Itoa(limit)) q.Set("order_by", "name") if searchTerm != "" { q.Set("search_term", searchTerm) } path := "/_synapse/admin/v1/rooms?" + q.Encode() data, err := c.do(ctx, http.MethodGet, path, nil) if err != nil { return nil, 0, nil, err } var raw struct { Rooms []AdminRoom `json:"rooms"` TotalRooms int `json:"total_rooms"` NextBatch *int `json:"next_batch"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, 0, nil, fmt.Errorf("synapse_admin: ListRooms decode: %w", err) } return raw.Rooms, raw.TotalRooms, raw.NextBatch, nil } // GetRoom returns the admin view of a single room by its room ID (e.g. !room:server). func (c *SynapseAdminClient) GetRoom(ctx context.Context, roomID string) (*AdminRoom, error) { path := "/_synapse/admin/v1/rooms/" + url.PathEscape(roomID) data, err := c.do(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } var r AdminRoom if err := json.Unmarshal(data, &r); err != nil { return nil, fmt.Errorf("synapse_admin: GetRoom decode: %w", err) } return &r, nil } // DeleteRoom schedules an async room deletion. Returns the delete_id for status polling. // purge=true destroys all messages and state (IRREVERSIBLE). // block=true prevents new users from joining after deletion. func (c *SynapseAdminClient) DeleteRoom(ctx context.Context, roomID, reason string, purge, block bool) (deleteID string, err error) { path := "/_synapse/admin/v2/rooms/" + url.PathEscape(roomID) body := map[string]interface{}{ "new_room_user_id": nil, "purge": purge, "block": block, "message": reason, } data, err := c.do(ctx, http.MethodDelete, path, body) if err != nil { return "", err } var raw struct { DeleteID string `json:"delete_id"` } if err := json.Unmarshal(data, &raw); err != nil { return "", fmt.Errorf("synapse_admin: DeleteRoom decode: %w", err) } return raw.DeleteID, nil } // --- Devices --- // ListUserDevices returns all devices registered for the given user. func (c *SynapseAdminClient) ListUserDevices(ctx context.Context, userID string) ([]AdminDevice, error) { path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices" data, err := c.do(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } var raw struct { Devices []AdminDevice `json:"devices"` Total int `json:"total"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("synapse_admin: ListUserDevices decode: %w", err) } return raw.Devices, nil } // DeleteUserDevice removes a specific device from a user's account. func (c *SynapseAdminClient) DeleteUserDevice(ctx context.Context, userID, deviceID string) error { path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices/" + url.PathEscape(deviceID) _, err := c.do(ctx, http.MethodDelete, path, nil) return err }