package clientapi import ( "encoding/json" "errors" "net/http" "strings" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/pushserver" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // MaxAccountDataSize caps one account data blob. The spec sets no limit, but // account data is sent down /sync in full on every initial sync, so an // unbounded blob is an unbounded sync. const MaxAccountDataSize = 64 * 1024 // typeFullyRead is the account data type the spec reserves: it is set // through POST /rooms/{roomId}/read_markers, which keeps the read marker and // the receipt in step, so writing it directly is refused. const typeFullyRead = "m.fully_read" // handleGetAccountData implements // GET /_matrix/client/v3/user/{userId}/account_data/{type}. func (c *ClientAPI) handleGetAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { return c.getAccountData(w, r, auth, "") } // handleGetRoomAccountData implements the per-room variant. func (c *ClientAPI) handleGetRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { roomID := r.PathValue("roomID") if err := validateRoomID(roomID); err != nil { return err } return c.getAccountData(w, r, auth, roomID) } func (c *ClientAPI) getAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error { // Account data is private, so even a read is only ever of one's own — // an admin reading it would be reading a user's client settings, which // the admin API has no business doing either. localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID")) if err != nil { return err } dataType := r.PathValue("dataType") if dataType == "" { return httputil.MissingParam("An account data type is required") } var content []byte if roomID == "" && dataType == pushserver.AccountDataType && c.push != nil { // The push server's, rendered over the defaults: a user who never // changed a rule has nothing stored and still has rules. content, err = c.push.Document(r.Context(), localpart, auth.UserID) } else { content, err = c.users.AccountData(r.Context(), localpart, roomID, dataType) } switch { case errors.Is(err, userapi.ErrNoSuchAccountData): return httputil.NotFound("Account data not found") case err != nil: return err } // The stored bytes are the client's own JSON and go back out verbatim, // so they are written directly rather than round-tripped through a map. w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if _, err := w.Write(content); err != nil { httputil.LoggerFrom(r.Context()).Debug("writing account data failed", "error", err) } return nil } // handlePutAccountData implements // PUT /_matrix/client/v3/user/{userId}/account_data/{type}. func (c *ClientAPI) handlePutAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { return c.putAccountData(w, r, auth, "") } // handlePutRoomAccountData implements the per-room variant. func (c *ClientAPI) handlePutRoomAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error { roomID := r.PathValue("roomID") if err := validateRoomID(roomID); err != nil { return err } return c.putAccountData(w, r, auth, roomID) } func (c *ClientAPI) putAccountData(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated, roomID string) error { localpart, err := c.requireOwnAccountData(auth, r.PathValue("userID")) if err != nil { return err } dataType := r.PathValue("dataType") if dataType == "" { return httputil.MissingParam("An account data type is required") } if dataType == typeFullyRead { return httputil.BadJSON("%s may only be set through /rooms/{roomId}/read_markers", typeFullyRead) } if dataType == pushserver.AccountDataType { return httputil.NewError(http.StatusMethodNotAllowed, httputil.ErrCodeBadJSON, "%s may only be changed through the /pushrules endpoints", pushserver.AccountDataType) } // The content is stored as the client sent it, so it is read as raw // JSON and only checked for being a JSON object at all. var content json.RawMessage if err := httputil.DecodeJSON(w, r, &content); err != nil { return err } if len(content) > MaxAccountDataSize { return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeTooLarge, "Account data must be at most %d bytes", MaxAccountDataSize) } if !json.Valid(content) { return httputil.NotJSON() } if trimmed := strings.TrimSpace(string(content)); !strings.HasPrefix(trimmed, "{") { return httputil.BadJSON("Account data content must be a JSON object") } if err := c.users.SetAccountData(r.Context(), localpart, roomID, dataType, content); err != nil { return err } // Account data reaches a client's other devices through /sync, and // one of them blocked in a long poll should hear about it now. if c.sync != nil { c.sync.Notifier().NotifyUser(auth.UserID) } httputil.WriteJSON(w, http.StatusOK, struct{}{}) return nil } // requireOwnAccountData resolves the path's user ID and insists it is the // caller's own. Unlike a profile, account data is not something an admin may // reach into: it is the user's client configuration. func (c *ClientAPI) requireOwnAccountData(auth userapi.Authenticated, pathUserID string) (string, error) { localpart, err := c.users.ParseLocalUserID(pathUserID) switch { case errors.Is(err, userapi.ErrForeignServer): return "", httputil.Forbidden("Cannot access the account data of another server") case err != nil: return "", httputil.InvalidParam("%s is not a valid user ID", pathUserID) } if localpart != auth.Localpart { return "", httputil.Forbidden("Cannot access the account data of another user") } return localpart, nil } // validateRoomID checks the shape of a room ID in a path. // // Phase 1 has no rooms, so there is nothing to check it against: room // account data is simply a second key space, and a client may set it for a // room it has not joined. The full check — that the room exists and the user // is in it — belongs to the roomserver in Phase 3. func validateRoomID(roomID string) error { switch { case roomID == "": return httputil.MissingParam("A room ID is required") case !strings.HasPrefix(roomID, "!"): return httputil.InvalidParam("%s is not a valid room ID", roomID) case len(roomID) > 255: return httputil.InvalidParam("Room IDs must be at most 255 bytes") } return nil }