Files
Warp/server/internal/clientapi/accountdata.go
T
tajniak81andClaude Opus 5.5 b507753172 Phase 8: push and notifications
Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:36 +02:00

172 lines
6.4 KiB
Go

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
}