feat: Security
This commit is contained in:
@@ -28,6 +28,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
},
|
||||
attachments: {},
|
||||
debugging: {},
|
||||
messageRevisions: {
|
||||
verifyRevision: vi.fn(async () => true)
|
||||
},
|
||||
currentUser: null,
|
||||
currentRoom: null,
|
||||
savedRooms: [],
|
||||
|
||||
@@ -20,7 +20,9 @@ import { Action } from '@ngrx/store';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
type ChatEvent,
|
||||
type ChatInventoryItem,
|
||||
type Message,
|
||||
type MessageRevision,
|
||||
type Room,
|
||||
type User
|
||||
} from '../../shared-kernel';
|
||||
@@ -40,8 +42,10 @@ import {
|
||||
buildLocalInventoryMap,
|
||||
findMissingIds,
|
||||
hydrateMessage,
|
||||
mergeIncomingMessage
|
||||
mergeIncomingMessage,
|
||||
mergeIncomingRevision
|
||||
} from './messages.helpers';
|
||||
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
|
||||
|
||||
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
|
||||
type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
|
||||
@@ -61,7 +65,7 @@ type IncomingMessageType =
|
||||
|
||||
interface IncomingMessageEvent extends Omit<ChatEvent, 'type'> {
|
||||
type: IncomingMessageType;
|
||||
items?: InventoryItem[];
|
||||
items?: ChatInventoryItem[];
|
||||
ids?: string[];
|
||||
messages?: Message[];
|
||||
attachments?: AttachmentMetaMap;
|
||||
@@ -94,6 +98,7 @@ export interface IncomingMessageContext {
|
||||
webrtc: RealtimeSessionFacade;
|
||||
attachments: AttachmentFacade;
|
||||
debugging: DebuggingService;
|
||||
messageRevisions: MessageRevisionService;
|
||||
currentUser: User | null;
|
||||
currentRoom: Room | null;
|
||||
savedRooms?: Room[];
|
||||
@@ -383,6 +388,54 @@ function handleChatMessage(
|
||||
return of(MessagesActions.receiveMessage({ message: msg }));
|
||||
}
|
||||
|
||||
function handleMessageRevision(
|
||||
event: IncomingMessageEvent,
|
||||
ctx: IncomingMessageContext
|
||||
): Observable<Action> {
|
||||
const revision = (event as { revision?: MessageRevision }).revision;
|
||||
|
||||
if (!revision) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
const isValid = await ctx.messageRevisions.verifyRevision(revision);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { message, changed } = await mergeIncomingRevision(revision, ctx.db);
|
||||
|
||||
if (!changed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message.isDeleted) {
|
||||
await ctx.attachments.deleteForMessage(message.id);
|
||||
return MessagesActions.deleteMessageSuccess({ messageId: message.id });
|
||||
}
|
||||
|
||||
if (revision.type === 'create') {
|
||||
return MessagesActions.receiveMessage({ message });
|
||||
}
|
||||
|
||||
if (revision.type.endsWith('edit')) {
|
||||
return MessagesActions.editMessageSuccess({
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
editedAt: message.editedAt ?? revision.editedAt
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
})()
|
||||
).pipe(
|
||||
mergeMap((action) => action ? of(action) : EMPTY)
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies a remote message edit to the local DB and store. */
|
||||
function handleMessageEdited(
|
||||
event: IncomingMessageEvent,
|
||||
@@ -664,6 +717,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
|
||||
'chat-message': handleChatMessage,
|
||||
'message-edited': handleMessageEdited,
|
||||
'message-deleted': handleMessageDeleted,
|
||||
'message-revision': handleMessageRevision,
|
||||
|
||||
// Reactions
|
||||
'reaction-added': handleReactionAdded,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
* Message store helpers - delegates pure domain logic to `domains/chat/domain/`
|
||||
* and provides DB-dependent hydration/merge operations at the application level.
|
||||
*/
|
||||
import { Message } from '../../shared-kernel';
|
||||
import {
|
||||
Message,
|
||||
type MessageRevision
|
||||
} from '../../shared-kernel';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { getMessageTimestamp, normaliseDeletedMessage } from '../../domains/chat/domain/rules/message.rules';
|
||||
import type { InventoryItem } from '../../domains/chat/domain/rules/message-sync.rules';
|
||||
import {
|
||||
computeMessageHeadHashFromMessage,
|
||||
getMessageRevision,
|
||||
shouldApplyIncomingRevision
|
||||
} from '../../domains/chat/domain/rules/message-integrity.rules';
|
||||
import {
|
||||
materializeMessageFromRevision,
|
||||
revisionBeatsMessage
|
||||
} from '../../domains/chat/domain/rules/message-revision.builder.rules';
|
||||
|
||||
// Re-export domain logic so existing callers keep working
|
||||
export {
|
||||
@@ -58,26 +70,28 @@ export async function buildInventoryItem(
|
||||
_db: DatabaseService,
|
||||
attachmentCountOverride?: number
|
||||
): Promise<InventoryItem> {
|
||||
const revision = getMessageRevision(msg);
|
||||
const headHash = msg.headHash ?? await computeMessageHeadHashFromMessage(msg, revision);
|
||||
|
||||
if (msg.isDeleted) {
|
||||
return {
|
||||
id: msg.id,
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: 0,
|
||||
ac: 0
|
||||
ac: 0,
|
||||
revision,
|
||||
headHash
|
||||
};
|
||||
}
|
||||
|
||||
const item: InventoryItem = {
|
||||
return {
|
||||
id: msg.id,
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: msg.reactions?.length ?? 0
|
||||
rc: msg.reactions?.length ?? 0,
|
||||
ac: attachmentCountOverride ?? 0,
|
||||
revision,
|
||||
headHash
|
||||
};
|
||||
|
||||
if (attachmentCountOverride !== undefined) {
|
||||
item.ac = attachmentCountOverride;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID.
|
||||
@@ -90,25 +104,17 @@ export async function buildLocalInventoryMap(
|
||||
messages: Message[],
|
||||
_db: DatabaseService,
|
||||
attachmentCountOverrides?: ReadonlyMap<string, number>
|
||||
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
|
||||
const map = new Map<string, { ts: number; rc: number; ac: number }>();
|
||||
): Promise<Map<string, InventoryItem>> {
|
||||
const map = new Map<string, InventoryItem>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.isDeleted) {
|
||||
map.set(msg.id, {
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: 0,
|
||||
ac: 0
|
||||
});
|
||||
const item = await buildInventoryItem(
|
||||
msg,
|
||||
_db,
|
||||
attachmentCountOverrides?.get(msg.id)
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
map.set(msg.id, {
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: msg.reactions?.length ?? 0,
|
||||
ac: attachmentCountOverrides?.get(msg.id) ?? 0
|
||||
});
|
||||
map.set(msg.id, item);
|
||||
}
|
||||
|
||||
return map;
|
||||
@@ -125,11 +131,22 @@ export interface MergeResult {
|
||||
* Handles message upsert and reaction deduplication, then returns
|
||||
* the fully hydrated message alongside a `changed` flag.
|
||||
*/
|
||||
export async function mergeIncomingMessage(
|
||||
incoming: Message,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(incoming.id);
|
||||
function shouldApplyIncomingMessage(incoming: Message, existing: Message | null): boolean {
|
||||
const incomingRevision = getMessageRevision(incoming);
|
||||
const existingRevision = getMessageRevision(existing ?? undefined);
|
||||
|
||||
if (incoming.headHash) {
|
||||
const existingHeadHash = existing?.headHash
|
||||
?? '';
|
||||
|
||||
return shouldApplyIncomingRevision(
|
||||
incomingRevision,
|
||||
existingRevision,
|
||||
incoming.headHash,
|
||||
existingHeadHash
|
||||
);
|
||||
}
|
||||
|
||||
const existingTs = existing ? getMessageTimestamp(existing) : -1;
|
||||
const incomingTs = getMessageTimestamp(incoming);
|
||||
const isDeletedStateNewer =
|
||||
@@ -137,10 +154,70 @@ export async function mergeIncomingMessage(
|
||||
incomingTs === existingTs &&
|
||||
incoming.isDeleted &&
|
||||
!existing.isDeleted;
|
||||
const isNewer = !existing || incomingTs > existingTs || isDeletedStateNewer;
|
||||
|
||||
return !existing || incomingTs > existingTs || isDeletedStateNewer;
|
||||
}
|
||||
|
||||
export async function mergeIncomingRevision(
|
||||
revision: MessageRevision,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(revision.messageId);
|
||||
|
||||
if (!revisionBeatsMessage(revision, existing)) {
|
||||
if (!existing) {
|
||||
return {
|
||||
message: materializeMessageFromRevision(null, revision),
|
||||
changed: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: normaliseDeletedMessage(existing),
|
||||
changed: false
|
||||
};
|
||||
}
|
||||
|
||||
const message = materializeMessageFromRevision(existing, revision);
|
||||
|
||||
await db.saveMessage(message);
|
||||
await db.saveMessageRevision(revision);
|
||||
|
||||
if (message.isDeleted) {
|
||||
return {
|
||||
message: normaliseDeletedMessage(message),
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
const reactions = await db.getReactionsForMessage(message.id);
|
||||
|
||||
return {
|
||||
message: {
|
||||
...message,
|
||||
reactions
|
||||
},
|
||||
changed: true
|
||||
};
|
||||
}
|
||||
|
||||
export async function mergeIncomingMessage(
|
||||
incoming: Message,
|
||||
db: DatabaseService
|
||||
): Promise<MergeResult> {
|
||||
const existing = await db.getMessageById(incoming.id);
|
||||
const isNewer = shouldApplyIncomingMessage(incoming, existing);
|
||||
|
||||
if (isNewer) {
|
||||
await db.saveMessage(incoming);
|
||||
const persisted = incoming.headHash
|
||||
? incoming
|
||||
: {
|
||||
...incoming,
|
||||
revision: getMessageRevision(incoming),
|
||||
headHash: await computeMessageHeadHashFromMessage(incoming, getMessageRevision(incoming))
|
||||
};
|
||||
|
||||
await db.saveMessage(persisted);
|
||||
}
|
||||
|
||||
// Persist incoming reactions (deduped by the DB layer)
|
||||
|
||||
@@ -119,7 +119,6 @@ export class RoomSettingsEffects {
|
||||
|
||||
if (canManageRoom) {
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: currentUserRole ?? undefined,
|
||||
name: updatedSettings.name,
|
||||
description: updatedSettings.description,
|
||||
@@ -175,7 +174,6 @@ export class RoomSettingsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(currentRoom.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: role ?? undefined,
|
||||
channels
|
||||
}, {
|
||||
@@ -286,7 +284,6 @@ export class RoomSettingsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
roles: nextRoom.roles,
|
||||
roleAssignments: nextRoom.roleAssignments,
|
||||
channelPermissions: nextRoom.channelPermissions,
|
||||
@@ -355,7 +352,6 @@ export class RoomSettingsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: isOwner ? 'host' : undefined,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
getPersistedCurrentUserId
|
||||
} from './rooms.helpers';
|
||||
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
|
||||
import { SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
|
||||
|
||||
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
|
||||
1_500,
|
||||
@@ -319,6 +320,10 @@ export class RoomStateSyncEffects {
|
||||
);
|
||||
}
|
||||
|
||||
case 'auth_required':
|
||||
case 'auth_error':
|
||||
return of(UsersActions.loadCurrentUserFailure({ error: SESSION_EXPIRED_ERROR_CODE }));
|
||||
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -753,7 +753,6 @@ export class RoomsEffects {
|
||||
});
|
||||
|
||||
this.serverDirectory.updateServer(roomId, {
|
||||
currentOwnerId: currentUser.id,
|
||||
actingRole: 'host',
|
||||
ownerId: nextHostId,
|
||||
ownerPublicKey: nextHostOderId
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
catchError,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
switchMap
|
||||
switchMap,
|
||||
filter
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MessagesActions } from '../messages/messages.actions';
|
||||
@@ -47,9 +49,11 @@ import {
|
||||
Room,
|
||||
User
|
||||
} from '../../shared-kernel';
|
||||
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { clearStoredCurrentUserId, setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
||||
import { AppI18nService } from '../../core/i18n';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
|
||||
|
||||
type IncomingModerationExtraAction =
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||
@@ -68,6 +72,8 @@ export class UsersEffects {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
/** Prepares persisted state for a successful login before exposing the user in-memory. */
|
||||
authenticateUser$ = createEffect(() =>
|
||||
@@ -106,6 +112,14 @@ export class UsersEffects {
|
||||
|
||||
const sanitizedUser = this.clearStartupVoiceConnection(user);
|
||||
|
||||
if (!this.hasPersistedSessionToken(sanitizedUser)) {
|
||||
clearStoredCurrentUserId();
|
||||
|
||||
return of(UsersActions.loadCurrentUserFailure({
|
||||
error: SESSION_EXPIRED_ERROR_CODE
|
||||
}));
|
||||
}
|
||||
|
||||
if (sanitizedUser === user) {
|
||||
return of(UsersActions.loadCurrentUserSuccess({ user }));
|
||||
}
|
||||
@@ -205,8 +219,6 @@ export class UsersEffects {
|
||||
return this.serverDirectory.kickServerMember(
|
||||
room.id,
|
||||
{
|
||||
actorUserId: currentUser.id,
|
||||
actorRole: currentUser.role,
|
||||
targetUserId: userId
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
@@ -287,8 +299,6 @@ export class UsersEffects {
|
||||
return this.serverDirectory.banServerMember(
|
||||
room.id,
|
||||
{
|
||||
actorUserId: currentUser.id,
|
||||
actorRole: currentUser.role,
|
||||
targetUserId: userId,
|
||||
banId: ban.oderId,
|
||||
displayName: ban.displayName,
|
||||
@@ -358,8 +368,6 @@ export class UsersEffects {
|
||||
return this.serverDirectory.unbanServerMember(
|
||||
room.id,
|
||||
{
|
||||
actorUserId: currentUser.id,
|
||||
actorRole: currentUser.role,
|
||||
banId: oderId
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
@@ -477,6 +485,24 @@ export class UsersEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Send users back to login when their persisted session token is missing or rejected. */
|
||||
redirectOnSessionExpired$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadCurrentUserFailure),
|
||||
filter(({ error }) => error === SESSION_EXPIRED_ERROR_CODE),
|
||||
tap(() => {
|
||||
clearStoredCurrentUserId();
|
||||
void this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
});
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Keep signaling identity aligned with the current profile to avoid stale fallback names. */
|
||||
syncSignalingIdentity$ = createEffect(
|
||||
() =>
|
||||
@@ -511,6 +537,15 @@ export class UsersEffects {
|
||||
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||
}
|
||||
|
||||
private hasPersistedSessionToken(user: User): boolean {
|
||||
return hasValidPersistedSession(
|
||||
user,
|
||||
this.serverDirectory.activeServer()?.url,
|
||||
(serverUrl) => this.authTokenStore.getToken(serverUrl),
|
||||
() => this.authTokenStore.hasAnyValidToken()
|
||||
);
|
||||
}
|
||||
|
||||
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
|
||||
const displayName = user.displayName?.trim();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user