Phase 3: rooms, local only
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 3: the roomserver, the room half of storage, the
Client-Server room endpoints and the admin room endpoints. A client can now
create a room, invite, join, leave, kick, ban, send messages and state,
redact, name a room with an alias and find one in the public directory.
Nothing talks to another server; every operation that would need to is
refused with a message that says so rather than a bare M_FORBIDDEN.

roomserver: one input pipeline every event goes through, whatever sent it —
validate, authorise, resolve, persist, emit. The rules are not
reimplemented here; internal/matrix is pure library code and this is what
connects it to a database and to a room's history. The Client-Server
handlers do no authorisation of their own, deliberately: whether Alice may
kick Bob is a question about the room's power levels, and the one place
that is answered correctly is the authorisation rules inside the pipeline.

A mutex per room serialises writers. Two events racing into one room would
each read the same forward extremities, both become one, and fork the room
for no reason — which would then need state resolution to put back
together. State resolution is wired in anyway for the multi-extremity
case. It is unreachable from outside in this phase, and the alternative is
a pipeline that authorises against one arbitrary branch of a fork without
saying so.

Room versions 3 to 12 all work and are each tested end to end. Version 12
earns that coverage: its room ID is the create event's own ID, its create
event carries no room_id, and the create event is excluded from every other
event's auth_events — so the state auth.Check is given is the event's
auth_events plus the create event, which that version identifies by room ID
rather than citing.

createRoom sends the spec's events in the spec's order, each authorised
against what the ones before it produced. A failure part-way leaves an
incomplete room, which is the lesser evil: the alternative is holding every
event of a new room in memory and writing them in one transaction, making
the pipeline that authorises them a special case. The one failure a second
client can cause at any time, an alias clash, takes the room row back out.

Redactions stay two decisions, as the spec keeps them. The rules let almost
any redaction into the room; whether it applies to its target is a separate
test, run inside the same transaction as the redaction itself so that a
client holding an event ID can always read the stripped event.

storage: events keyed by event ID rather than by a surrogate, since an
event ID appears in prev_events, auth_events, state snapshots and client
requests alike. Stream ordering comes from a counter row bumped inside the
write transaction, because a PostgreSQL sequence is explicitly not
transactional and a rolled-back write would leave a hole in the stream that
/sync would have to be taught to skip. State snapshots are stored per event
and deduplicated by content hash, so the table grows with state changes
rather than with traffic — and that is what makes "the state after a
forward extremity" answerable at all. The room's listing columns are
denormalised onto its row because the public directory orders every
published room by member count and pages the result, which cannot be done
by reading each room's state in turn.

Federation-disabled behaviour from PLAN.md section 3 is enforced here.
Restricted joins are the exception that works: the rule needs a signature
from the authorising user's server verified, and while federation is off
that server is always this one.

adminapi: room list and detail, purge, force-leave, and room and event
counters on the dashboard. clientapi gains its own rate-limit buckets for
createRoom and for joining, both of which write events rather than read
them.

Tested against the real event core and a real database rather than fakes:
what is under test is whether an event this server signs is one the
authorisation rules accept, and a fake of either would only prove that the
pipeline calls what the fake expects. The storage tests run against both
SQL drivers. docs/rooms.md describes the pipeline, the storage decisions
and the known gaps — chief among them that history visibility is
room-level rather than per-event, and that /sync still reports no rooms,
both of which belong with the event stream in Phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-16 10:34:57 +02:00
1 parent 2fbd5f53ce
commit c96f88e994
45 files changed
+9637 -42

No files matched your search

