feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -55,6 +55,8 @@ import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
/** Cap on simultaneous browser-cache prefetches for apps with many saved rooms. */
@@ -73,6 +75,7 @@ export class MessagesEffects {
private readonly linkMetadata = inject(LinkMetadataService);
private readonly platform = inject(PlatformService);
private readonly i18n = inject(AppI18nService);
private readonly messageRevisions = inject(MessageRevisionService);
/** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() =>
@@ -235,7 +238,7 @@ export class MessagesEffects {
return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') }));
}
const message: Message = {
const draftMessage: Message = {
id: uuidv4(),
roomId: currentRoom.id,
channelId: channelId || 'general',
@@ -245,27 +248,44 @@ export class MessagesEffects {
timestamp: this.timeSync.now(),
reactions: [],
isDeleted: false,
replyToId
replyToId,
revision: 0
};
this.attachments.rememberMessageRoom(message.id, message.roomId);
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: draftMessage,
type: 'create',
actorId: currentUser.id,
editedAt: draftMessage.timestamp
});
const message = materializeMessageFromRevision(null, revision);
this.trackBackgroundOperation(
this.db.saveMessage(message),
'Failed to persist outgoing chat message',
{
channelId: message.channelId,
contentLength: message.content.length,
messageId: message.id,
roomId: message.roomId
}
);
this.attachments.rememberMessageRoom(message.id, message.roomId);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'chat-message',
message });
this.trackBackgroundOperation(
this.db.saveMessage(message),
'Failed to persist outgoing chat message',
{
channelId: message.channelId,
contentLength: message.content.length,
messageId: message.id,
roomId: message.roomId
}
);
return of(MessagesActions.sendMessageSuccess({ message }));
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist outgoing message revision',
{ messageId: message.id, revision: revision.revision }
);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'chat-message', message });
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.sendMessageSuccess({ message });
})());
}),
catchError((error) =>
of(MessagesActions.sendMessageFailure({ error: error.message }))
@@ -295,26 +315,38 @@ export class MessagesEffects {
const editedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, { content,
editedAt }),
'Failed to persist edited chat message',
{
contentLength: content.length,
editedAt,
messageId
}
);
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: existing,
type: 'author-edit',
actorId: currentUser.id,
content,
editedAt
});
const updatedMessage = materializeMessageFromRevision(existing, revision);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'message-edited',
messageId,
content,
editedAt });
this.trackBackgroundOperation(
this.db.saveMessage(updatedMessage),
'Failed to persist edited chat message',
{
contentLength: content.length,
editedAt,
messageId
}
);
return of(MessagesActions.editMessageSuccess({ messageId,
content,
editedAt }));
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist edited message revision',
{ messageId, revision: revision.revision }
);
this.customEmoji.pushEmojisInContent(content);
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.editMessageSuccess({ messageId, content, editedAt });
})());
}),
catchError((error) =>
of(MessagesActions.editMessageFailure({ error: error.message }))
@@ -346,30 +378,39 @@ export class MessagesEffects {
const deletedAt = this.timeSync.now();
this.trackBackgroundOperation(
this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: existing,
type: 'author-delete',
actorId: currentUser.id,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist message deletion',
{
deletedAt,
messageId
}
);
});
const deletedMessage = materializeMessageFromRevision(existing, revision);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete message attachments',
{ messageId }
);
this.trackBackgroundOperation(
this.db.saveMessage(deletedMessage),
'Failed to persist message deletion',
{ deletedAt, messageId }
);
this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedAt });
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist deleted message revision',
{ messageId, revision: revision.revision }
);
return of(MessagesActions.deleteMessageSuccess({ messageId }));
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete message attachments',
{ messageId }
);
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedAt });
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.deleteMessageSuccess({ messageId });
})());
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
@@ -399,37 +440,54 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageFailure({ error: this.i18n.instant('chat.effects.permissionDenied') }));
}
const deletedAt = this.timeSync.now();
return from(this.db.getMessageById(messageId)).pipe(
mergeMap((existing) => {
if (!existing) {
return of(MessagesActions.deleteMessageFailure({ error: this.i18n.instant('chat.effects.messageNotFound') }));
}
this.trackBackgroundOperation(
this.db.updateMessage(messageId, {
content: DELETED_MESSAGE_CONTENT,
editedAt: deletedAt,
isDeleted: true
}),
'Failed to persist admin message deletion',
{
deletedBy: currentUser.id,
deletedAt,
messageId
}
const deletedAt = this.timeSync.now();
return from((async () => {
const revision = await this.messageRevisions.createSignedRevision({
message: existing,
type: 'moderate-delete',
actorId: currentUser.id,
editedAt: deletedAt,
isDeleted: true
});
const deletedMessage = materializeMessageFromRevision(existing, revision);
this.trackBackgroundOperation(
this.db.saveMessage(deletedMessage),
'Failed to persist admin message deletion',
{ deletedBy: currentUser.id, deletedAt, messageId }
);
this.trackBackgroundOperation(
this.messageRevisions.persistRevision(revision),
'Failed to persist moderated delete revision',
{ messageId, revision: revision.revision }
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete admin-deleted message attachments',
{ deletedBy: currentUser.id, messageId }
);
this.webrtc.broadcastMessage({
type: 'message-deleted',
messageId,
deletedBy: currentUser.id,
deletedAt
});
this.messageRevisions.broadcastRevision(revision);
return MessagesActions.deleteMessageSuccess({ messageId });
})());
})
);
this.trackBackgroundOperation(
this.attachments.deleteForMessage(messageId),
'Failed to delete admin-deleted message attachments',
{
deletedBy: currentUser.id,
messageId
}
);
this.webrtc.broadcastMessage({ type: 'message-deleted',
messageId,
deletedBy: currentUser.id,
deletedAt });
return of(MessagesActions.deleteMessageSuccess({ messageId }));
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
@@ -606,12 +664,13 @@ export class MessagesEffects {
webrtc: this.webrtc,
attachments: this.attachments,
debugging: this.debugging,
messageRevisions: this.messageRevisions,
currentUser: currentUser ?? null,
currentRoom,
savedRooms
};
return dispatchIncomingMessage(event, ctx).pipe(
return dispatchIncomingMessage(event as Parameters<typeof dispatchIncomingMessage>[0], ctx).pipe(
catchError((error) => {
const eventRecord = event as unknown as Record<string, unknown>;
const messageRecord = (eventRecord['message'] && typeof eventRecord['message'] === 'object' && !Array.isArray(eventRecord['message']))
@@ -658,6 +717,7 @@ export class MessagesEffects {
webrtc: this.webrtc,
attachments: this.attachments,
debugging: this.debugging,
messageRevisions: this.messageRevisions,
currentUser: currentUser ?? null,
currentRoom,
savedRooms