UNPKG

matrix-js-sdk

Version:
353 lines (334 loc) 15.3 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import { logger as loggerInstance } from "../logger.js"; import { TypedEventEmitter } from "./typed-event-emitter.js"; var logger = loggerInstance.getChild("RoomStickyEvents"); export var RoomStickyEventsEvent = /*#__PURE__*/function (RoomStickyEventsEvent) { RoomStickyEventsEvent["Update"] = "RoomStickyEvents.Update"; return RoomStickyEventsEvent; }({}); function assertIsUserId(value) { if (typeof value !== "string") throw new Error("Not a string"); if (!value.startsWith("@")) throw new Error("Not a userId"); } /** * Tracks sticky events on behalf of one room, and fires an event * whenever a sticky event is updated or replaced. */ export class RoomStickyEventsStore extends TypedEventEmitter { constructor() { super(...arguments); /** * Sticky event map is a nested map of: * eventType -> `content.sticky_key sender` -> StickyMatrixEvent[] * * The events are ordered in latest to earliest expiry, so that the first event * in the array will always be the "current" one. */ _defineProperty(this, "stickyEventsMap", new Map()); /** * These are sticky events that have no sticky key and therefore exist outside the tuple * system above. They are just held in this Set until they expire. */ _defineProperty(this, "unkeyedStickyEvents", new Set()); _defineProperty(this, "stickyEventTimer", void 0); _defineProperty(this, "nextStickyEventExpiryTs", Number.MAX_SAFE_INTEGER); /** * Clean out any expired sticky events. */ _defineProperty(this, "cleanExpiredStickyEvents", () => { var now = Date.now(); var removedEvents = []; // We will recalculate this as we check all events. this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; for (var [eventType, innerEvents] of this.stickyEventsMap.entries()) { var _this$stickyEventsMap; for (var [innerMapKey, [currentEvent, ...previousEvents]] of innerEvents) { // we only added items with `sticky` into this map so we can assert non-null here if (now >= currentEvent.unstableStickyExpiresAt) { logger.debug("Expiring sticky event", currentEvent.getId()); removedEvents.push(currentEvent); this.stickyEventsMap.get(eventType).delete(innerMapKey); } else { // Ensure we remove any previous events which have now expired, to avoid unbounded memory consumption. this.stickyEventsMap.get(eventType).set(innerMapKey, [currentEvent, ...previousEvents.filter(e => e.unstableStickyExpiresAt <= now)]); // If not removing the event, check to see if it's the next lowest expiry. this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, currentEvent.unstableStickyExpiresAt); } } // Clean up map after use. if (((_this$stickyEventsMap = this.stickyEventsMap.get(eventType)) === null || _this$stickyEventsMap === void 0 ? void 0 : _this$stickyEventsMap.size) === 0) { this.stickyEventsMap.delete(eventType); } } for (var event of this.unkeyedStickyEvents) { if (now >= event.unstableStickyExpiresAt) { logger.debug("Expiring sticky event", event.getId()); this.unkeyedStickyEvents.delete(event); removedEvents.push(event); } else { // If not removing the event, check to see if it's the next lowest expiry. this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, event.unstableStickyExpiresAt); } } if (removedEvents.length) { this.emit(RoomStickyEventsEvent.Update, [], [], removedEvents); } // Finally, schedule the next run. this.scheduleStickyTimer(); }); } /** * Sort two sticky events by order of expiry. This assumes the sticky events have the same * `type`, `sticky_key` and `sender`. * @returns A positive value if event A will expire sooner, or a negative value if event B will expire sooner. */ static sortStickyEvent(eventA, eventB) { var _eventB$getId, _eventA$getId; // Sticky events with the same key have to use the same expiration duration. // Hence, comparing via `origin_server_ts` yields the exact same result as comparing their expiration time. if (eventB.getTs() !== eventA.getTs()) { return eventB.getTs() - eventA.getTs(); } if (((_eventB$getId = eventB.getId()) !== null && _eventB$getId !== void 0 ? _eventB$getId : "") > ((_eventA$getId = eventA.getId()) !== null && _eventA$getId !== void 0 ? _eventA$getId : "")) { return 1; } // This should fail as we've got corruption in our sticky array. throw Error("Comparing two sticky events with the same event ID is not allowed."); } /** * Generate the correct key for an event to be found in the inner maps of `stickyEventsMap`. * @param stickyKey The sticky key of an event. * @param sender The sender of the event. */ static stickyMapKey(stickyKey, sender) { return "".concat(stickyKey).concat(sender); } /** * Get all sticky events that are currently active. * @returns An iterable set of events. */ *getStickyEvents() { yield* this.unkeyedStickyEvents; for (var innerMap of this.stickyEventsMap.values()) { // Inner map contains a map of sender+stickykeys => all sticky events for (var events of innerMap.values()) { // The first sticky event is the "current" one in the sticky map. yield events[0]; } } } /** * Get an active sticky event that match the given `type`, `sender`, and `stickyKey` * @param type The event `type`. * @param sender The sender of the sticky event. * @param stickyKey The sticky key used by the event. * @returns A matching active sticky event, or undefined. */ getKeyedStickyEvent(sender, type, stickyKey) { var _this$stickyEventsMap2; assertIsUserId(sender); return (_this$stickyEventsMap2 = this.stickyEventsMap.get(type)) === null || _this$stickyEventsMap2 === void 0 || (_this$stickyEventsMap2 = _this$stickyEventsMap2.get(RoomStickyEventsStore.stickyMapKey(stickyKey, sender))) === null || _this$stickyEventsMap2 === void 0 ? void 0 : _this$stickyEventsMap2[0]; } /** * Get active sticky events without a sticky key that match the given `type` and `sender`. * @param type The event `type`. * @param sender The sender of the sticky event. * @returns An array of matching sticky events. */ getUnkeyedStickyEvent(sender, type) { return [...this.unkeyedStickyEvents].filter(ev => ev.getType() === type && ev.getSender() === sender); } /** * Adds a sticky event into the local sticky event map. * * NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted. * * @throws If the `event` does not contain valid sticky data. * @param event The MatrixEvent that contains sticky data. * @returns An object describing whether the event was added to the map, * and the previous event it may have replaced. */ addStickyEvent(event) { var _this$stickyEventsMap3, _this$stickyEventsMap4, _this$stickyEventsMap5; var stickyKey = event.getContent().msc4354_sticky_key; if (typeof stickyKey !== "string" && stickyKey !== undefined) { throw new Error("".concat(event.getId(), " is missing msc4354_sticky_key")); } // With this we have the guarantee, that all events in stickyEventsMap are correctly formatted if (event.unstableStickyExpiresAt === undefined) { throw new Error("".concat(event.getId(), " is missing msc4354_sticky.duration_ms")); } var sender = event.getSender(); var type = event.getType(); assertIsUserId(sender); if (event.unstableStickyExpiresAt <= Date.now()) { logger.info("ignored sticky event with older expiration time than current time", stickyKey); return { added: false }; } // While we fully expect the server to always provide the correct value, // this is just insurance to protect against attacks on our Map. if (!sender.startsWith("@")) { throw new Error("Expected sender to start with @"); } var stickyEvent = event; if (stickyKey === undefined) { this.unkeyedStickyEvents.add(stickyEvent); // Recalculate the next expiry time. this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); this.scheduleStickyTimer(); return { added: true }; } // Why this is safe: // A type may contain anything but the *sender* is tightly // constrained so that a key will always end with a @<user_id> // E.g. Where a malicious event type might be "rtc.member.event@foo:bar" the key becomes: // "rtc.member.event.@foo:bar@bar:baz" var innerMapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender); var currentEventSet = [stickyEvent, ...((_this$stickyEventsMap3 = (_this$stickyEventsMap4 = this.stickyEventsMap.get(type)) === null || _this$stickyEventsMap4 === void 0 ? void 0 : _this$stickyEventsMap4.get(innerMapKey)) !== null && _this$stickyEventsMap3 !== void 0 ? _this$stickyEventsMap3 : [])].sort(RoomStickyEventsStore.sortStickyEvent); if (!this.stickyEventsMap.has(type)) { this.stickyEventsMap.set(type, new Map()); } (_this$stickyEventsMap5 = this.stickyEventsMap.get(type)) === null || _this$stickyEventsMap5 === void 0 || _this$stickyEventsMap5.set(innerMapKey, currentEventSet); // Recalculate the next expiry time. this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); this.scheduleStickyTimer(); return { added: currentEventSet[0] === stickyEvent, prevEvent: currentEventSet === null || currentEventSet === void 0 ? void 0 : currentEventSet[1] }; } /** * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any * changes were made. * @param events A set of new sticky events. */ addStickyEvents(events) { var added = []; var updated = []; for (var event of events) { try { var result = this.addStickyEvent(event); if (result.added) { if (result.prevEvent) { // e is validated as a StickyMatrixEvent by virtue of `addStickyEvent` returning added: true. updated.push({ current: event, previous: result.prevEvent }); } else { added.push(event); } } } catch (ex) { logger.warn("ignored invalid sticky event", ex); } } if (added.length || updated.length) this.emit(RoomStickyEventsEvent.Update, added, updated, []); this.scheduleStickyTimer(); } /** * Schedule the sticky event expiry timer. The timer will * run immediately if an event has already expired. */ scheduleStickyTimer() { if (this.stickyEventTimer) { clearTimeout(this.stickyEventTimer); this.stickyEventTimer = undefined; } if (this.nextStickyEventExpiryTs === Number.MAX_SAFE_INTEGER) { // We have no events due to expire. return; } // otherwise, schedule in the future this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now()); } /** * Handles incoming event redactions. Checks the sticky map * for any active sticky events being redacted. * @param redactedEvent The MatrixEvent OR event ID of the event being redacted. MAY not be a sticky event. */ handleRedaction(redactedEvent) { // Note, we do not adjust`nextStickyEventExpiryTs` here. // If this event happens to be the most recent expiring event // then we may do one extra iteration of cleanExpiredStickyEvents // but this saves us having to iterate over all events here to calculate // the next expiry time. // Note, as soon as we find a positive match on an event in this function // we can return. There is no need to continue iterating on a positive match // as an event can only appear in one map. // Handle unkeyedStickyEvents first since it's *quick*. var redactEventId = typeof redactedEvent === "string" ? redactedEvent : redactedEvent.getId(); for (var event of this.unkeyedStickyEvents) { if (event.getId() === redactEventId) { this.unkeyedStickyEvents.delete(event); this.emit(RoomStickyEventsEvent.Update, [], [], [event]); return; } } // Faster method of finding the event since we have the event cached. if (typeof redactedEvent !== "string" && !redactedEvent.isRedacted()) { var _innerMap$get, _this$stickyEventsMap6; var stickyKey = redactedEvent.getContent().msc4354_sticky_key; if (typeof stickyKey !== "string" && stickyKey !== undefined) { return; // Not a sticky event. } var eventType = redactedEvent.getType(); var sender = redactedEvent.getSender(); assertIsUserId(sender); var innerMap = this.stickyEventsMap.get(eventType); if (!innerMap) { return; } var mapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender); var [currentEvent, ...previousEvents] = (_innerMap$get = innerMap.get(mapKey)) !== null && _innerMap$get !== void 0 ? _innerMap$get : []; if (!currentEvent) { // No event current in the map so ignore. return; } logger.debug("Redaction for ".concat(redactEventId, " under sticky key ").concat(stickyKey)); // Revert to previous state, taking care to skip any other redacted events. var newEvents = previousEvents.filter(e => !e.isRedacted()).sort(RoomStickyEventsStore.sortStickyEvent); (_this$stickyEventsMap6 = this.stickyEventsMap.get(eventType)) === null || _this$stickyEventsMap6 === void 0 || _this$stickyEventsMap6.set(mapKey, newEvents); if (newEvents.length) { this.emit(RoomStickyEventsEvent.Update, [], [{ // This looks confusing. This emits that the newer event // has been redacted and the previous event has taken it's place. previous: currentEvent, current: newEvents[0] }], []); } else { // We did not find a previous event, so just expire. innerMap.delete(mapKey); if (innerMap.size === 0) { this.stickyEventsMap.delete(eventType); } this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]); } return; } // We only know the event ID of the redacted event, so we need to // traverse the map to find our event. for (var _innerMap of this.stickyEventsMap.values()) { for (var [_currentEvent] of _innerMap.values()) { if (_currentEvent.getId() !== redactEventId) { continue; } // Found the event. return this.handleRedaction(_currentEvent); } } } /** * Clear all events and stop the timer from firing. */ clear() { this.stickyEventsMap.clear(); // Unschedule timer. this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; this.scheduleStickyTimer(); } } //# sourceMappingURL=room-sticky-events.js.map