feat: Security
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Action, Store } from '@ngrx/store';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
Channel,
|
||||
ChatEvent,
|
||||
Message,
|
||||
MessageRevision,
|
||||
PluginCapabilityId,
|
||||
PluginEventEnvelope,
|
||||
TojuPluginManifest,
|
||||
@@ -49,6 +50,8 @@ import { PluginLoggerService } from './plugin-logger.service';
|
||||
import { PluginMessageBusService } from './plugin-message-bus.service';
|
||||
import { PluginStorageService } from './plugin-storage.service';
|
||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
|
||||
import { materializeMessageFromRevision } from '../../../chat/domain/rules/message-revision.builder.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginClientApiService {
|
||||
@@ -63,6 +66,7 @@ export class PluginClientApiService {
|
||||
private readonly storage = inject(PluginStorageService);
|
||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly messageRevisions = inject(MessageRevisionService);
|
||||
|
||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
@@ -380,7 +384,6 @@ export class PluginClientApiService {
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
actingRole: isOwner ? 'host' : undefined,
|
||||
currentOwnerId: currentUser.id,
|
||||
icon,
|
||||
iconUpdatedAt
|
||||
}, {
|
||||
@@ -601,7 +604,8 @@ export class PluginClientApiService {
|
||||
|
||||
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
|
||||
const roomId = this.requireRoomId();
|
||||
const message: Message = {
|
||||
const timestamp = Date.now();
|
||||
const draftMessage: Message = {
|
||||
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
|
||||
content: request.content,
|
||||
id: createId(),
|
||||
@@ -610,53 +614,74 @@ export class PluginClientApiService {
|
||||
roomId,
|
||||
senderId: request.pluginUserId,
|
||||
senderName: request.pluginUserId,
|
||||
timestamp: Date.now()
|
||||
timestamp,
|
||||
revision: 0
|
||||
};
|
||||
|
||||
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
|
||||
this.persistPluginMessage(pluginId, message);
|
||||
this.store.dispatch(MessagesActions.receiveMessage({ message }));
|
||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||
void this.emitPluginMessageRevision(pluginId, {
|
||||
draftMessage,
|
||||
type: 'create',
|
||||
actorId: request.pluginUserId,
|
||||
editedAt: timestamp,
|
||||
pluginId,
|
||||
sign: false,
|
||||
dispatch: (message) => MessagesActions.receiveMessage({ message })
|
||||
});
|
||||
}
|
||||
|
||||
private deletePluginMessage(pluginId: string, messageId: string): void {
|
||||
this.persistPluginMessageUpdate(pluginId, messageId, {
|
||||
content: '[Message deleted]',
|
||||
editedAt: Date.now(),
|
||||
isDeleted: true
|
||||
void this.emitPluginMessageMutation(pluginId, messageId, {
|
||||
type: 'plugin-delete',
|
||||
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||
message: existing,
|
||||
type: 'plugin-delete',
|
||||
actorId: this.currentUser()?.id ?? pluginId,
|
||||
editedAt,
|
||||
isDeleted: true,
|
||||
pluginId,
|
||||
sign: false
|
||||
}),
|
||||
legacyBroadcast: (editedAt) => ({
|
||||
deletedAt: editedAt,
|
||||
messageId,
|
||||
type: 'message-deleted'
|
||||
}),
|
||||
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
|
||||
});
|
||||
|
||||
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
this.voice.broadcastMessage({
|
||||
deletedAt: Date.now(),
|
||||
messageId,
|
||||
type: 'message-deleted'
|
||||
} as unknown as ChatEvent);
|
||||
}
|
||||
|
||||
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
||||
const editedAt = Date.now();
|
||||
|
||||
this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt });
|
||||
|
||||
this.store.dispatch(MessagesActions.editMessageSuccess({
|
||||
void this.emitPluginMessageMutation(pluginId, messageId, {
|
||||
type: 'plugin-edit',
|
||||
content,
|
||||
editedAt,
|
||||
messageId
|
||||
}));
|
||||
|
||||
this.voice.broadcastMessage({
|
||||
content,
|
||||
editedAt,
|
||||
messageId,
|
||||
type: 'message-edited'
|
||||
} as unknown as ChatEvent);
|
||||
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||
message: existing,
|
||||
type: 'plugin-edit',
|
||||
actorId: this.currentUser()?.id ?? pluginId,
|
||||
content,
|
||||
editedAt,
|
||||
pluginId,
|
||||
sign: false
|
||||
}),
|
||||
legacyBroadcast: (editedAt) => ({
|
||||
content,
|
||||
editedAt,
|
||||
messageId,
|
||||
type: 'message-edited'
|
||||
}),
|
||||
dispatch: (message) => MessagesActions.editMessageSuccess({
|
||||
content: message.content,
|
||||
editedAt: message.editedAt ?? Date.now(),
|
||||
messageId
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
||||
const currentUser = this.currentUser();
|
||||
const roomId = this.requireRoomId();
|
||||
const message: Message = {
|
||||
const timestamp = Date.now();
|
||||
const draftMessage: Message = {
|
||||
channelId: channelId ?? this.activeChannelId() ?? 'general',
|
||||
content,
|
||||
id: createId(),
|
||||
@@ -665,14 +690,89 @@ export class PluginClientApiService {
|
||||
roomId,
|
||||
senderId: currentUser?.id ?? 'plugin',
|
||||
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
|
||||
timestamp: Date.now()
|
||||
timestamp,
|
||||
revision: 0
|
||||
};
|
||||
|
||||
this.persistPluginMessage(pluginId, message);
|
||||
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
|
||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||
void this.emitPluginMessageRevision(pluginId, {
|
||||
draftMessage,
|
||||
type: 'create',
|
||||
actorId: currentUser?.id ?? pluginId,
|
||||
editedAt: timestamp,
|
||||
pluginId,
|
||||
sign: !!currentUser,
|
||||
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
|
||||
});
|
||||
|
||||
return message;
|
||||
return draftMessage;
|
||||
}
|
||||
|
||||
private async emitPluginMessageRevision(
|
||||
pluginId: string,
|
||||
input: {
|
||||
draftMessage: Message;
|
||||
type: 'create';
|
||||
actorId: string;
|
||||
editedAt: number;
|
||||
pluginId: string;
|
||||
sign: boolean;
|
||||
dispatch: (message: Message) => Action;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const revision = await this.messageRevisions.createSignedRevision({
|
||||
message: input.draftMessage,
|
||||
type: input.type,
|
||||
actorId: input.actorId,
|
||||
editedAt: input.editedAt,
|
||||
pluginId: input.pluginId,
|
||||
sign: input.sign
|
||||
});
|
||||
const message = materializeMessageFromRevision(null, revision);
|
||||
|
||||
this.logger.info(pluginId, 'Plugin message emitted', { messageId: message.id });
|
||||
await this.db.saveMessage(message);
|
||||
await this.messageRevisions.persistRevision(revision);
|
||||
this.store.dispatch(input.dispatch(message));
|
||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(pluginId, 'Failed to emit plugin message revision', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async emitPluginMessageMutation(
|
||||
pluginId: string,
|
||||
messageId: string,
|
||||
input: {
|
||||
type: 'plugin-edit' | 'plugin-delete';
|
||||
content?: string;
|
||||
apply: (existing: Message, editedAt: number) => Promise<MessageRevision>;
|
||||
legacyBroadcast: (editedAt: number) => ChatEvent;
|
||||
dispatch: (message: Message) => Action;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = await this.db.getMessageById(messageId);
|
||||
|
||||
if (!existing) {
|
||||
this.logger.warn(pluginId, 'Plugin message mutation target not found', { messageId });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const editedAt = Date.now();
|
||||
const revision = await input.apply(existing, editedAt);
|
||||
const message = materializeMessageFromRevision(existing, revision);
|
||||
|
||||
await this.db.saveMessage(message);
|
||||
await this.messageRevisions.persistRevision(revision);
|
||||
this.store.dispatch(input.dispatch(message));
|
||||
this.voice.broadcastMessage(input.legacyBroadcast(editedAt) as unknown as ChatEvent);
|
||||
this.messageRevisions.broadcastRevision(revision);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(pluginId, 'Failed to emit plugin message mutation revision', error);
|
||||
}
|
||||
}
|
||||
|
||||
private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void {
|
||||
@@ -831,8 +931,7 @@ export class PluginClientApiService {
|
||||
|
||||
this.serverDirectory.updateServer(room.id, {
|
||||
actingRole: isOwner ? 'host' : undefined,
|
||||
channels,
|
||||
currentOwnerId: currentUser.id
|
||||
channels
|
||||
}, {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
|
||||
Reference in New Issue
Block a user