Files
Warp/server/internal/syncapi/messages.go
T
tajniak81andClaude Opus 5.5 6aaf554336
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
Phase 5: federation
Implements PLAN.md Phase 5: the Server-Server API. Warp joins rooms on
other servers and lets their users join its rooms, invites across servers
with the invitee's server countersigning, rejects remote invites (locally
if the inviter is unreachable), and exchanges events, typing, receipts and
to-device messages with other homeservers — all behind the runtime switch,
which now starts and stops real outbound traffic.

The protocol lives in internal/federation with no knowledge of rooms:
X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping
the dial address and the TLS name apart, a signed client that refuses
every request while the switch is off or the policy denies the
destination, and a key ring that caches other servers' keys for at most a
week, with optional notaries.

The roomserver takes events from elsewhere through the spec's intake:
signatures and hashes (a hash mismatch keeps the redacted form), the rules
against the event's own auth events, against the state before it — with
/get_missing_events for small gaps and /state_ids for wide ones — and
against the current state, where a failure is a soft failure. Events held
without their state are outliers: a column on roomserver_events, because
the current state of a room joined over federation is almost all
outliers, and excluded from every timeline query. Forks are resolved at
the room's head. Membership across servers is make_join/send_join,
make_leave/send_leave and countersigned invites, in both directions;
m.room.server_acl is enforced on everything another server sends.
History before a remote join is backfilled when /messages pages past it,
stored at negative stream positions.

internal/federationapi is the running component: the inbound endpoints,
and an outbound queue that persists what it owes each destination, sends
it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to
six hours, survives restarts, and drops events persisted while federation
is off rather than delivering a backlog in one burst. Its Module is what
the switch starts and stops.

