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 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.2 KiB
Go
188 lines
6.2 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// To-device messaging: a message from one device to another, which the
|
|
// server stores and forwards without reading.
|
|
//
|
|
// It exists for end-to-end encryption — a device sending another one a
|
|
// Megolm session key is a to-device message — and the server deliberately
|
|
// cannot tell what is in one. Phase 7 builds the keyserver on top of this;
|
|
// the transport itself belongs here, because the only way a device ever
|
|
// receives one is in a /sync response.
|
|
|
|
// wildcardDevice is the device ID that means "every device of this user".
|
|
const wildcardDevice = "*"
|
|
|
|
// SendToDeviceRequest is PUT /sendToDevice/{eventType}/{txnID}.
|
|
type SendToDeviceRequest struct {
|
|
// Sender is the full user ID sending, which is the caller.
|
|
Sender string
|
|
|
|
EventType string
|
|
|
|
// Messages is user ID -> device ID -> content, as the request body
|
|
// nests it. A device ID of "*" addresses every device of that user.
|
|
Messages map[string]map[string]json.RawMessage
|
|
}
|
|
|
|
// SendToDevice queues messages for delivery.
|
|
//
|
|
// A message to a user of another server goes to the federation queue. With
|
|
// federation off it is dropped rather than refused. That is deliberate: a
|
|
// client encrypting for a room with remote members sends one message per
|
|
// device in one request, and failing the request because federation is
|
|
// off would stop it from delivering to the local devices as well. The
|
|
// dropped recipients simply never see the key and their client reports an
|
|
// undecryptable message, which is what they would see anyway.
|
|
func (s *API) SendToDevice(ctx context.Context, req SendToDeviceRequest) error {
|
|
if req.EventType == "" {
|
|
return errInvalidParamf("an event type is required")
|
|
}
|
|
|
|
var (
|
|
queued []storage.NewToDeviceMessage
|
|
woken []string
|
|
remote = map[string]map[string]json.RawMessage{}
|
|
sentAt = s.nowMS()
|
|
)
|
|
for userID, byDevice := range req.Messages {
|
|
localpart, ok := s.localpartOf(userID)
|
|
if !ok {
|
|
// A remote recipient goes to the federation queue when there
|
|
// is one and the sender is ours; a remote sender's message to
|
|
// a remote user is not this server's to forward.
|
|
if s.outbound != nil && s.isLocal(req.Sender) {
|
|
remote[userID] = byDevice
|
|
continue
|
|
}
|
|
s.log.Debug("dropping a to-device message for a remote user",
|
|
"sender", req.Sender, "recipient", userID)
|
|
continue
|
|
}
|
|
|
|
for deviceID, content := range byDevice {
|
|
if deviceID != wildcardDevice {
|
|
queued = append(queued, storage.NewToDeviceMessage{
|
|
Localpart: localpart,
|
|
DeviceID: deviceID,
|
|
Sender: req.Sender,
|
|
Type: req.EventType,
|
|
Content: contentOrEmpty(content),
|
|
SentAtMS: sentAt,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// A wildcard is expanded here rather than stored as one: the
|
|
// set of a user's devices changes, and a message addressed to
|
|
// "every device" means the ones that existed when it was sent.
|
|
// Storing the wildcard would deliver it to a device that was
|
|
// created afterwards, which for a key share is both useless
|
|
// and a leak.
|
|
devices, err := s.accounts.Devices(ctx, localpart)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, device := range devices {
|
|
queued = append(queued, storage.NewToDeviceMessage{
|
|
Localpart: localpart,
|
|
DeviceID: device.ID,
|
|
Sender: req.Sender,
|
|
Type: req.EventType,
|
|
Content: contentOrEmpty(content),
|
|
SentAtMS: sentAt,
|
|
})
|
|
}
|
|
}
|
|
woken = append(woken, userID)
|
|
}
|
|
|
|
if len(remote) > 0 {
|
|
s.outbound.ToDevice(ctx, req.Sender, req.EventType, remote)
|
|
}
|
|
if len(queued) == 0 {
|
|
return nil
|
|
}
|
|
if _, err := s.store.SendToDevice(ctx, queued); err != nil {
|
|
return err
|
|
}
|
|
s.notifier.NotifyUsers(woken)
|
|
return nil
|
|
}
|
|
|
|
// contentOrEmpty keeps a null or absent content out of the store: the
|
|
// to-device content is passed through untouched, and "untouched" has to
|
|
// still be valid JSON on the way out.
|
|
func contentOrEmpty(content json.RawMessage) []byte {
|
|
if len(content) == 0 {
|
|
return []byte("{}")
|
|
}
|
|
return content
|
|
}
|
|
|
|
// toDeviceEvent is one message in the shape a sync reports it.
|
|
type toDeviceEvent struct {
|
|
Sender string `json:"sender"`
|
|
Type string `json:"type"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
|
|
// toDeviceMessages reads a device's queue between two positions and returns
|
|
// the events plus the position of the last one.
|
|
//
|
|
// The returned position is what the next token carries, and it is the
|
|
// position of the last message *delivered* rather than the top of the
|
|
// stream: a queue longer than one batch is drained over several syncs, and
|
|
// a token naming the top would skip the rest.
|
|
func (s *API) toDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64, limit int) ([]toDeviceEvent, int64, error) {
|
|
if deviceID == "" {
|
|
// An access token always names a device, so this cannot happen
|
|
// through the API; a guest session later might.
|
|
return nil, from, nil
|
|
}
|
|
|
|
msgs, err := s.store.ToDeviceMessages(ctx, localpart, deviceID, from, to, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if len(msgs) == 0 {
|
|
return nil, from, nil
|
|
}
|
|
|
|
out := make([]toDeviceEvent, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
out = append(out, toDeviceEvent{
|
|
Sender: m.Sender,
|
|
Type: m.Type,
|
|
Content: m.Content,
|
|
})
|
|
}
|
|
return out, msgs[len(msgs)-1].StreamPos, nil
|
|
}
|
|
|
|
// ackToDevice deletes the messages a device has acknowledged by syncing
|
|
// past them.
|
|
//
|
|
// A device acknowledges a batch by sending back the token that batch came
|
|
// with, so the deletion happens on the *next* sync rather than when the
|
|
// batch is sent. A client that crashes between the two gets the batch
|
|
// again, which is the right way round: a lost key share leaves a message
|
|
// permanently undecryptable, and a duplicate one is ignored.
|
|
func (s *API) ackToDevice(ctx context.Context, localpart, deviceID string, through int64) {
|
|
if deviceID == "" || through <= 0 {
|
|
return
|
|
}
|
|
if err := s.store.DeleteToDeviceMessages(ctx, localpart, deviceID, through); err != nil {
|
|
// Failing the sync over this would be worse than leaving the rows:
|
|
// the client has the messages, and the next sync will try again.
|
|
s.log.Warn("could not drop acknowledged to-device messages",
|
|
"localpart", localpart, "device_id", deviceID, "through", through, "error", err)
|
|
}
|
|
}
|