package roomserver import ( "context" "fmt" "strconv" "strings" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/matrix/id" "github.com/dariuszjaniak/warp/server/internal/storage" ) // MaxAliasLength is the spec's cap on a room alias, sigil and server name // included. const MaxAliasLength = id.MaxLength // aliasFor builds a full local alias from the localpart a client supplied, // and checks it. func (r *API) aliasFor(localpart string) (string, error) { alias := "#" + localpart + ":" + r.serverName if err := r.validateLocalAlias(alias); err != nil { return "", err } return alias, nil } // validateLocalAlias checks an alias and insists it belongs to this server. // // The grammar is deliberately the loose one the spec gives for an alias — // anything but a colon, and no control characters — rather than the // localpart rules an account is held to. An alias is a label a room's // members chose, and the spec puts no character restrictions on it beyond // what parsing needs. func (r *API) validateLocalAlias(alias string) error { if err := validateAliasGrammar(alias); err != nil { return err } _, serverName, _ := strings.Cut(alias[1:], ":") if serverName != r.serverName { return fmt.Errorf("%w: %s is not an alias of this server", ErrForbidden, alias) } return nil } // validateAliasGrammar checks an alias of any server. func validateAliasGrammar(alias string) error { switch { case alias == "": return fmt.Errorf("%w: a room alias must not be empty", ErrInvalidContent) case alias[0] != '#': return fmt.Errorf("%w: a room alias must start with '#'", ErrInvalidContent) case len(alias) > MaxAliasLength: return fmt.Errorf("%w: a room alias may be at most %d bytes", ErrInvalidContent, MaxAliasLength) } localpart, serverName, found := strings.Cut(alias[1:], ":") switch { case !found: return fmt.Errorf("%w: %s has no server name; expected #alias:server", ErrInvalidContent, alias) case localpart == "": return fmt.Errorf("%w: %s has an empty localpart", ErrInvalidContent, alias) case serverName == "": return fmt.Errorf("%w: %s has an empty server name", ErrInvalidContent, alias) } // A control character in an alias would be invisible in every client // that shows it, which is exactly what makes it worth refusing. if strings.ContainsFunc(alias, func(c rune) bool { return c < 0x20 || c == 0x7f }) { return fmt.Errorf("%w: %s contains a control character", ErrInvalidContent, alias) } return nil } // isLocalAlias reports whether an alias belongs to this server. func (r *API) isLocalAlias(alias string) bool { if validateAliasGrammar(alias) != nil { return false } _, serverName, _ := strings.Cut(alias[1:], ":") return serverName == r.serverName } // CreateAlias points a local alias at a room. // // The caller has to be in the room. The spec leaves the permission to the // server, and "a member of the room may give it a name" is the rule that // matches what an alias is for while keeping a stranger from squatting // names against rooms they cannot see. func (r *API) CreateAlias(ctx context.Context, alias, roomID, userID string) error { if err := r.validateLocalAlias(alias); err != nil { return err } if err := id.ValidateRoomID(roomID); err != nil { return fmt.Errorf("%w: %v", ErrInvalidContent, err) } if _, err := r.store.Room(ctx, roomID); err != nil { return err } if err := r.requireJoined(ctx, roomID, userID); err != nil { return err } return r.store.CreateAlias(ctx, storage.RoomAlias{ Alias: alias, RoomID: roomID, Creator: userID, CreatedAtMS: r.nowMS(), }) } // DeleteAlias removes a local alias. // // Either the user who created it, or a user who could set the room's // canonical alias, may delete it — the first because it is theirs, the // second because the room's moderators have to be able to clean up after a // member who has left. func (r *API) DeleteAlias(ctx context.Context, alias, userID string, isAdmin bool) error { if err := r.validateLocalAlias(alias); err != nil { return err } stored, err := r.store.Alias(ctx, alias) if err != nil { return err } if !isAdmin && stored.Creator != userID { canSet, err := r.CanSendStateEvent(ctx, stored.RoomID, userID, TypeCanonicalAlias) if err != nil { return err } if !canSet { return fmt.Errorf("%w: %s may not delete the alias %s", ErrForbidden, userID, alias) } } return r.store.DeleteAlias(ctx, alias) } // ResolvedAlias is what a directory lookup returns. type ResolvedAlias struct { RoomID string // Servers are the servers known to be in the room, which a client // passes back as ?server_name= when it joins. With federation off the // only one is this server. Servers []string } // ResolveAlias turns a room alias into a room ID. // // A remote alias has to be asked of the server that owns it, which needs // federation; with it off the client is told that rather than being told // the alias does not exist, because the two call for quite different // reactions. func (r *API) ResolveAlias(ctx context.Context, alias string) (ResolvedAlias, error) { if err := validateAliasGrammar(alias); err != nil { return ResolvedAlias{}, err } if !r.isLocalAlias(alias) { if !r.federationEnabled() { return ResolvedAlias{}, fmt.Errorf( "%w: %s belongs to another server, and resolving a remote alias", ErrFederationDisabled, alias) } return ResolvedAlias{}, fmt.Errorf( "%w: resolving an alias of another server is not implemented yet", ErrFederationDisabled) } stored, err := r.store.Alias(ctx, alias) if err != nil { return ResolvedAlias{}, err } return ResolvedAlias{RoomID: stored.RoomID, Servers: []string{r.serverName}}, nil } // RoomAliases lists the local aliases of a room, for a user who may see it. func (r *API) RoomAliases(ctx context.Context, roomID, userID string) ([]string, error) { if _, err := r.requireReadable(ctx, roomID, userID); err != nil { return nil, err } aliases, err := r.store.AliasesForRoom(ctx, roomID) if err != nil { return nil, err } if aliases == nil { aliases = []string{} } return aliases, nil } // RoomVisibility reports whether a room is in this server's public // directory. func (r *API) RoomVisibility(ctx context.Context, roomID string) (string, error) { room, err := r.store.Room(ctx, roomID) if err != nil { return "", err } if room.Published { return VisibilityPublic, nil } return VisibilityPrivate, nil } // SetRoomVisibility publishes a room in this server's directory, or // withdraws it. // // The permission is the one the spec hints at and Synapse settled on: a // user who could set the room's canonical alias, or a server admin. An // unpublished room that nobody can find is recoverable; a room published by // a passer-by is not. func (r *API) SetRoomVisibility(ctx context.Context, roomID, userID, visibility string, isAdmin bool) error { switch visibility { case VisibilityPublic, VisibilityPrivate: default: return fmt.Errorf("%w: visibility must be %q or %q", ErrInvalidContent, VisibilityPublic, VisibilityPrivate) } if _, err := r.store.Room(ctx, roomID); err != nil { return err } if !isAdmin { canSet, err := r.CanSendStateEvent(ctx, roomID, userID, TypeCanonicalAlias) if err != nil { return err } if !canSet { return fmt.Errorf("%w: %s may not change whether %s is published", ErrForbidden, userID, roomID) } } return r.store.SetPublished(ctx, roomID, visibility == VisibilityPublic) } // PublicRoomsRequest pages and filters the public room directory. type PublicRoomsRequest struct { // Limit is how many rooms to return; 0 lets the store decide. Limit int // Since is the pagination token from a previous response. Since string // Search is the generic search term from the filter. Search string // Server is the ?server= parameter: another server's directory, which // needs federation. Server string } // PublicRoom is one entry in the public room directory. type PublicRoom struct { RoomID string `json:"room_id"` Name string `json:"name,omitempty"` Topic string `json:"topic,omitempty"` CanonicalAlias string `json:"canonical_alias,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` NumJoinedMembers int `json:"num_joined_members"` WorldReadable bool `json:"world_readable"` GuestCanJoin bool `json:"guest_can_join"` JoinRule string `json:"join_rule,omitempty"` RoomType string `json:"room_type,omitempty"` Aliases []string `json:"aliases,omitempty"` } // PublicRoomsResponse is the directory page. type PublicRoomsResponse struct { Chunk []PublicRoom `json:"chunk"` // NextBatch and PrevBatch are the tokens for the pages either side, // and are absent at the ends. NextBatch string `json:"next_batch,omitempty"` PrevBatch string `json:"prev_batch,omitempty"` TotalRoomCountEstimate int `json:"total_room_count_estimate"` } // PublicRooms returns a page of this server's public room directory. // // The pagination token is the offset, written out as a decimal. That is // enough for a directory ordered by member count and is honest about what // it is: a token that encoded a cursor would imply a stability the ordering // does not have, since a room gaining a member moves between pages whatever // the token says. func (r *API) PublicRooms(ctx context.Context, req PublicRoomsRequest) (PublicRoomsResponse, error) { if req.Server != "" && req.Server != r.serverName { if !r.federationEnabled() { return PublicRoomsResponse{}, fmt.Errorf( "%w: reading the room directory of %s", ErrFederationDisabled, req.Server) } return PublicRoomsResponse{}, fmt.Errorf( "%w: reading another server's room directory is not implemented yet", ErrFederationDisabled) } offset, err := parseDirectoryToken(req.Since) if err != nil { return PublicRoomsResponse{}, err } summaries, total, err := r.store.PublishedRooms(ctx, storage.RoomFilter{ Search: req.Search, Limit: req.Limit, Offset: offset, }) if err != nil { return PublicRoomsResponse{}, err } resp := PublicRoomsResponse{ Chunk: make([]PublicRoom, 0, len(summaries)), TotalRoomCountEstimate: total, } for _, summary := range summaries { aliases, err := r.store.AliasesForRoom(ctx, summary.ID) if err != nil { return PublicRoomsResponse{}, err } resp.Chunk = append(resp.Chunk, publicRoomOf(summary, aliases)) } if offset > 0 { resp.PrevBatch = directoryToken(max(offset-len(summaries), 0)) } if offset+len(summaries) < total { resp.NextBatch = directoryToken(offset + len(summaries)) } return resp, nil } // publicRoomOf projects a stored room onto a directory entry. func publicRoomOf(summary storage.RoomSummary, aliases []string) PublicRoom { return PublicRoom{ RoomID: summary.ID, Name: summary.Name, Topic: summary.Topic, CanonicalAlias: summary.CanonicalAlias, AvatarURL: summary.AvatarURL, NumJoinedMembers: summary.JoinedMembers, WorldReadable: summary.HistoryVisibility == HistoryVisibilityWorldReadable, GuestCanJoin: summary.GuestAccess == GuestAccessCanJoin, JoinRule: summary.JoinRule, RoomType: summary.RoomType, Aliases: aliases, } } // containsString reports whether needle is in haystack. func containsString(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false } // membershipFilterValues are the memberships a client may filter a member // list by. var membershipFilterValues = []string{ event.MembershipJoin, event.MembershipLeave, event.MembershipInvite, event.MembershipBan, event.MembershipKnock, } // ValidateMembershipFilter checks a ?membership= or ?not_membership= value. func ValidateMembershipFilter(value string) error { if value == "" || containsString(membershipFilterValues, value) { return nil } return fmt.Errorf("%w: %q is not a membership", ErrInvalidContent, value) } // maxDirectoryOffset bounds how far into the directory a pagination token // may point. The token comes back from a client, so it is input: without a // bound, "since=99999999999999" is an invitation to make the database count // past the end of a table. const maxDirectoryOffset = 1 << 20 // directoryToken renders a directory offset as a pagination token. func directoryToken(offset int) string { return strconv.Itoa(offset) } // parseDirectoryToken reads a pagination token back. func parseDirectoryToken(token string) (int, error) { if token == "" { return 0, nil } offset, err := strconv.Atoi(token) if err != nil || offset < 0 || offset > maxDirectoryOffset { return 0, fmt.Errorf("%w: %q is not a pagination token from this server", ErrInvalidContent, token) } return offset, nil }