package roomserver import ( "context" "fmt" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" "github.com/dariuszjaniak/warp/server/internal/storage" ) // VisibleRange is the span of a room's event stream a user may read. // // It is a range rather than a yes or no because membership is not a yes or // no either. A user who has left a room keeps what they saw while they were // in it and sees nothing after their leave; a user who joined a room whose // history visibility is `joined` sees nothing from before their join. Both // are the same question — where does this user's window start and end — and // answering it once per room is what lets a timeline read be a single // bounded query rather than a filter applied to every event in it. type VisibleRange struct { // Allowed is false when the user may see nothing of the room at all. Allowed bool // From is the first position the user may see, exclusive. Zero means // the start of the room, backfilled history included: positions can be // negative, so zero is the one value that cannot be a real floor. From int64 // To is the last position the user may see, inclusive. Zero means the // top of the stream, and goes on meaning that as the stream grows. To int64 // Membership is the user's current membership, empty for a user who was // never in the room. Membership string // HistoryVisibility is the room's current history visibility, which the // bounds above were derived from. HistoryVisibility string } // Contains reports whether a position is inside the range. func (v VisibleRange) Contains(pos int64) bool { switch { case !v.Allowed: return false case v.From != 0 && pos <= v.From: return false case v.To != 0 && pos > v.To: return false } return true } // Clamp narrows a caller's window to what the range allows, reporting // whether anything is left of it. // // The bounds are exclusive at the bottom and inclusive at the top, the same // as a storage.TimelineRequest, so the result can be handed straight to // one. func (v VisibleRange) Clamp(from, to int64) (int64, int64, bool) { if !v.Allowed { return 0, 0, false } // Zero is unbounded at either end, and every other value — negative // ones inside backfilled history among them — is a real bound, so the // narrower of two bounds is not simply the larger or smaller number. if v.From != 0 && (from == 0 || from < v.From) { from = v.From } if v.To != 0 && (to == 0 || to > v.To) { to = v.To } if from != 0 && to != 0 && from >= to { return 0, 0, false } return from, to, true } // VisibleRange works out what a user may read of a room. // // The rules are the spec's history visibility: // // - world_readable: everything, to anybody, member or not. // - shared: everything, to anybody who is or has been a member. // - invited: from the user's invite onwards. // - joined: from the user's join onwards. // // A user who has left or was banned is bounded at their own membership // event in every case, which is what stops a kicked user's client from // going on streaming the room. An invitee is told enough to decide — the // stripped state that travels with the invite — and no timeline. // // The approximation here is the room's *current* history visibility. The // spec judges each event by the visibility in force when it was sent, so a // room that was once world_readable and is now `joined` hides history here // that the spec would show, and the reverse shows history it would hide. // Doing it exactly means resolving the history visibility at every event in // the window — a state read per event — and belongs with the state storage // work of PLAN.md open question 1. Until then this follows the setting the // room's members last agreed on, which is the one they would expect to // apply. func (r *API) VisibleRange(ctx context.Context, roomID, userID string) (VisibleRange, error) { room, rules, err := r.roomAndRules(ctx, roomID) if err != nil { return VisibleRange{}, err } summary, err := r.store.RoomSummary(ctx, room.ID) if err != nil { return VisibleRange{}, err } return r.visibleRange(ctx, roomID, rules, userID, summary.HistoryVisibility) } // visibleRange is VisibleRange with the room's history visibility already // in hand, for the callers — a sync over many rooms — that have it. func (r *API) visibleRange(ctx context.Context, roomID string, rules roomversion.Rules, userID, historyVisibility string) (VisibleRange, error) { out := VisibleRange{HistoryVisibility: historyVisibility} changes, err := r.membershipHistory(ctx, roomID, rules, userID) if err != nil { return VisibleRange{}, err } if len(changes) > 0 { out.Membership = changes[len(changes)-1].membership } worldReadable := historyVisibility == HistoryVisibilityWorldReadable switch out.Membership { case event.MembershipJoin: out.Allowed = true case event.MembershipLeave, event.MembershipBan: out.Allowed = true // Bounded at their own membership event: what they saw stays // theirs, what happened afterwards does not. out.To = changes[len(changes)-1].position default: // Invited, knocking, or never a member. Only a world-readable room // has anything to show them. out.Allowed = worldReadable } // World-readable removes the floor — they could read the room as a // stranger, so there is nothing to gain by hiding it from them as a // member — but not the ceiling: a leaver's *membership* view still ends // where they left, and the stranger's view is what /messages on a // world-readable room gives them anyway. if !worldReadable && out.Allowed { switch historyVisibility { case HistoryVisibilityInvited, HistoryVisibilityJoined: out.From = spanStart(changes, historyVisibility) } } return out, nil } // membershipChange is one of a user's membership events in a room. type membershipChange struct { membership string position int64 } // membershipHistory reads a user's membership events in a room, oldest // first. func (r *API) membershipHistory(ctx context.Context, roomID string, rules roomversion.Rules, userID string) ([]membershipChange, error) { stored, err := r.store.UserMembershipEvents(ctx, roomID, userID) if err != nil { return nil, err } out := make([]membershipChange, 0, len(stored)) for _, s := range stored { e, err := parseEvent(s, rules) if err != nil { return nil, err } membership, err := e.Membership() if err != nil { // A member event with no membership could not have been // accepted into the room, so this is a corrupted row rather // than something a client sent. Skipping it would widen the // window silently. return nil, fmt.Errorf("member event %s of %s has no membership: %w", s.EventID, roomID, err) } out = append(out, membershipChange{membership: membership, position: s.StreamOrdering}) } return out, nil } // spanStart is the floor of the user's current membership span, exclusive. // // For `joined` visibility that is the join that began the span they are in // — or, for a user who has left, the one that began the span they left // from. For `invited` it is the invite that preceded that join, because an // invitee is entitled to the room from the moment they were asked. // // A user who joined, left and rejoined therefore sees their latest span and // not the earlier ones, which is what the spec's per-event rule works out // to for everything after their last join. func spanStart(changes []membershipChange, historyVisibility string) int64 { // Walk back to the membership event that opened the current span. start := len(changes) - 1 for start > 0 && changes[start].membership != event.MembershipJoin { start-- } for start > 0 && changes[start-1].membership == event.MembershipJoin { // Consecutive joins are profile changes, not rejoins; the span // began at the first of them. start-- } if historyVisibility == HistoryVisibilityInvited && start > 0 && changes[start-1].membership == event.MembershipInvite { start-- } // Exclusive, so one below the event itself: the membership event is the // first thing the user sees, which is how a client renders "you joined". return storage.PositionBefore(changes[start].position) } // requireReadable refuses a caller who may see nothing of a room, and // returns the range for one who may. func (r *API) requireReadable(ctx context.Context, roomID, userID string) (VisibleRange, error) { visible, err := r.VisibleRange(ctx, roomID, userID) if err != nil { return VisibleRange{}, err } return visible, visible.refusal(roomID, userID) } // refusal turns a range that allows nothing into the error a client is // given for it. func (v VisibleRange) refusal(roomID, userID string) error { if v.Allowed { return nil } if v.Membership == event.MembershipInvite { return fmt.Errorf("%w: %s has been invited to %s but has not joined it", ErrForbidden, userID, roomID) } return fmt.Errorf("%w: %s is not in %s", ErrForbidden, userID, roomID) }