package clientapi import ( "encoding/json" "errors" "net/http" "strings" "github.com/dariuszjaniak/warp/server/internal/delayedevents" "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.ErrIncompatibleRoomVersion): return httputil.NewError(http.StatusBadRequest, "M_INCOMPATIBLE_ROOM_VERSION", "%s", err.Error()) case errors.Is(err, roomserver.ErrNotInRoom): return httputil.Forbidden("%s", err.Error()) case errors.Is(err, roomserver.ErrRemote), errors.Is(err, roomserver.ErrBadEvent): // Another server failed us; the client can do nothing about it but // try again, or try another server. return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "%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") } if req.RoomAliasName != "" { if err := c.checkExclusiveAlias("#"+req.RoomAliasName+":"+c.serverName, auth); err != nil { return err } } 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") } delay, delayed, err := c.delayRequested(r) if err != nil { return err } var content sendEventRequest if err := httputil.DecodeJSON(w, r, &content); err != nil { return err } if delayed { return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{ UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID, Type: eventType, Content: content, Delay: delay, }) } 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") delay, delayed, err := c.delayRequested(r) if err != nil { return err } var content sendEventRequest if err := httputil.DecodeJSON(w, r, &content); err != nil { return err } if delayed { return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{ UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID, Type: eventType, StateKey: &stateKey, Content: content, Delay: delay, }) } 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 is a sync token, and names the point in the event // stream to report the membership as of. A client rendering an older part // of a room's timeline needs it: the names and avatars to show against // those messages are the ones their senders had then, not now. 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) } } // ?at= only has a meaning once there is a stream, so it is read here // rather than in the roomserver: the token format is the syncapi's. at, err := ParseSyncPosition(query.Get("at")) if err != nil { return err } members, err := c.rooms.Members(r.Context(), roomserver.MembersRequest{ RoomID: roomID, User: auth.UserID, Membership: membership, NotMembership: notMembership, At: at, }) 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 }