diff --git a/eventV1.go b/eventV1.go index 18f2cef0..885a9f4e 100644 --- a/eventV1.go +++ b/eventV1.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "time" "github.com/matrix-org/gomatrixserverlib/spec" "github.com/tidwall/gjson" @@ -11,6 +12,10 @@ import ( "golang.org/x/crypto/ed25519" ) +type stickyEventData struct { + DurationMillis int64 `json:"duration_ms"` +} + type eventV1 struct { redacted bool eventJSON []byte @@ -21,6 +26,9 @@ type eventV1 struct { EventIDRaw string `json:"event_id,omitempty"` PrevEvents []eventReference `json:"prev_events"` AuthEvents []eventReference `json:"auth_events"` + + UnstableSticky stickyEventData `json:"msc4354_sticky,omitempty"` + StableSticky stickyEventData `json:"sticky,omitempty"` } // MarshalJSON implements json.Marshaller @@ -259,6 +267,46 @@ func (e *eventV1) ToHeaderedJSON() ([]byte, error) { return eventJSON, nil } +func (e *eventV1) assumedStickyStartTime(received time.Time) time.Time { + if e.OriginServerTS().Time().Before(received) { + return e.OriginServerTS().Time() + } + return received +} + +func (e *eventV1) calculatedStickyEndTime(startTime time.Time) time.Time { + if e.StateKey() != nil { + return time.Time{} // zero, state events can't be sticky (only message events) + } + + // Stable first, unstable second + durationMillis := e.StableSticky.DurationMillis + if durationMillis == 0 { + durationMillis = e.UnstableSticky.DurationMillis + } + + if durationMillis == 0 { + return time.Time{} // zero, not sticky + } + if durationMillis > 3600000 { + durationMillis = 3600000 // cap at 1 hour + } + + return startTime.Add(time.Duration(durationMillis) * time.Millisecond) +} + +func (e *eventV1) IsSticky(received time.Time) bool { + endTime := e.StickyEndTime(received) + if endTime.IsZero() { + return false + } + return endTime.After(time.Now()) +} + +func (e *eventV1) StickyEndTime(received time.Time) time.Time { + return e.calculatedStickyEndTime(e.assumedStickyStartTime(received)) +} + func newEventFromUntrustedJSONV1(eventJSON []byte, roomVersion IRoomVersion) (PDU, error) { if r := gjson.GetBytes(eventJSON, "_*"); r.Exists() { return nil, fmt.Errorf("gomatrixserverlib NewEventFromUntrustedJSON: found top-level '_' key, is this a headered event: %v", string(eventJSON)) diff --git a/eventV1_test.go b/eventV1_test.go new file mode 100644 index 00000000..82788a25 --- /dev/null +++ b/eventV1_test.go @@ -0,0 +1,136 @@ +package gomatrixserverlib + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/tidwall/sjson" +) + +func makeStickyEvent(t *testing.T, durationMS int64, originTS int64, stateKey *string) PDU { + verImpl := MustGetRoomVersion(RoomVersionV12) + + m := map[string]interface{}{ + "sticky": map[string]int64{ + "duration_ms": durationMS, + }, + "room_id": "!L6nFTAu28CEi9yn9up1SUiKtTNnKt2yomgy2JFRT2Zk", + "type": "m.room.message", + "sender": "@user:localhost", + "content": map[string]interface{}{ + "body": "Hello, World!", + "msgtype": "m.text", + }, + "origin_server_ts": originTS, + "unsigned": make(map[string]interface{}), + "depth": 1, + "origin": "localhost", + "prev_events": []string{"$65vISquU7WNlFCaJeJ5uohlX4LVEPx5yEkAc1hpRf44"}, + "auth_events": []string{"$65vISquU7WNlFCaJeJ5uohlX4LVEPx5yEkAc1hpRf44"}, + "hashes": map[string]string{ + "sha256": "1234567890", + }, + "signatures": map[string]interface{}{ + "localhost": map[string]string{ + "ed25519:localhost": "doesn't matter because it's not checked", + }, + }, + } + if stateKey != nil { + m["state_key"] = *stateKey + } + if durationMS < 0 { + delete(m, "sticky") + } + + b, err := json.Marshal(m) + assert.NoError(t, err, "failed to marshal sticky message event") + + // we need to add hashes manually so we don't cause our event to become redacted + cj, err := CanonicalJSON(b) + assert.NoError(t, err, "failed to canonicalize sticky message event") + for _, key := range []string{"signatures", "unsigned", "hashes"} { + cj, err = sjson.DeleteBytes(cj, key) + assert.NoErrorf(t, err, "failed to delete %s from sticky message event", key) + } + sum := sha256.Sum256(cj) + b, err = sjson.SetBytes(b, "hashes.sha256", base64.RawURLEncoding.EncodeToString(sum[:])) + assert.NoError(t, err, "failed to set sha256 hash on sticky message event") + + ev, err := verImpl.NewEventFromUntrustedJSON(b) + assert.NoError(t, err, "failed to create new untrusted sticky message event") + assert.NotNil(t, ev) + return ev +} + +func TestIsSticky(t *testing.T) { + // Note: IsSticky internally uses `time.Now()`, so we can't play with the time too much. + + // Happy path + ev := makeStickyEvent(t, 20000, time.Now().UnixMilli(), nil) + assert.True(t, ev.IsSticky(time.Now())) + + // Origin before now + ev = makeStickyEvent(t, 20000, time.Now().UnixMilli()-10000, nil) + assert.True(t, ev.IsSticky(time.Now())) // should use the -10s time from origin as the start time + + // Origin in the future + ev = makeStickyEvent(t, 20000, time.Now().UnixMilli()+30000, nil) + assert.True(t, ev.IsSticky(time.Now())) // This will switch to using Now() instead of the 30s future, so should be in range + + // Origin is well before now, leading to expiration upon receipt + ev = makeStickyEvent(t, 20000, time.Now().UnixMilli()-30000, nil) + assert.False(t, ev.IsSticky(time.Now())) + + // Not a message event + stateKey := "state_key" + ev = makeStickyEvent(t, 20000, time.Now().UnixMilli(), &stateKey) + assert.False(t, ev.IsSticky(time.Now())) + + // Not a sticky event + ev = makeStickyEvent(t, -1, time.Now().UnixMilli(), nil) // -1 creates a non-sticky event + assert.False(t, ev.IsSticky(time.Now())) +} + +func TestStickyEndTime(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + nowTS := now.UnixMilli() + received := now + + // Happy path: event is a message event, and origin and duration are within range + ev := makeStickyEvent(t, 20000, nowTS, nil) + assert.Equal(t, now.Add(20*time.Second), ev.StickyEndTime(received)) + + // Origin before now, but duration still within range + ev = makeStickyEvent(t, 20000, nowTS-10000, nil) + assert.Equal(t, now.Add(10*time.Second), ev.StickyEndTime(received)) // +10 s because origin is -10s with a duration of 20s + + // Origin and duration before now + ev = makeStickyEvent(t, 20000, nowTS-30000, nil) + assert.Equal(t, received.Add(-10*time.Second), ev.StickyEndTime(received)) // 10s before received (-30+20 = -10) + + // Origin in the future (using received time instead), duration still within range + ev = makeStickyEvent(t, 20000, nowTS+10000, nil) + assert.Equal(t, now.Add(20*time.Second), ev.StickyEndTime(received)) // +20s because we'll use the received time as a start time + + // Origin is in the future, which places the start time before the origin + ev = makeStickyEvent(t, 20000, nowTS+30000, nil) + assert.Equal(t, received.Add(20*time.Second), ev.StickyEndTime(received)) // The origin is ignored, so +20s for the duration + + // Duration is more than an hour + ev = makeStickyEvent(t, 3699999, nowTS, nil) + assert.Equal(t, now.Add(1*time.Hour), ev.StickyEndTime(received)) + + // Not a message event + stateKey := "state_key" + ev = makeStickyEvent(t, 20000, nowTS, &stateKey) + assert.Equal(t, time.Time{}, ev.StickyEndTime(received)) + + // Not a sticky event + ev = makeStickyEvent(t, -1, nowTS, nil) // -1 creates a non-sticky event + assert.Equal(t, time.Time{}, ev.StickyEndTime(received)) +} diff --git a/pdu.go b/pdu.go index 25b7926c..a51b2984 100644 --- a/pdu.go +++ b/pdu.go @@ -3,6 +3,7 @@ package gomatrixserverlib import ( "encoding/json" "fmt" + "time" "github.com/matrix-org/gomatrixserverlib/spec" "golang.org/x/crypto/ed25519" @@ -52,6 +53,15 @@ type PDU interface { JSON() []byte // TODO: remove AuthEventIDs() []string // TODO: remove ToHeaderedJSON() ([]byte, error) // TODO: remove + // IsSticky returns true if the event is *currently* considered "sticky" given the received time. + // Sticky events are annotated as sticky and carry strong delivery guarantees to clients (and + // therefore servers). `received` should be specified as the time the event was received by the + // server if, and only if, the event was received over `/send`. Otherwise, `received` should be + // `time.Now()`. Returns false if the event is not sticky, or no longer sticky. + IsSticky(received time.Time) bool + // StickyEndTime returns the time at which the event is no longer considered "sticky". See `IsSticky` + // for details on sticky events. Returns `time.Time{}` (zero) if the event is not a sticky event. + StickyEndTime(received time.Time) time.Time } // Convert a slice of concrete PDU implementations to a slice of PDUs. This is useful when