+89 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
@@ -30,6 +31,7 @@ type ClientAPI struct {
serverName string
clientBaseURL string
users *userapi.API
rooms *roomserver.API
settings *settings.Service
federation *federation.Gate
registration config.Registration
@@ -41,9 +43,11 @@ type ClientAPI struct {
// One limiter per endpoint class, so a client hammering /login cannot
// spend the budget of /profile.
limitDefault *ratelimit.Limiter
limitLogin *ratelimit.Limiter
limitRegister *ratelimit.Limiter
limitDefault *ratelimit.Limiter
limitLogin *ratelimit.Limiter
limitRegister *ratelimit.Limiter
limitRoomCreate *ratelimit.Limiter
limitJoin *ratelimit.Limiter
}
// Options configure the component.
@@ -60,6 +64,12 @@ type Options struct {
// Users is the userapi component.
Users *userapi.API
// Rooms is the roomserver component. Without it the room endpoints are
// not registered at all, which is what the Phase 1 and Phase 2 tests
// relied on and what keeps a half-wired server from offering a
// createRoom it cannot honour.
Rooms *roomserver.API
// Settings is the runtime-settings service; the registration switch is
// read from it on every registration attempt.
Settings *settings.Service
@@ -107,6 +117,7 @@ func New(opts Options) (*ClientAPI, error) {
serverName: opts.ServerName,
clientBaseURL: opts.ClientBaseURL,
users: opts.Users,
rooms: opts.Rooms,
settings: opts.Settings,
federation: opts.Federation,
registration: opts.Registration,
@@ -120,9 +131,16 @@ func New(opts Options) (*ClientAPI, error) {
c.limitDefault = ratelimit.New(bucket(opts.RateLimit.Default))
c.limitLogin = ratelimit.New(bucket(opts.RateLimit.Login))
c.limitRegister = ratelimit.New(bucket(opts.RateLimit.Register))
// Creating a room writes a dozen events and joining one writes a
// membership event into a room everybody in it then sees, so both
// get their own, tighter bucket: the default one is sized for
// reads.
c.limitRoomCreate = ratelimit.New(bucket(opts.RateLimit.RoomCreate))
c.limitJoin = ratelimit.New(bucket(opts.RateLimit.Join))
} else {
c.limitDefault, c.limitLogin, c.limitRegister =
ratelimit.Disabled(), ratelimit.Disabled(), ratelimit.Disabled()
c.limitRoomCreate, c.limitJoin = ratelimit.Disabled(), ratelimit.Disabled()
}
return c, nil
}
@@ -187,6 +205,74 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
c.limitDefault, c.handlePutRoomAccountData)
c.authed(r, "GET "+v3+"/sync", c.limitDefault, c.handleSync)
c.registerRoomRoutes(r)
}
// registerRoomRoutes adds the room endpoints, if there is a roomserver.
//
// A build without one answers them with M_UNRECOGNIZED, which is the right
// answer: a server that cannot store a room should say it has no createRoom
// rather than fail every call to it.
func (c *ClientAPI) registerRoomRoutes(r *httputil.Router) {
if c.rooms == nil {
return
}
c.authed(r, "POST "+v3+"/createRoom", c.limitRoomCreate, c.handleCreateRoom)
// Membership. The two join endpoints differ only in what they are
// addressed by: /join/{roomIdOrAlias} resolves an alias first.
c.authed(r, "POST "+v3+"/join/{roomIDOrAlias}", c.limitJoin, c.handleJoinRoom)
c.authed(r, "POST "+v3+"/rooms/{roomID}/join", c.limitJoin, c.handleJoinRoomByID)
c.authed(r, "POST "+v3+"/rooms/{roomID}/leave", c.limitDefault, c.handleLeaveRoom)
c.authed(r, "POST "+v3+"/rooms/{roomID}/invite", c.limitDefault, c.handleInvite)
c.authed(r, "POST "+v3+"/rooms/{roomID}/kick", c.limitDefault, c.handleKick)
c.authed(r, "POST "+v3+"/rooms/{roomID}/ban", c.limitDefault, c.handleBan)
c.authed(r, "POST "+v3+"/rooms/{roomID}/unban", c.limitDefault, c.handleUnban)
// Sending. The state endpoint is registered twice, because the empty
// state key is both valid and the common case and arrives in two
// spellings: ".../state/m.room.name" and ".../state/m.room.name/".
// The trailing-slash form uses a multi-segment wildcard, which is the
// only kind Go's router matches against an empty segment — and which
// also means a state key a client did not escape arrives whole rather
// than as a 404.
c.authed(r, "PUT "+v3+"/rooms/{roomID}/send/{eventType}/{txnID}",
c.limitDefault, c.handleSendEvent)
c.authed(r, "PUT "+v3+"/rooms/{roomID}/state/{eventType}",
c.limitDefault, c.handlePutState)
c.authed(r, "PUT "+v3+"/rooms/{roomID}/state/{eventType}/{stateKey...}",
c.limitDefault, c.handlePutState)
c.authed(r, "PUT "+v3+"/rooms/{roomID}/redact/{eventID}/{txnID}",
c.limitDefault, c.handleRedact)
// Reading.
c.authed(r, "GET "+v3+"/rooms/{roomID}/event/{eventID}", c.limitDefault, c.handleGetEvent)
c.authed(r, "GET "+v3+"/rooms/{roomID}/state", c.limitDefault, c.handleGetState)
c.authed(r, "GET "+v3+"/rooms/{roomID}/state/{eventType}",
c.limitDefault, c.handleGetStateEvent)
c.authed(r, "GET "+v3+"/rooms/{roomID}/state/{eventType}/{stateKey...}",
c.limitDefault, c.handleGetStateEvent)
c.authed(r, "GET "+v3+"/rooms/{roomID}/members", c.limitDefault, c.handleGetMembers)
c.authed(r, "GET "+v3+"/rooms/{roomID}/joined_members",
c.limitDefault, c.handleGetJoinedMembers)
c.authed(r, "GET "+v3+"/joined_rooms", c.limitDefault, c.handleGetJoinedRooms)
// The directory. Resolving an alias and reading the public room list
// need no token: a client handed a link has to be able to follow it
// before it decides whether to log in.
c.public(r, "GET "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handleGetAlias)
c.authed(r, "PUT "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handlePutAlias)
c.authed(r, "DELETE "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handleDeleteAlias)
c.authed(r, "GET "+v3+"/rooms/{roomID}/aliases", c.limitDefault, c.handleGetRoomAliases)
c.public(r, "GET "+v3+"/publicRooms", c.limitDefault, c.handleGetPublicRooms)
c.authed(r, "POST "+v3+"/publicRooms", c.limitDefault, c.handlePostPublicRooms)
c.public(r, "GET "+v3+"/directory/list/room/{roomID}",
c.limitDefault, c.handleGetRoomVisibility)
c.authed(r, "PUT "+v3+"/directory/list/room/{roomID}",
c.limitDefault, c.handlePutRoomVisibility)
}
// public registers an endpoint that needs no access token.
@@ -14,6 +14,8 @@ import (
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
@@ -31,6 +33,7 @@ type testServer struct {
t *testing.T
handler http.Handler
users *userapi.API
rooms *roomserver.API
settings *settings.Service
fedGate *federation.Gate
}
@@ -75,9 +78,28 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
}
gate := federation.NewGate(settingsService.FederationEnabled, nil)
// A throwaway signing key: the roomserver signs every event it builds,
// and the room endpoints are not registered without one.
key, err := signing.Generate("test")
if err != nil {
t.Fatalf("signing.Generate: %v", err)
}
rooms, err := roomserver.New(roomserver.Options{
ServerName: testServerName,
Store: db.Rooms(),
Profiles: users,
SigningKey: key,
FederationEnabled: settingsService.FederationEnabled,
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("roomserver.New: %v", err)
}
opts := clientapi.Options{
ServerName: testServerName,
Users: users,
Rooms: rooms,
Settings: settingsService,
Federation: gate,
Logger: slog.New(slog.DiscardHandler),
@@ -97,6 +119,7 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
t: t,
handler: router,
users: users,
rooms: rooms,
settings: settingsService,
fedGate: gate,
}
+211
View File
@@ -0,0 +1,211 @@
package clientapi
import (
"net/http"
"strconv"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// aliasResponse is the GET /directory/room/{roomAlias} body.
type aliasResponse struct {
RoomID string `json:"room_id"`
Servers []string `json:"servers"`
}
// handleGetAlias implements GET /_matrix/client/v3/directory/room/{roomAlias}.
//
// The endpoint needs no token: a client that has been handed a link has to
// be able to turn it into a room ID before it can decide whether to log in,
// and the spec makes it unauthenticated for that reason.
func (c *ClientAPI) handleGetAlias(w http.ResponseWriter, r *http.Request) error {
alias, err := aliasFromPath(r)
if err != nil {
return err
}
resolved, err := c.rooms.ResolveAlias(r.Context(), alias)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, aliasResponse{
RoomID: resolved.RoomID,
Servers: resolved.Servers,
})
return nil
}
// putAliasRequest is the PUT /directory/room/{roomAlias} body.
type putAliasRequest struct {
RoomID string `json:"room_id"`
}
// handlePutAlias implements PUT /_matrix/client/v3/directory/room/{roomAlias}.
func (c *ClientAPI) handlePutAlias(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
alias, err := aliasFromPath(r)
if err != nil {
return err
}
var req putAliasRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if req.RoomID == "" {
return httputil.MissingParam("A room ID is required")
}
if err := c.rooms.CreateAlias(r.Context(), alias, req.RoomID, auth.UserID); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleDeleteAlias implements
// DELETE /_matrix/client/v3/directory/room/{roomAlias}.
func (c *ClientAPI) handleDeleteAlias(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
alias, err := aliasFromPath(r)
if err != nil {
return err
}
if err := c.rooms.DeleteAlias(r.Context(), alias, auth.UserID, auth.Admin); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// roomAliasesResponse is the GET /rooms/{roomID}/aliases body.
type roomAliasesResponse struct {
Aliases []string `json:"aliases"`
}
// handleGetRoomAliases implements
// GET /_matrix/client/v3/rooms/{roomID}/aliases.
func (c *ClientAPI) handleGetRoomAliases(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
aliases, err := c.rooms.RoomAliases(r.Context(), roomID, auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, roomAliasesResponse{Aliases: aliases})
return nil
}
// visibilityResponse is the GET /directory/list/room/{roomID} body.
type visibilityResponse struct {
Visibility string `json:"visibility"`
}
// handleGetRoomVisibility implements
// GET /_matrix/client/v3/directory/list/room/{roomID}.
//
// Whether a room is in the public directory is itself public: the directory
// can be read without a token, so hiding the flag behind one would protect
// nothing.
func (c *ClientAPI) handleGetRoomVisibility(w http.ResponseWriter, r *http.Request) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
visibility, err := c.rooms.RoomVisibility(r.Context(), roomID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, visibilityResponse{Visibility: visibility})
return nil
}
// handlePutRoomVisibility implements
// PUT /_matrix/client/v3/directory/list/room/{roomID}.
func (c *ClientAPI) handlePutRoomVisibility(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
var req visibilityResponse
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return err
}
if err := c.rooms.SetRoomVisibility(r.Context(), roomID, auth.UserID, req.Visibility, auth.Admin); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// publicRoomsFilter is the filter in a POST /publicRooms body.
type publicRoomsFilter struct {
GenericSearchTerm string `json:"generic_search_term"`
RoomTypes []string `json:"room_types"`
}
// publicRoomsRequest is the POST /publicRooms body.
type publicRoomsRequest struct {
Limit int `json:"limit"`
Since string `json:"since"`
Filter publicRoomsFilter `json:"filter"`
// IncludeAllNetworks and ThirdPartyInstanceID select an application
// service's own directory, which arrives with application service
// support in Phase 9.
IncludeAllNetworks bool `json:"include_all_networks"`
ThirdPartyInstanceID string `json:"third_party_instance_id"`
}
// handleGetPublicRooms implements GET /_matrix/client/v3/publicRooms.
func (c *ClientAPI) handleGetPublicRooms(w http.ResponseWriter, r *http.Request) error {
query := r.URL.Query()
limit := 0
if raw := query.Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 0 {
return httputil.InvalidParam("limit must be a non-negative integer")
}
limit = parsed
}
return c.publicRooms(w, r, roomserver.PublicRoomsRequest{
Limit: limit,
Since: query.Get("since"),
Server: query.Get("server"),
})
}
// handlePostPublicRooms implements POST /_matrix/client/v3/publicRooms,
// which is the same listing with a filter.
func (c *ClientAPI) handlePostPublicRooms(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
var req publicRoomsRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
if req.ThirdPartyInstanceID != "" {
return httputil.Unknown(http.StatusBadRequest,
"Application service room directories are not supported by this server")
}
if req.Limit < 0 {
return httputil.InvalidParam("limit must be a non-negative integer")
}
return c.publicRooms(w, r, roomserver.PublicRoomsRequest{
Limit: req.Limit,
Since: req.Since,
Search: req.Filter.GenericSearchTerm,
Server: r.URL.Query().Get("server"),
})
}
// publicRooms is the body of both directory endpoints.
func (c *ClientAPI) publicRooms(w http.ResponseWriter, r *http.Request, req roomserver.PublicRoomsRequest) error {
resp, err := c.rooms.PublicRooms(r.Context(), req)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}
+205
View File
@@ -0,0 +1,205 @@
package clientapi
import (
"net/http"
"strings"
"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/userapi"
)
// joinRequest is the body of both join endpoints.
type joinRequest struct {
Reason string `json:"reason"`
// ThirdPartySigned is the proof an identity server issued for a
// third-party invite. Warp does not talk to one, so it is refused
// rather than ignored.
ThirdPartySigned map[string]any `json:"third_party_signed"`
}
type joinResponse struct {
RoomID string `json:"room_id"`
}
// handleJoinRoomByID implements POST /_matrix/client/v3/rooms/{roomID}/join.
func (c *ClientAPI) handleJoinRoomByID(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
return c.join(w, r, auth, roomID)
}
// handleJoinRoom implements POST /_matrix/client/v3/join/{roomIDOrAlias},
// the endpoint a client uses when all it has is an alias.
func (c *ClientAPI) handleJoinRoom(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
target := r.PathValue("roomIDOrAlias")
switch {
case strings.HasPrefix(target, "!"):
if err := id.ValidateRoomID(target); err != nil {
return httputil.InvalidParam("%s is not a valid room ID", target)
}
return c.join(w, r, auth, target)
case strings.HasPrefix(target, "#"):
resolved, err := c.rooms.ResolveAlias(r.Context(), target)
if err != nil {
return roomError(err)
}
return c.join(w, r, auth, resolved.RoomID)
default:
return httputil.InvalidParam("%s is neither a room ID nor a room alias", target)
}
}
// join is the body of both join endpoints.
func (c *ClientAPI) join(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error {
var req joinRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
if len(req.ThirdPartySigned) > 0 {
return httputil.Unknown(http.StatusBadRequest,
"Third-party invites are not supported by this server")
}
joined, err := c.rooms.JoinRoom(r.Context(), roomserver.JoinRequest{
RoomID: roomID,
User: auth.UserID,
Reason: req.Reason,
ServerNames: r.URL.Query()["server_name"],
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, joinResponse{RoomID: joined})
return nil
}
// reasonRequest is the body of /leave: a note, at most.
type reasonRequest struct {
Reason string `json:"reason"`
}
// handleLeaveRoom implements POST /_matrix/client/v3/rooms/{roomID}/leave.
//
// The same endpoint rejects an invite, which is why it works on a room the
// caller has never been joined to.
func (c *ClientAPI) handleLeaveRoom(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
var req reasonRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
if err := c.rooms.LeaveRoom(r.Context(), roomID, auth.UserID, req.Reason); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// targetRequest is the body of invite, kick, ban and unban.
type targetRequest struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
// The identity-server fields of /invite. Their presence is what
// distinguishes a third-party invite from an ordinary one.
IDServer string `json:"id_server"`
Medium string `json:"medium"`
Address string `json:"address"`
IDAccessTok string `json:"id_access_token"`
}
// handleInvite implements POST /_matrix/client/v3/rooms/{roomID}/invite.
func (c *ClientAPI) handleInvite(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, req, err := c.targetRequest(w, r)
if err != nil {
return err
}
if req.Address != "" || req.Medium != "" || req.IDServer != "" {
return httputil.Unknown(http.StatusBadRequest,
"Third-party invites are not supported by this server")
}
if err := c.rooms.InviteUser(r.Context(), roomserver.InviteRequest{
RoomID: roomID,
Sender: auth.UserID,
Target: req.UserID,
Reason: req.Reason,
}); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleKick implements POST /_matrix/client/v3/rooms/{roomID}/kick.
func (c *ClientAPI) handleKick(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, req, err := c.targetRequest(w, r)
if err != nil {
return err
}
if req.UserID == "" {
return httputil.MissingParam("A user ID is required")
}
if err := c.rooms.KickUser(r.Context(), roomID, auth.UserID, req.UserID, req.Reason); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleBan implements POST /_matrix/client/v3/rooms/{roomID}/ban.
func (c *ClientAPI) handleBan(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, req, err := c.targetRequest(w, r)
if err != nil {
return err
}
if req.UserID == "" {
return httputil.MissingParam("A user ID is required")
}
if err := c.rooms.BanUser(r.Context(), roomID, auth.UserID, req.UserID, req.Reason); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// handleUnban implements POST /_matrix/client/v3/rooms/{roomID}/unban.
func (c *ClientAPI) handleUnban(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, req, err := c.targetRequest(w, r)
if err != nil {
return err
}
if req.UserID == "" {
return httputil.MissingParam("A user ID is required")
}
if err := c.rooms.UnbanUser(r.Context(), roomID, auth.UserID, req.UserID, req.Reason); err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// targetRequest decodes the body the four moderation endpoints share.
func (c *ClientAPI) targetRequest(w http.ResponseWriter, r *http.Request) (string, targetRequest, error) {
roomID, err := pathRoomID(r)
if err != nil {
return "", targetRequest{}, err
}
var req targetRequest
if err := httputil.DecodeJSON(w, r, &req); err != nil {
return "", targetRequest{}, err
}
// A third-party invite has no user_id, which is why the check for one
// is left to the handler.
return roomID, req, nil
}
+417
View File
@@ -0,0 +1,417 @@
package clientapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"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"
)
// roomError maps a roomserver error onto the Matrix error the spec answers
// it with.
//
// The roomserver's refusals carry their reason in the message — which rule
// said no, which membership was missing — and the message is passed through
// to the client. That is deliberate: "M_FORBIDDEN" on its own sends a user
// to their operator and the operator to the logs, and nothing in these
// messages is anything the caller could not work out by reading the room's
// state, which they are entitled to do.
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.ErrNoSuchAlias):
return httputil.NotFound("Unknown room alias")
case errors.Is(err, roomserver.ErrAliasExists):
return httputil.NewError(http.StatusConflict, httputil.ErrCodeRoomInUse,
"That room alias is already taken")
case errors.Is(err, roomserver.ErrUnknownRoomVersion),
errors.Is(err, roomserver.ErrUnsupportedRoomVersion):
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnsupportedVer, "%s", err.Error())
case errors.Is(err, roomserver.ErrFederationDisabled):
return httputil.Forbidden("%s", err.Error())
case errors.Is(err, roomserver.ErrRejected), errors.Is(err, roomserver.ErrForbidden):
return httputil.Forbidden("%s", err.Error())
case errors.Is(err, roomserver.ErrInvalidContent):
return httputil.BadJSON("%s", err.Error())
default:
return err
}
}
// createRoomRequest is the POST /createRoom body.
type createRoomRequest struct {
RoomVersion string `json:"room_version"`
Preset string `json:"preset"`
Visibility string `json:"visibility"`
RoomAliasName string `json:"room_alias_name"`
Name string `json:"name"`
Topic string `json:"topic"`
Invite []string `json:"invite"`
CreationContent json.RawMessage `json:"creation_content"`
InitialState []roomserver.StateEvent `json:"initial_state"`
PowerLevelContentOverride json.RawMessage `json:"power_level_content_override"`
IsDirect bool `json:"is_direct"`
Invite3PID []map[string]json.RawMessage `json:"invite_3pid"`
}
type createRoomResponse struct {
RoomID string `json:"room_id"`
RoomAlias string `json:"room_alias,omitempty"`
}
// handleCreateRoom implements POST /_matrix/client/v3/createRoom.
func (c *ClientAPI) handleCreateRoom(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
var req createRoomRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
// Third-party invites go through an identity server, which Warp does
// not talk to (PLAN.md phase 9). Saying so is better than creating the
// room and quietly dropping the invites.
if len(req.Invite3PID) > 0 {
return httputil.Unknown(http.StatusBadRequest,
"Third-party invites are not supported by this server")
}
resp, err := c.rooms.CreateRoom(r.Context(), roomserver.CreateRoomRequest{
Creator: auth.UserID,
Version: req.RoomVersion,
Preset: req.Preset,
Visibility: req.Visibility,
RoomAliasName: req.RoomAliasName,
Name: req.Name,
Topic: req.Topic,
Invite: req.Invite,
CreationContent: req.CreationContent,
InitialState: req.InitialState,
PowerLevelContentOverride: req.PowerLevelContentOverride,
IsDirect: req.IsDirect,
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, createRoomResponse{
RoomID: resp.RoomID,
RoomAlias: resp.Alias,
})
return nil
}
// eventIDResponse is what every endpoint that sends an event returns.
type eventIDResponse struct {
EventID string `json:"event_id"`
}
// sendEventRequest is the body of a send or state PUT: any JSON object.
type sendEventRequest = json.RawMessage
// handleSendEvent implements
// PUT /_matrix/client/v3/rooms/{roomID}/send/{eventType}/{txnID}.
func (c *ClientAPI) handleSendEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventType := r.PathValue("eventType")
if eventType == "" {
return httputil.MissingParam("An event type is required")
}
txnID := r.PathValue("txnID")
if txnID == "" {
return httputil.MissingParam("A transaction ID is required")
}
var content sendEventRequest
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
RoomID: roomID,
Sender: auth.UserID,
Type: eventType,
Content: content,
Transaction: transactionRef(auth, txnID),
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, eventIDResponse{EventID: eventID})
return nil
}
// handlePutState implements
// PUT /_matrix/client/v3/rooms/{roomID}/state/{eventType}[/{stateKey}].
//
// State events carry no transaction ID: the spec gives none, because a
// state event is idempotent in a way a message is not — sending the same
// state twice leaves the room in the state you asked for either way.
func (c *ClientAPI) handlePutState(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventType := r.PathValue("eventType")
if eventType == "" {
return httputil.MissingParam("An event type is required")
}
stateKey := r.PathValue("stateKey")
var content sendEventRequest
if err := httputil.DecodeJSON(w, r, &content); err != nil {
return err
}
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
RoomID: roomID,
Sender: auth.UserID,
Type: eventType,
StateKey: &stateKey,
Content: content,
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, eventIDResponse{EventID: eventID})
return nil
}
// redactRequest is the PUT /rooms/{roomID}/redact/{eventID}/{txnID} body.
type redactRequest struct {
Reason string `json:"reason"`
}
// handleRedact implements
// PUT /_matrix/client/v3/rooms/{roomID}/redact/{eventID}/{txnID}.
func (c *ClientAPI) handleRedact(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventID := r.PathValue("eventID")
if err := id.ValidateEventID(eventID); err != nil {
return httputil.InvalidParam("%s is not a valid event ID", eventID)
}
txnID := r.PathValue("txnID")
if txnID == "" {
return httputil.MissingParam("A transaction ID is required")
}
var req redactRequest
if _, err := httputil.DecodeOptionalJSON(w, r, &req); err != nil {
return err
}
redactionID, err := c.rooms.Redact(r.Context(), roomserver.RedactRequest{
RoomID: roomID,
Sender: auth.UserID,
EventID: eventID,
Reason: req.Reason,
Transaction: transactionRef(auth, txnID),
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, eventIDResponse{EventID: redactionID})
return nil
}
// handleGetEvent implements
// GET /_matrix/client/v3/rooms/{roomID}/event/{eventID}.
func (c *ClientAPI) handleGetEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventID := r.PathValue("eventID")
if err := id.ValidateEventID(eventID); err != nil {
return httputil.InvalidParam("%s is not a valid event ID", eventID)
}
clientEvent, err := c.rooms.Event(r.Context(), roomID, auth.UserID, eventID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, clientEvent)
return nil
}
// handleGetState implements GET /_matrix/client/v3/rooms/{roomID}/state,
// which returns the room's whole current state as an array.
func (c *ClientAPI) handleGetState(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
state, err := c.rooms.RoomState(r.Context(), roomID, auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, state)
return nil
}
// handleGetStateEvent implements
// GET /_matrix/client/v3/rooms/{roomID}/state/{eventType}[/{stateKey}].
//
// It returns the event's content rather than the event: an inconsistency in
// the Client-Server API that predates most of it, and one every client
// relies on. The ?format=event query parameter asks for the whole event
// instead, which is how a client that wants the sender gets it.
func (c *ClientAPI) handleGetStateEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
eventType := r.PathValue("eventType")
if eventType == "" {
return httputil.MissingParam("An event type is required")
}
stateKey := r.PathValue("stateKey")
if r.URL.Query().Get("format") == "event" {
state, err := c.rooms.StateEvent(r.Context(), roomID, auth.UserID, eventType, stateKey)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, state)
return nil
}
content, err := c.rooms.StateEventContent(r.Context(), roomID, auth.UserID, eventType, stateKey)
if err != nil {
return roomError(err)
}
httputil.WriteRawJSON(w, http.StatusOK, content)
return nil
}
// membersResponse is the GET /rooms/{roomID}/members body.
type membersResponse struct {
Chunk []roomserver.ClientEvent `json:"chunk"`
}
// handleGetMembers implements GET /_matrix/client/v3/rooms/{roomID}/members.
//
// The ?at= parameter asks for the membership as of a point in the event
// stream, and is accepted and ignored. That is not a shortcut: there is one
// stream position in this build, because /sync has no stream to be at until
// Phase 4, so the membership as of any token a client can hold *is* the
// current membership. Phase 4 replaces this endpoint's filtering along with
// /sync and has to honour it then — see docs/rooms.md.
func (c *ClientAPI) handleGetMembers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
query := r.URL.Query()
membership, notMembership := query.Get("membership"), query.Get("not_membership")
for _, value := range []string{membership, notMembership} {
if err := roomserver.ValidateMembershipFilter(value); err != nil {
return roomError(err)
}
}
members, err := c.rooms.Members(r.Context(), roomserver.MembersRequest{
RoomID: roomID,
User: auth.UserID,
Membership: membership,
NotMembership: notMembership,
})
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, membersResponse{Chunk: members})
return nil
}
// joinedMembersResponse is the GET /rooms/{roomID}/joined_members body.
type joinedMembersResponse struct {
Joined map[string]roomserver.RoomMember `json:"joined"`
}
// handleGetJoinedMembers implements
// GET /_matrix/client/v3/rooms/{roomID}/joined_members.
func (c *ClientAPI) handleGetJoinedMembers(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
roomID, err := pathRoomID(r)
if err != nil {
return err
}
joined, err := c.rooms.JoinedMembers(r.Context(), roomID, auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, joinedMembersResponse{Joined: joined})
return nil
}
// joinedRoomsResponse is the GET /joined_rooms body.
type joinedRoomsResponse struct {
JoinedRooms []string `json:"joined_rooms"`
}
// handleGetJoinedRooms implements GET /_matrix/client/v3/joined_rooms.
func (c *ClientAPI) handleGetJoinedRooms(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
rooms, err := c.rooms.JoinedRooms(r.Context(), auth.UserID)
if err != nil {
return roomError(err)
}
httputil.WriteJSON(w, http.StatusOK, joinedRoomsResponse{JoinedRooms: rooms})
return nil
}
// pathRoomID reads and validates the {roomID} path value.
func pathRoomID(r *http.Request) (string, error) {
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
}
// transactionRef builds the idempotency key for a send.
//
// It is keyed on the device rather than the user: the spec scopes a
// transaction ID to the access token, and two of a user's clients each
// counting from "m1" must not collide.
func transactionRef(auth userapi.Authenticated, txnID string) *storage.TransactionRef {
return &storage.TransactionRef{
Localpart: auth.Localpart,
DeviceID: auth.DeviceID,
TxnID: txnID,
}
}
// aliasFromPath reads the {roomAlias} path value, which arrives percent-
// encoded because it contains a '#' and a ':'.
func aliasFromPath(r *http.Request) (string, error) {
alias := r.PathValue("roomAlias")
if alias == "" {
return "", httputil.MissingParam("A room alias is required")
}
if !strings.HasPrefix(alias, "#") {
return "", httputil.InvalidParam("%s is not a valid room alias", alias)
}
return alias, nil
}
+526
View File
@@ -0,0 +1,526 @@
package clientapi_test
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// createRoom calls POST /createRoom and returns the room ID.
func (ts *testServer) createRoom(acc account, body map[string]any) string {
ts.t.Helper()
rec := ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/createRoom",
body: body,
token: acc.AccessToken,
})
expectStatus(ts.t, rec, http.StatusOK)
roomID, _ := jsonBody(ts.t, rec)["room_id"].(string)
if roomID == "" {
ts.t.Fatalf("createRoom returned no room ID: %s", rec.Body.String())
}
return roomID
}
// put is a PUT with a body, which most of the room endpoints are.
func (ts *testServer) put(path, token string, body any) *httptest.ResponseRecorder {
ts.t.Helper()
return ts.do(request{method: http.MethodPut, path: path, body: body, token: token})
}
// post is a POST with a body.
func (ts *testServer) post(path, token string, body any) *httptest.ResponseRecorder {
ts.t.Helper()
return ts.do(request{method: http.MethodPost, path: path, body: body, token: token})
}
func TestCreateRoomEndpoint(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
rec := ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/createRoom",
body: map[string]any{
"name": "Test Room",
"topic": "Testing",
"preset": "public_chat",
"visibility": "public",
"room_alias_name": "test",
},
token: alice.AccessToken,
})
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
roomID, _ := body["room_id"].(string)
if roomID == "" || roomID[0] != '!' {
t.Fatalf("createRoom returned room_id %q", roomID)
}
if got, _ := body["room_alias"].(string); got != "#test:"+testServerName {
t.Errorf("createRoom returned room_alias %q", got)
}
// A token is required: a room has to be created by somebody.
rec = ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/createRoom",
body: map[string]any{},
})
expectError(t, rec, http.StatusUnauthorized, httputil.ErrCodeMissingToken)
// An unknown room version is reported as such, so a client can offer
// the user the versions that do exist.
rec = ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/createRoom",
body: map[string]any{"room_version": "99"},
token: alice.AccessToken,
})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnsupportedVer)
// A taken alias is a conflict, not a bad request.
rec = ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/createRoom",
body: map[string]any{"room_alias_name": "test"},
token: alice.AccessToken,
})
expectError(t, rec, http.StatusConflict, httputil.ErrCodeRoomInUse)
}
func TestSendAndReadEvents(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
rec := ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/txn1",
alice.AccessToken, map[string]any{"msgtype": "m.text", "body": "hello"})
expectStatus(t, rec, http.StatusOK)
eventID, _ := jsonBody(t, rec)["event_id"].(string)
if eventID == "" || eventID[0] != '$' {
t.Fatalf("send returned event_id %q", eventID)
}
// A retry with the same transaction ID returns the first event.
rec = ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/txn1",
alice.AccessToken, map[string]any{"msgtype": "m.text", "body": "hello"})
expectStatus(t, rec, http.StatusOK)
if again, _ := jsonBody(t, rec)["event_id"].(string); again != eventID {
t.Errorf("a retried send returned %q, want %q", again, eventID)
}
rec = ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/event/"+
url.PathEscape(eventID), alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
fetched := jsonBody(t, rec)
if fetched["event_id"] != eventID {
t.Errorf("the event came back as %v", fetched["event_id"])
}
if fetched["room_id"] != roomID {
t.Errorf("the event's room_id is %v, want %q", fetched["room_id"], roomID)
}
if fetched["sender"] != alice.UserID {
t.Errorf("the event's sender is %v, want %q", fetched["sender"], alice.UserID)
}
// A client is given the ID explicitly and never the signatures: in
// every room version since 3 the ID is a hash, not a property, and the
// signatures are a server's business.
for _, forbidden := range []string{"hashes", "signatures", "auth_events", "prev_events", "depth"} {
if _, present := fetched[forbidden]; present {
t.Errorf("the client event carries %q", forbidden)
}
}
// An unknown event in a known room is a 404.
rec = ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/event/%24nope",
alice.AccessToken)
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
// A malformed room ID is caught before anything is looked up.
rec = ts.get("/_matrix/client/v3/rooms/not-a-room/state", alice.AccessToken)
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam)
}
func TestStateEndpoints(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat", "name": "Original"})
escaped := url.PathEscape(roomID)
// The whole state comes back as an array.
rec := ts.get("/_matrix/client/v3/rooms/"+escaped+"/state", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
state := decode[[]map[string]any](t, rec)
if len(state) < 6 {
t.Fatalf("the room state has %d events, want the full setup: %s", len(state), rec.Body.String())
}
// One piece of state comes back as bare content, which is a quirk of
// the Client-Server API that every client relies on.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.name/", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["name"].(string); got != "Original" {
t.Errorf("the state content is %s", rec.Body.String())
}
// Without a trailing slash the state key is the empty string too.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.name", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["name"].(string); got != "Original" {
t.Errorf("the state content without a trailing slash is %s", rec.Body.String())
}
// ?format=event asks for the whole event instead.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.name?format=event",
alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["type"].(string); got != "m.room.name" {
t.Errorf("?format=event returned %s", rec.Body.String())
}
rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.topic",
alice.AccessToken, map[string]any{"topic": "Changed"})
expectStatus(t, rec, http.StatusOK)
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.topic", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["topic"].(string); got != "Changed" {
t.Errorf("the topic is %s", rec.Body.String())
}
// State this room has never set is a 404.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.avatar", alice.AccessToken)
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
// A stranger cannot read the state of a room they are not in.
bob := ts.register("bob", "correct-horse-battery-staple")
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state", bob.AccessToken)
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
// Nor can they write it.
rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.topic",
bob.AccessToken, map[string]any{"topic": "Hijacked"})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
}
func TestMembershipEndpoints(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
escaped := url.PathEscape(roomID)
rec := ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["room_id"].(string); got != roomID {
t.Errorf("join returned room_id %q, want %q", got, roomID)
}
rec = ts.get("/_matrix/client/v3/joined_rooms", bob.AccessToken)
expectStatus(t, rec, http.StatusOK)
rooms := decode[struct {
JoinedRooms []string `json:"joined_rooms"`
}](t, rec)
if len(rooms.JoinedRooms) != 1 || rooms.JoinedRooms[0] != roomID {
t.Errorf("bob's joined rooms = %v", rooms.JoinedRooms)
}
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/joined_members", bob.AccessToken)
expectStatus(t, rec, http.StatusOK)
joined := decode[struct {
Joined map[string]struct {
DisplayName string `json:"display_name"`
} `json:"joined"`
}](t, rec)
if len(joined.Joined) != 2 {
t.Errorf("joined_members returned %d members, want 2", len(joined.Joined))
}
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/members", bob.AccessToken)
expectStatus(t, rec, http.StatusOK)
members := decode[struct {
Chunk []map[string]any `json:"chunk"`
}](t, rec)
if len(members.Chunk) != 2 {
t.Errorf("members returned %d events, want 2", len(members.Chunk))
}
// An invented membership filter is refused rather than ignored.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/members?membership=lurking",
bob.AccessToken)
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeBadJSON)
// ?at= is accepted and ignored: there is one stream position in this
// build, so the membership as of any token a client can hold is the
// current membership. Phase 4 has to honour it properly.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/members?at=s0_0", bob.AccessToken)
expectStatus(t, rec, http.StatusOK)
// Moderation.
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/kick", alice.AccessToken,
map[string]any{"user_id": bob.UserID, "reason": "behave"})
expectStatus(t, rec, http.StatusOK)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/ban", alice.AccessToken,
map[string]any{"user_id": bob.UserID})
expectStatus(t, rec, http.StatusOK)
// A banned user cannot come back.
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/unban", alice.AccessToken,
map[string]any{"user_id": bob.UserID})
expectStatus(t, rec, http.StatusOK)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/leave", bob.AccessToken, nil)
expectStatus(t, rec, http.StatusOK)
// user_id is required where it is the whole point of the call.
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/kick", alice.AccessToken, map[string]any{})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeMissingParam)
// Moderation without the power is M_FORBIDDEN, decided by the room's
// power levels rather than by this handler.
carol := ts.register("carol", "correct-horse-battery-staple")
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", carol.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/kick", carol.AccessToken,
map[string]any{"user_id": alice.UserID})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
}
func TestInviteEndpoint(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "private_chat"})
escaped := url.PathEscape(roomID)
// An invite-only room cannot be walked into.
rec := ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/invite", alice.AccessToken,
map[string]any{"user_id": bob.UserID})
expectStatus(t, rec, http.StatusOK)
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
// Inviting a user of another server needs federation, and says so.
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/invite", alice.AccessToken,
map[string]any{"user_id": "@eve:elsewhere.example"})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
if msg, _ := jsonBody(t, rec)["error"].(string); msg == "" {
t.Error("the federation refusal carried no explanation")
}
// A third-party invite goes through an identity server, which this
// server does not talk to.
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/invite", alice.AccessToken,
map[string]any{"id_server": "id.example", "medium": "email", "address": "a@b.example"})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnknown)
}
func TestRedactEndpoint(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
escaped := url.PathEscape(roomID)
rec := ts.put("/_matrix/client/v3/rooms/"+escaped+"/send/m.room.message/t1",
alice.AccessToken, map[string]any{"msgtype": "m.text", "body": "oops"})
expectStatus(t, rec, http.StatusOK)
eventID, _ := jsonBody(t, rec)["event_id"].(string)
rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/redact/"+
url.PathEscape(eventID)+"/r1", alice.AccessToken, map[string]any{"reason": "a mistake"})
expectStatus(t, rec, http.StatusOK)
redaction, _ := jsonBody(t, rec)["event_id"].(string)
if redaction == "" || redaction == eventID {
t.Fatalf("redact returned event_id %q", redaction)
}
// The event is readable and empty, and says why.
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/event/"+url.PathEscape(eventID),
alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
fetched := jsonBody(t, rec)
content, _ := fetched["content"].(map[string]any)
if len(content) != 0 {
t.Errorf("the redacted event still has content: %v", content)
}
unsigned, _ := fetched["unsigned"].(map[string]any)
because, _ := unsigned["redacted_because"].(map[string]any)
if because["event_id"] != redaction {
t.Errorf("unsigned.redacted_because = %v, want the redaction %q", because, redaction)
}
// A malformed event ID in the path is caught before anything else.
rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/redact/nope/r2",
alice.AccessToken, map[string]any{})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam)
}
func TestDirectoryEndpoints(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
escaped := url.PathEscape(roomID)
const alias = "#chat:" + testServerName
escapedAlias := url.PathEscape(alias)
rec := ts.put("/_matrix/client/v3/directory/room/"+escapedAlias, alice.AccessToken,
map[string]any{"room_id": roomID})
expectStatus(t, rec, http.StatusOK)
// Resolving an alias needs no token: a client handed a link has to be
// able to follow it before it decides whether to log in.
rec = ts.get("/_matrix/client/v3/directory/room/"+escapedAlias, "")
expectStatus(t, rec, http.StatusOK)
resolved := jsonBody(t, rec)
if resolved["room_id"] != roomID {
t.Errorf("the alias resolves to %v, want %q", resolved["room_id"], roomID)
}
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/aliases", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
aliases := decode[struct {
Aliases []string `json:"aliases"`
}](t, rec)
if len(aliases.Aliases) != 1 || aliases.Aliases[0] != alias {
t.Errorf("the room's aliases = %v", aliases.Aliases)
}
// An unknown alias is a 404.
rec = ts.get("/_matrix/client/v3/directory/room/"+url.PathEscape("#nope:"+testServerName), "")
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
// The public room directory.
rec = ts.get("/_matrix/client/v3/publicRooms", "")
expectStatus(t, rec, http.StatusOK)
if total, _ := jsonBody(t, rec)["total_room_count_estimate"].(float64); total != 0 {
t.Errorf("an unpublished room is in the directory (total %v)", total)
}
rec = ts.put("/_matrix/client/v3/directory/list/room/"+escaped, alice.AccessToken,
map[string]any{"visibility": "public"})
expectStatus(t, rec, http.StatusOK)
rec = ts.get("/_matrix/client/v3/directory/list/room/"+escaped, "")
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["visibility"].(string); got != "public" {
t.Errorf("visibility = %q, want public", got)
}
rec = ts.get("/_matrix/client/v3/publicRooms?limit=10", "")
expectStatus(t, rec, http.StatusOK)
directory := decode[struct {
Chunk []map[string]any `json:"chunk"`
Total int `json:"total_room_count_estimate"`
}](t, rec)
if directory.Total != 1 || len(directory.Chunk) != 1 {
t.Fatalf("the directory has %d of %d rooms, want 1 of 1",
len(directory.Chunk), directory.Total)
}
if directory.Chunk[0]["room_id"] != roomID {
t.Errorf("the directory lists %v, want %q", directory.Chunk[0]["room_id"], roomID)
}
// The POST form takes a filter.
rec = ts.post("/_matrix/client/v3/publicRooms", alice.AccessToken, map[string]any{
"filter": map[string]any{"generic_search_term": "nothing matches this"},
})
expectStatus(t, rec, http.StatusOK)
filtered := decode[struct {
Chunk []map[string]any `json:"chunk"`
}](t, rec)
if len(filtered.Chunk) != 0 {
t.Errorf("a search that matches nothing returned %d rooms", len(filtered.Chunk))
}
// An ordinary member cannot publish or unpublish the room.
rec = ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
rec = ts.put("/_matrix/client/v3/directory/list/room/"+escaped, bob.AccessToken,
map[string]any{"visibility": "private"})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
// Another server's directory needs federation.
rec = ts.get("/_matrix/client/v3/publicRooms?server=other.example", "")
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
// A bad limit is a bad request rather than a default.
rec = ts.get("/_matrix/client/v3/publicRooms?limit=lots", "")
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam)
}
// TestJoinByAlias is the endpoint a client uses when all it was given is a
// link.
func TestJoinByAlias(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{
"preset": "public_chat", "room_alias_name": "open",
})
alias := "#open:" + testServerName
rec := ts.post("/_matrix/client/v3/join/"+url.PathEscape(alias), bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["room_id"].(string); got != roomID {
t.Errorf("joining by alias returned %q, want %q", got, roomID)
}
// The same endpoint takes a room ID.
rec = ts.post("/_matrix/client/v3/join/"+url.PathEscape(roomID), bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
// Anything that is neither is a bad request.
rec = ts.post("/_matrix/client/v3/join/nonsense", bob.AccessToken, map[string]any{})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeInvalidParam)
// A room on another server needs federation.
rec = ts.post("/_matrix/client/v3/join/"+url.PathEscape("!x:other.example"),
bob.AccessToken, map[string]any{})
expectError(t, rec, http.StatusForbidden, httputil.ErrCodeForbidden)
}
// TestStateKeyWithSpecialCharacters is about the router, not the rooms: a
// membership state key is a user ID, so it carries an '@' and a ':', and
// the empty state key arrives in two spellings. All of them have to route.
func TestStateKeyWithSpecialCharacters(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
escaped := url.PathEscape(roomID)
rec := ts.post("/_matrix/client/v3/rooms/"+escaped+"/join", bob.AccessToken, map[string]any{})
expectStatus(t, rec, http.StatusOK)
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.member/"+
url.PathEscape(bob.UserID), alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["membership"].(string); got != "join" {
t.Errorf("bob's membership content is %s", rec.Body.String())
}
// And writing one back through the state endpoint.
rec = ts.put("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.member/"+
url.PathEscape(bob.UserID), alice.AccessToken,
map[string]any{"membership": "ban"})
expectStatus(t, rec, http.StatusOK)
rec = ts.get("/_matrix/client/v3/rooms/"+escaped+"/state/m.room.member/"+
url.PathEscape(bob.UserID), alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if got, _ := jsonBody(t, rec)["membership"].(string); got != "ban" {
t.Errorf("bob's membership after the ban is %s", rec.Body.String())
}
}
+16 -15
View File
@@ -7,8 +7,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// syncResponse is the subset of the /sync body that is meaningful before
// there are any rooms.
// syncResponse is the subset of the /sync body this build can fill in.
type syncResponse struct {
NextBatch string `json:"next_batch"`
Rooms syncRooms `json:"rooms"`
@@ -34,27 +33,29 @@ type syncDeviceLists struct {
Left []string `json:"left"`
}
// syncPlaceholderToken is the only stream position Phase 1 can be at.
// syncPlaceholderToken is the only stream position this build has.
//
// It is a real token shape rather than an empty string so that a client
// storing it and sending it back as ?since= gets the same answer, which is
// the correct one: nothing has happened.
// storing it and sending it back as ?since= gets the same answer.
const syncPlaceholderToken = "s0_0"
// handleSync implements GET /_matrix/client/v3/sync as far as Phase 1 can.
// handleSync implements GET /_matrix/client/v3/sync as far as this build
// can.
//
// This is a placeholder, and deliberately a working one. There are no rooms
// until Phase 3 and no event streams until Phase 4, so an empty sync is not
// a lie — it is the complete state of this server. What it buys in the
// meantime is the thing every client and the test suite check first: that a
// valid token syncs and a revoked one does not. Without it, "logging out
// invalidates the session" cannot be observed at all.
// It is a placeholder, and since Phase 3 it is an incomplete answer rather
// than a complete one: there are rooms now, and this reports none of them.
// Filling it in means the event stream — stream tokens, incremental deltas,
// long polling, filters and lazy-loaded members — which is the syncapi of
// PLAN.md phase 4, and which replaces this wholesale.
//
// Phase 4 replaces this wholesale with the syncapi: stream tokens, long
// polling, filters and lazy-loaded members.
// It stays because of what it does buy: a valid token syncs and a revoked
// one does not, which is the first thing every client and the test suite
// check, and which cannot be observed at all without an endpoint here. A
// client can meanwhile see its rooms through /joined_rooms and read them
// through /rooms/{roomID}/state. See docs/rooms.md.
func (c *ClientAPI) handleSync(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
// The `timeout` parameter is ignored rather than honoured: blocking for
// 30 seconds to return a response that cannot change would hold a
// 30 seconds to return a response this build cannot change would hold a
// connection open for nothing.
httputil.WriteJSON(w, http.StatusOK, syncResponse{
NextBatch: syncPlaceholderToken,