Files
fn_registry/functions/infra/synapse_admin_client.go
egutierrez 621e8895c9 feat(infra): auto-commit con 86 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:15 +02:00

324 lines
10 KiB
Go

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
}