package clientapi import ( "context" "errors" "net/http" "github.com/dariuszjaniak/warp/server/internal/federation" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/matrix/id" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // profileResponse is the body of GET /profile/{userId}. Both fields are // omitted when unset: the spec distinguishes "no display name" from an empty // one, and clients fall back to the localpart for the former. type profileResponse struct { DisplayName string `json:"displayname,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` } // MaxDisplayNameLength and MaxAvatarURLLength bound the profile fields. // The spec sets no limit; these are what keeps a member list from carrying a // megabyte of display name per user once rooms exist. const ( MaxDisplayNameLength = 256 MaxAvatarURLLength = 1024 ) // handleGetProfile implements GET /_matrix/client/v3/profile/{userId}. func (c *ClientAPI) handleGetProfile(w http.ResponseWriter, r *http.Request) error { account, err := c.lookupProfile(r) if err != nil { return err } httputil.WriteJSON(w, http.StatusOK, profileResponse{ DisplayName: account.DisplayName, AvatarURL: account.AvatarURL, }) return nil } // handleGetDisplayName implements GET /profile/{userId}/displayname. func (c *ClientAPI) handleGetDisplayName(w http.ResponseWriter, r *http.Request) error { account, err := c.lookupProfile(r) if err != nil { return err } httputil.WriteJSON(w, http.StatusOK, profileResponse{DisplayName: account.DisplayName}) return nil } // handleGetAvatarURL implements GET /profile/{userId}/avatar_url. func (c *ClientAPI) handleGetAvatarURL(w http.ResponseWriter, r *http.Request) error { account, err := c.lookupProfile(r) if err != nil { return err } httputil.WriteJSON(w, http.StatusOK, profileResponse{AvatarURL: account.AvatarURL}) return nil } // RemoteProfiles asks another server for a profile. *federation.Client // satisfies it. type RemoteProfiles interface { QueryProfile(ctx context.Context, destination, userID, field string) (map[string]any, error) } // lookupProfile resolves the user ID in the path to an account: a local // one from the database, a remote one by asking its server. // // A user ID on another server is where the federation switch shows up in the // Client-Server API: with federation off there is no way to answer and the // spec's M_FORBIDDEN is the honest reply, as PLAN.md section 3 requires. func (c *ClientAPI) lookupProfile(r *http.Request) (storage.Account, error) { userID := r.PathValue("userID") localpart, err := c.users.ParseLocalUserID(userID) switch { case errors.Is(err, userapi.ErrForeignServer): if !c.federation.Enabled() || c.remoteProfile == nil { return storage.Account{}, federation.RejectClientRequest( "looking up the profile of a user on another server") } return c.lookupRemoteProfile(r.Context(), userID) case err != nil: return storage.Account{}, httputil.InvalidParam("%s is not a valid user ID", userID) } account, err := c.users.Account(r.Context(), localpart) switch { case errors.Is(err, userapi.ErrNoSuchUser): return storage.Account{}, httputil.NotFound("Profile not found") case err != nil: return storage.Account{}, err } return account, nil } // putDisplayNameRequest is the body of PUT /profile/{userId}/displayname. type putDisplayNameRequest struct { DisplayName string `json:"displayname"` } // putAvatarURLRequest is the body of PUT /profile/{userId}/avatar_url. type putAvatarURLRequest struct { AvatarURL string `json:"avatar_url"` } // handlePutDisplayName implements PUT /profile/{userId}/displayname. func (c *ClientAPI) handlePutDisplayName(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { localpart, err := c.requireSelf(auth, r.PathValue("userID")) if err != nil { return err } var req putDisplayNameRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if len(req.DisplayName) > MaxDisplayNameLength { return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge, "Display names must be at most %d bytes", MaxDisplayNameLength) } if err := c.users.SetDisplayName(r.Context(), localpart, req.DisplayName); err != nil { return profileWriteError(err) } // Once rooms exist this also has to rewrite the m.room.member event in // every room the user is in; that belongs to the roomserver in Phase 3. httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // handlePutAvatarURL implements PUT /profile/{userId}/avatar_url. func (c *ClientAPI) handlePutAvatarURL(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { localpart, err := c.requireSelf(auth, r.PathValue("userID")) if err != nil { return err } var req putAvatarURLRequest if err := httputil.DecodeJSON(w, r, &req); err != nil { return err } if len(req.AvatarURL) > MaxAvatarURLLength { return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge, "Avatar URLs must be at most %d bytes", MaxAvatarURLLength) } if err := c.users.SetAvatarURL(r.Context(), localpart, req.AvatarURL); err != nil { return profileWriteError(err) } httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // profileWriteError maps a failed profile update. An admin may write to // another user's profile, so "no such user" is reachable here. func profileWriteError(err error) error { if errors.Is(err, userapi.ErrNoSuchUser) { return httputil.NotFound("Profile not found") } return err } // lookupRemoteProfile asks a user's own server for their profile. // // Nothing is cached. A profile changes whenever its owner likes, and a // cached copy would need invalidating by an EDU this server does not // receive; the membership events of the rooms a remote user shares with // ours carry their display name already, which is where clients look for // it most of the time. func (c *ClientAPI) lookupRemoteProfile(ctx context.Context, userID string) (storage.Account, error) { server, err := id.ServerNameOf(userID) if err != nil { return storage.Account{}, httputil.InvalidParam("%s is not a valid user ID", userID) } profile, err := c.remoteProfile.QueryProfile(ctx, server, userID, "") switch { case federation.IsNotFound(err): return storage.Account{}, httputil.NotFound("Profile not found") case err != nil: c.log.Info("remote profile lookup failed", "user_id", userID, "error", err) return storage.Account{}, httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "%s could not be asked for the profile", server) } account := storage.Account{} if name, ok := profile["displayname"].(string); ok { account.DisplayName = name } if avatar, ok := profile["avatar_url"].(string); ok { account.AvatarURL = avatar } return account, nil }