192 lines
8.2 KiB
JavaScript
192 lines
8.2 KiB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
/*
|
|
Copyright 2026 Element Creations Ltd.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import { MatrixEvent } from "../models/event.js";
|
|
import { RoomEvent } from "./room.js";
|
|
import { RoomStateEvent } from "./room-state.js";
|
|
import { logger as rootLogger } from "../logger.js";
|
|
import { EventType } from "../@types/event.js";
|
|
import { ROOM_RETENTION_TYPE } from "../@types/retention.js";
|
|
|
|
/**
|
|
* Applies https://github.com/matrix-org/matrix-spec-proposals/pull/1763 by checking the current
|
|
* state of the room and applying the *latest* retention policy to all preceeding events.
|
|
* Currently does not:
|
|
* - Apply global server policies (requires Synapse work)
|
|
* - React at all to `min_retention` configs.
|
|
* - Loop
|
|
*/
|
|
export class RoomRetentionPolicy {
|
|
constructor(room, retentionService, store) {
|
|
// minRetention is not implemented.
|
|
_defineProperty(this, "maxRetention", null);
|
|
_defineProperty(this, "retentionTimeout", void 0);
|
|
_defineProperty(this, "logger", void 0);
|
|
_defineProperty(this, "handleRetentionUpdate", async () => {
|
|
// First store if we currently have a retention period.
|
|
const hadRetentionPeriod = this.maxRetention !== null;
|
|
try {
|
|
await this.recalculateRetention(this.room.currentState);
|
|
} catch (err) {
|
|
this.logger.warn("Failed to recalculate retention policy", err);
|
|
return;
|
|
}
|
|
|
|
// Bind/unbind the Timeline event handler based on whether we should be running retention.
|
|
if (hadRetentionPeriod !== (this.maxRetention !== null)) {
|
|
this.logger.info("retention recalculated to be", this.maxRetention);
|
|
// We've changed
|
|
if (this.maxRetention) {
|
|
this.room.on(RoomEvent.Timeline, this.timelineUpdated);
|
|
} else {
|
|
this.room.off(RoomEvent.Timeline, this.timelineUpdated);
|
|
}
|
|
}
|
|
});
|
|
_defineProperty(this, "recalculateRetention", async roomState => {
|
|
const unstableEvent = roomState.getStateEvents(ROOM_RETENTION_TYPE.name).find(e => e.getStateKey() === "");
|
|
const stableEvent = roomState.getStateEvents(ROOM_RETENTION_TYPE.altName).find(e => e.getStateKey() === "");
|
|
const serverPolicy = this.retentionService.getCached();
|
|
const serverRoomPolicy = serverPolicy?.policies?.[this.room.roomId];
|
|
const roomStatePolicy = unstableEvent?.getContent() ?? stableEvent?.getContent();
|
|
const content =
|
|
// * if the homeserver defines a specific retention policy for this room, then use this policy as the effective retention policy of the room.
|
|
serverRoomPolicy ??
|
|
// * otherwise, if the state of the room includes a `m.room.retention` event with an empty state key:
|
|
roomStatePolicy ??
|
|
// * otherwise, if the state of the room does not include a m.room.retention event with an empty state key:
|
|
serverPolicy?.policies?.["*"];
|
|
if (!content) {
|
|
// * otherwise, don't apply a retention policy in this room.
|
|
this.maxRetention = null;
|
|
return;
|
|
}
|
|
const validatePolicy = !serverRoomPolicy && roomStatePolicy;
|
|
|
|
// Parse it
|
|
let {
|
|
max_lifetime: maxLifetime
|
|
} = content;
|
|
if (!maxLifetime && validatePolicy) {
|
|
// if there is no value specified in the room's state, use the limit's min value for the effective retention policy of the room (which can be null or absent).
|
|
maxLifetime = serverPolicy?.limits?.max_lifetime?.min;
|
|
if (!maxLifetime) {
|
|
this.maxRetention = null;
|
|
return;
|
|
}
|
|
}
|
|
if (typeof maxLifetime !== "number") {
|
|
throw Error(`max_lifetime must be a number, got "${maxLifetime}"`);
|
|
}
|
|
if (maxLifetime < 0 || !Number.isInteger(maxLifetime)) {
|
|
throw Error(`max_lifetime must be >= 0, got ${maxLifetime}`);
|
|
}
|
|
this.maxRetention = maxLifetime;
|
|
if (validatePolicy && serverPolicy?.limits?.max_lifetime) {
|
|
// We're using room state so we need to validate this policy matches the server.
|
|
/**
|
|
if the value specified in the room's state complies with the limit, use this value for the effective retention policy of the room.
|
|
if the value specified in the room's state is lower than the limit's min value, use the min value for the effective retention policy of the room.
|
|
if the value specified in the room's state is greater than the limit's max value, use the max value for the effective retention policy of the room.
|
|
*/
|
|
this.maxRetention = Math.max(this.maxRetention, serverPolicy.limits.max_lifetime.min ?? 0);
|
|
this.maxRetention = Math.min(this.maxRetention, serverPolicy.limits.max_lifetime.max ?? Number.MAX_SAFE_INTEGER);
|
|
}
|
|
this.processTimeline();
|
|
});
|
|
_defineProperty(this, "timelineUpdated", () => {
|
|
this.logger.info("timelineUpdated");
|
|
this.scheduleTimelineCheck(200);
|
|
});
|
|
_defineProperty(this, "scheduleTimelineCheck", nextTs => {
|
|
if (this.retentionTimeout) {
|
|
clearTimeout(this.retentionTimeout);
|
|
}
|
|
this.retentionTimeout = setTimeout(() => {
|
|
this.processTimeline();
|
|
this.retentionTimeout = undefined;
|
|
}, nextTs);
|
|
});
|
|
this.room = room;
|
|
this.retentionService = retentionService;
|
|
this.store = store;
|
|
this.logger = rootLogger.getChild(`RetentionPolicy ${room.roomId}`);
|
|
|
|
// Recalculate on room state update.
|
|
room.on(RoomStateEvent.Events, event => {
|
|
if (event.getStateKey() !== "" || event.getType() !== "org.matrix.msc1763.retention" && event.getType() !== "m.room.retention") {
|
|
return;
|
|
}
|
|
void this.handleRetentionUpdate();
|
|
});
|
|
|
|
// Recalculate our retention whenever the global policy changes.
|
|
this.retentionService.on("update", () => {
|
|
this.logger.info("Got global retention policy update!");
|
|
void this.handleRetentionUpdate();
|
|
});
|
|
// Do an initial check on construction.
|
|
void this.handleRetentionUpdate();
|
|
|
|
// Only bind RoomEvent.Timeline once we know we have a retention policy.
|
|
}
|
|
shouldEventBeRetained(ev) {
|
|
if (!this.maxRetention) {
|
|
return true;
|
|
}
|
|
const expireBefore = Date.now() - this.maxRetention;
|
|
return ev.getTs() > expireBefore;
|
|
}
|
|
processTimeline() {
|
|
if (!this.maxRetention) {
|
|
return; // No policy, skip.
|
|
}
|
|
this.logger.info(`Running processTimeline`);
|
|
const expireBefore = Date.now() - this.maxRetention;
|
|
const events = this.room.getLiveTimeline().getEvents().filter(ev => ev.getStateKey() === undefined && !ev.isRedacted() && ev.getType() !== "m.room.redaction");
|
|
const expiredEvents = events.filter(ev => ev.getTs() < expireBefore);
|
|
const [earliestExpiringEvent] = events.filter(ev => ev.getTs() >= expireBefore).sort((a, b) => a.getTs() - b.getTs());
|
|
if (earliestExpiringEvent) {
|
|
const nextTs = earliestExpiringEvent.getTs() + this.maxRetention - Date.now();
|
|
this.logger.info(`Next expiry scheduled for ${nextTs}`);
|
|
this.scheduleTimelineCheck(nextTs);
|
|
}
|
|
if (expiredEvents.length === 0) {
|
|
this.logger.info("Found no expired events");
|
|
return;
|
|
}
|
|
this.logger.info(`Found ${expiredEvents.length} expired events`);
|
|
for (const event of expiredEvents) {
|
|
this.room.tryApplyRedaction(new MatrixEvent({
|
|
type: EventType.RoomRedaction,
|
|
sender: event.getSender(),
|
|
event_id: "$synthetic_redaction",
|
|
room_id: this.room.roomId,
|
|
redacts: event.getId(),
|
|
content: {
|
|
reason: "Retention policy"
|
|
},
|
|
origin_server_ts: Date.now(),
|
|
unsigned: {}
|
|
}));
|
|
}
|
|
void this.store.removeEventsFromRoom(this.room.roomId, expiredEvents.map(e => e.getId())).catch(ex => {
|
|
this.logger.warn(`Failed to remove events from store`, ex);
|
|
});
|
|
}
|
|
}
|
|
//# sourceMappingURL=room-retention.js.map
|