Also: a TLS listener for port 8448, trusted key servers, key validity and
an outbound CA bundle in the config; per-destination health and backoff
reset in the admin API; remote profiles, aliases and directories in the
Client-Server API; a Complement federation profile. See
docs/federation.md, including its known gaps (no partial-state joins, no
key rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:20 +02:00

214 lines
6.9 KiB
Go

package syncapi
import (
"context"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// GET /rooms/{roomID}/messages: a room's history, a page at a time.
//
// It is the other half of /sync. A sync hands a client the live stream and,
// when a client has been away, a `limited` timeline with a gap in it;
// /messages is how the client closes that gap, and how it scrolls back
// through a room it has just opened. The tokens are the same ones — a sync's
// prev_batch is a valid ?from= here, and an ?end= from here is a valid
// ?since= for /sync — which is what makes the two compose.
// MessagesRequest is one page of a room's history.
type MessagesRequest struct {
RoomID string
// User is the full user ID asking, and Localpart is their localpart,
// for resolving a filter ID.
User string
Localpart string
// From is where to page from. Empty means the end of the room for a
// backwards page and the start of it for a forwards one, which is what
// a client opening a room sends.
From string
// To bounds the far end of the page. Empty is unbounded.
To string
// Direction is "b" to page backwards, which is what scrolling up does,
// or "f" to page forwards. The spec requires it.
Direction storage.TimelineDirection
// Limit caps the events returned.
Limit int
// Filter is the ?filter= parameter: a filter ID or an inline
// RoomEventFilter as JSON.
Filter string
}
// MessagesResponse is one page.
type MessagesResponse struct {
// Start is the token this page began at, which is the ?from= the
// client sent back to it.
Start string `json:"start"`
// End is the token for the next page. It is left out when the page
// reached the end of what the client may see, which is how a client
// knows to stop scrolling.
End string `json:"end,omitempty"`
Chunk []roomserver.ClientEvent `json:"chunk"`
// State is the state of the events in the chunk: the member events of
// their senders, when the filter asked for lazy loading. A client
// needs them to render a name against a message from somebody who is
// no longer in the room.
State []roomserver.ClientEvent `json:"state,omitempty"`
}
// Messages answers one page of a room's history.
func (s *API) Messages(ctx context.Context, req MessagesRequest) (MessagesResponse, error) {
from, err := ParseToken(req.From)
if err != nil {
return MessagesResponse{}, errInvalidTokenf("from: %v", err)
}
to, err := ParseToken(req.To)
if err != nil {
return MessagesResponse{}, errInvalidTokenf("to: %v", err)
}
filter, err := s.messageFilter(ctx, req.Localpart, req.Filter)
if err != nil {
return MessagesResponse{}, err
}
direction := req.Direction
if direction != storage.DirectionForward {
// Backwards is the default: the spec makes `dir` required, but
// every client that leaves it out means "scroll up".
direction = storage.DirectionBackward
}
limit := req.Limit
if limit <= 0 {
limit = DefaultTimelineLimit
}
limit = min(limit, MaxTimelineLimit)
// Which end of the window the client's two tokens are depends on which
// way it is paging: scrolling up, `from` is the ceiling and `to` the
// floor, and scrolling down it is the other way round. An unset bound
// is zero, which the read already takes as "unbounded at that end" —
// so paging backwards from the end of the room needs nothing special.
low, high := from.PDU, to.PDU
if direction == storage.DirectionBackward {
low, high = to.PDU, from.PDU
}
timeline, err := s.rooms.Timeline(ctx, roomserver.TimelineRequest{
RoomID: req.RoomID,
User: req.User,
Low: low,
High: high,
Direction: direction,
Limit: limit,
Filter: filter.TimelineFilter(),
// Scrolling up past the oldest event this server holds of a room
// joined over federation asks the room's other servers for more.
Backfill: true,
})
if err != nil {
return MessagesResponse{}, err
}
// The chunk is in the direction asked for: the spec says a backwards
// page is in reverse chronological order, because that is the order a
// client scrolling up consumes it in. The read returns events oldest
// first — which is what /sync wants — so a backwards page is turned
// round here.
chunk := timeline.Events
if direction == storage.DirectionBackward {
chunk = reversed(chunk)
}
if chunk == nil {
chunk = []roomserver.ClientEvent{}
}
out := MessagesResponse{
Start: pduToken(from.PDU).String(),
Chunk: chunk,
}
// `from` is optional, and without it `start` has to name the end of
// the room the page actually began at rather than position zero: a
// client that paged backwards from the end and then used `start` to
// page forwards again would otherwise be handed the whole room.
if req.From == "" {
if direction == storage.DirectionBackward {
out.Start = pduToken(timeline.End).String()
} else {
out.Start = pduToken(timeline.Start).String()
}
}
// `end` is left out at the end of the room, which is how a client
// knows to stop asking. Reading backwards the far end is Start, the
// position below the oldest event; reading forwards it is End.
//
// The read fetches one more row than the limit, so `Limited` is the
// whole answer to "is there more": a page that came back exactly full
// and happened to be the last one still correctly has no `end`.
if timeline.Limited {
if direction == storage.DirectionBackward {
out.End = pduToken(timeline.Start).String()
} else {
out.End = pduToken(timeline.End).String()
}
}
if filter.LazyLoadMembers() {
state, err := s.rooms.MemberEvents(ctx, req.RoomID, sendersOf(chunk))
if err != nil {
return MessagesResponse{}, err
}
out.State = state
}
return out, nil
}
// messageFilter resolves the ?filter= of /messages.
//
// The parameter is a RoomEventFilter here rather than the whole filter that
// /sync takes — the spec defines it that way, because a filter about
// presence or account data would mean nothing to a room's history. A filter
// ID, though, names a full filter, so both shapes have to be accepted: an
// inline one is wrapped into the room's timeline slot, and a stored one is
// used as it stands.
func (s *API) messageFilter(ctx context.Context, localpart, raw string) (Filter, error) {
if raw == "" {
return Filter{}, nil
}
if raw[0] != '{' {
return s.filterFor(ctx, localpart, raw)
}
var roomEvents RoomEventFilter
if err := unmarshalStrict([]byte(raw), &roomEvents); err != nil {
return Filter{}, errInvalidParamf("filter: %v", err)
}
return Filter{Room: &RoomFilter{Timeline: &roomEvents}}, nil
}
// sendersOf lists the distinct senders of a chunk, in the order they first
// appear.
func sendersOf(events []roomserver.ClientEvent) []string {
seen := make(map[string]struct{}, len(events))
out := make([]string, 0, len(events))
for _, e := range events {
if _, dup := seen[e.Sender]; dup {
continue
}
seen[e.Sender] = struct{}{}
out = append(out, e.Sender)
}
return out
}