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
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>
272 lines
8.5 KiB
Go
272 lines
8.5 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// The receipt types the Client-Server API defines.
|
|
const (
|
|
// ReceiptRead is public: everybody in the room sees it.
|
|
ReceiptRead = "m.read"
|
|
|
|
// ReceiptReadPrivate is the user's own, and is reported only back to
|
|
// them. It exists so that a user can mark a room read without telling
|
|
// the room they read it.
|
|
ReceiptReadPrivate = "m.read.private"
|
|
|
|
// ReceiptFullyRead is not a receipt at all: it is the read marker, and
|
|
// is stored as the room account data type m.fully_read. It is named
|
|
// here because POST /read_markers takes all three in one body.
|
|
ReceiptFullyRead = "m.fully_read"
|
|
)
|
|
|
|
// requireJoined refuses a caller who is not in the room.
|
|
//
|
|
// Every write in this file is a claim about a room — "I am typing in it",
|
|
// "I have read this far" — rather than data belonging to the caller, so
|
|
// being in the room is the condition for all of them.
|
|
func (s *API) requireJoined(ctx context.Context, roomID, userID string) error {
|
|
membership, err := s.rooms.Membership(ctx, roomID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if membership != event.MembershipJoin {
|
|
return errForbiddenf("%s is not joined to %s", userID, roomID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReceiptRequest is POST /rooms/{roomID}/receipt/{receiptType}/{eventID}.
|
|
type ReceiptRequest struct {
|
|
RoomID string
|
|
|
|
// User is the full user ID sending the receipt.
|
|
User string
|
|
|
|
ReceiptType string
|
|
EventID string
|
|
|
|
// ThreadID is the thread the receipt is for, or empty for the room's
|
|
// main timeline. Threads are PLAN.md phase 10; the value is stored so
|
|
// that a client that sends one gets it back, rather than having its
|
|
// threaded receipt silently collapse onto the main timeline's.
|
|
ThreadID string
|
|
}
|
|
|
|
// SetReceipt records a read receipt.
|
|
func (s *API) SetReceipt(ctx context.Context, req ReceiptRequest) error {
|
|
switch req.ReceiptType {
|
|
case ReceiptRead, ReceiptReadPrivate, ReceiptFullyRead:
|
|
default:
|
|
return errInvalidParamf("%q is not a receipt type this server accepts", req.ReceiptType)
|
|
}
|
|
|
|
if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The spec routes m.fully_read through this endpoint too, and it is
|
|
// account data rather than a receipt — but it is still a claim about a
|
|
// room the caller has to be in, which is why the check above comes
|
|
// first.
|
|
if req.ReceiptType == ReceiptFullyRead {
|
|
return s.setFullyRead(ctx, req.RoomID, req.User, req.EventID)
|
|
}
|
|
|
|
_, moved, err := s.store.SetReceipt(ctx, storage.Receipt{
|
|
RoomID: req.RoomID,
|
|
UserID: req.User,
|
|
ReceiptType: req.ReceiptType,
|
|
ThreadID: req.ThreadID,
|
|
EventID: req.EventID,
|
|
TimestampMS: s.nowMS(),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Nothing moved means the user had already read further, and there is
|
|
// nothing for anybody to be woken about.
|
|
if !moved {
|
|
return nil
|
|
}
|
|
// A private receipt concerns only its sender; a public one is part of
|
|
// the room's ephemeral stream and everybody in it wants it.
|
|
if req.ReceiptType == ReceiptReadPrivate {
|
|
s.notifier.NotifyUser(req.User)
|
|
return nil
|
|
}
|
|
s.notifier.NotifyRoom(req.RoomID)
|
|
// A private receipt never leaves the server, which is the point of it.
|
|
if s.outbound != nil && s.isLocal(req.User) {
|
|
s.outbound.Receipt(ctx, req.RoomID, req.User, req.ReceiptType, req.EventID, req.ThreadID, s.nowMS())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadMarkersRequest is POST /rooms/{roomID}/read_markers.
|
|
type ReadMarkersRequest struct {
|
|
RoomID string
|
|
User string
|
|
|
|
// FullyRead is the event up to which the user has read, stored as the
|
|
// room account data m.fully_read. It is where a client draws the
|
|
// "unread from here" line, which is a different thing from a receipt:
|
|
// the line stays where the user left it even as they scroll past.
|
|
FullyRead string
|
|
|
|
// Read and ReadPrivate are ordinary receipts, sent in the same request
|
|
// because a client moving the marker almost always moves both.
|
|
Read string
|
|
ReadPrivate string
|
|
}
|
|
|
|
// SetReadMarkers implements the read markers endpoint: up to three
|
|
// independent writes from one request.
|
|
//
|
|
// They are not atomic, and cannot usefully be: the marker is account data
|
|
// and the receipts are the receipt stream, which are two streams with two
|
|
// positions. A failure partway through leaves the earlier writes in place,
|
|
// which is the same state a client would reach by sending them separately
|
|
// and losing its connection — and which the next call corrects.
|
|
func (s *API) SetReadMarkers(ctx context.Context, req ReadMarkersRequest) error {
|
|
if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil {
|
|
return err
|
|
}
|
|
|
|
if req.FullyRead != "" {
|
|
if err := s.setFullyRead(ctx, req.RoomID, req.User, req.FullyRead); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, receipt := range []struct {
|
|
receiptType string
|
|
eventID string
|
|
}{
|
|
{ReceiptRead, req.Read},
|
|
{ReceiptReadPrivate, req.ReadPrivate},
|
|
} {
|
|
if receipt.eventID == "" {
|
|
continue
|
|
}
|
|
if err := s.SetReceipt(ctx, ReceiptRequest{
|
|
RoomID: req.RoomID,
|
|
User: req.User,
|
|
ReceiptType: receipt.receiptType,
|
|
EventID: receipt.eventID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setFullyRead writes the read marker as room account data.
|
|
func (s *API) setFullyRead(ctx context.Context, roomID, userID, eventID string) error {
|
|
localpart, ok := s.localpartOf(userID)
|
|
if !ok {
|
|
return errForbiddenf("%s is not a user of this server", userID)
|
|
}
|
|
content, err := json.Marshal(map[string]string{"event_id": eventID})
|
|
if err != nil {
|
|
return fmt.Errorf("marshalling the read marker: %w", err)
|
|
}
|
|
if err := s.accounts.SetAccountData(ctx, localpart, roomID, ReceiptFullyRead, content); err != nil {
|
|
return err
|
|
}
|
|
s.notifier.NotifyUser(userID)
|
|
return nil
|
|
}
|
|
|
|
// receiptsEvent builds the m.receipt EDU of one room.
|
|
//
|
|
// The shape is the spec's, and it is an awkward one: a map of event ID to
|
|
// receipt type to user ID to the receipt's data. It is nested that way
|
|
// because a client's job is to answer "who has read up to this event",
|
|
// which this shape makes a lookup and a flat list would make a scan.
|
|
func receiptsEvent(receipts []storage.Receipt) *EphemeralEvent {
|
|
if len(receipts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
type receiptData struct {
|
|
TS int64 `json:"ts"`
|
|
ThreadID string `json:"thread_id,omitempty"`
|
|
}
|
|
content := map[string]map[string]map[string]receiptData{}
|
|
for _, r := range receipts {
|
|
byType, ok := content[r.EventID]
|
|
if !ok {
|
|
byType = map[string]map[string]receiptData{}
|
|
content[r.EventID] = byType
|
|
}
|
|
byUser, ok := byType[r.ReceiptType]
|
|
if !ok {
|
|
byUser = map[string]receiptData{}
|
|
byType[r.ReceiptType] = byUser
|
|
}
|
|
byUser[r.UserID] = receiptData{TS: r.TimestampMS, ThreadID: r.ThreadID}
|
|
}
|
|
|
|
body, err := json.Marshal(content)
|
|
if err != nil {
|
|
// Every value here is a string or an integer, so this cannot
|
|
// happen; dropping the EDU beats failing the sync if it does.
|
|
return nil
|
|
}
|
|
return &EphemeralEvent{Type: "m.receipt", Content: body}
|
|
}
|
|
|
|
// roomReceipts reads the receipts a user is entitled to see in a set of
|
|
// rooms, between two positions.
|
|
//
|
|
// Private receipts are filtered here rather than in SQL: the query is the
|
|
// same one the public receipts come from, and a WHERE clause that had to
|
|
// name the asking user would turn one query per sync into two.
|
|
func (s *API) roomReceipts(ctx context.Context, userID string, roomIDs []string, from, to int64) (map[string][]storage.Receipt, error) {
|
|
if len(roomIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
receipts, err := s.store.Receipts(ctx, roomIDs, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// An initial sync also wants the user's own receipts however old they
|
|
// are: a client that has just logged in has to know where it had read
|
|
// up to, and that receipt was set long before this sync's window.
|
|
if from == 0 {
|
|
own, err := s.store.ReceiptsForUser(ctx, userID, roomIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
receipts = append(receipts, own...)
|
|
}
|
|
|
|
out := map[string][]storage.Receipt{}
|
|
seen := map[storage.Receipt]struct{}{}
|
|
for _, r := range receipts {
|
|
if r.ReceiptType == ReceiptReadPrivate && r.UserID != userID {
|
|
continue
|
|
}
|
|
if _, dup := seen[r]; dup {
|
|
// The user's own receipts can arrive from both reads above.
|
|
continue
|
|
}
|
|
seen[r] = struct{}{}
|
|
out[r.RoomID] = append(out[r.RoomID], r)
|
|
}
|
|
for roomID := range out {
|
|
slices.SortFunc(out[roomID], func(a, b storage.Receipt) int {
|
|
return cmp.Compare(a.StreamPos, b.StreamPos)
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|