diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index b8393484..121ad27a 100644 --- a/packages/federation-sdk/src/index.ts +++ b/packages/federation-sdk/src/index.ts @@ -20,6 +20,7 @@ import { Room } from './repositories/room.repository'; import { Server } from './repositories/server.repository'; import { StateGraphStore } from './repositories/state-graph.repository'; import { Upload } from './repositories/upload.repository'; +import { User } from './repositories/user.repository'; import { FederationSDK } from './sdk'; import { DatabaseConnectionService } from './services/database-connection.service'; import { EventEmitterService } from './services/event-emitter.service'; @@ -223,7 +224,18 @@ export type HomeserverEventSignatures = { displayname?: string; avatar_url?: string; reason?: string; + is_direct?: boolean; }; + stripped_state?: PduForType< + | 'm.room.create' + | 'm.room.name' + | 'm.room.avatar' + | 'm.room.topic' + | 'm.room.join_rules' + | 'm.room.canonical_alias' + | 'm.room.encryption' + | 'm.room.member' + >[]; }; 'homeserver.matrix.room.name': { event_id: EventID; @@ -312,6 +324,10 @@ export async function init({ ), }); + container.register>('UserCollection', { + useValue: db.collection('users'), + }); + const eventEmitterService = container.resolve(EventEmitterService); if (emitter) { eventEmitterService.setEmitter(emitter); diff --git a/packages/federation-sdk/src/repositories/event-staging.repository.ts b/packages/federation-sdk/src/repositories/event-staging.repository.ts index f814bcdd..2e338381 100644 --- a/packages/federation-sdk/src/repositories/event-staging.repository.ts +++ b/packages/federation-sdk/src/repositories/event-staging.repository.ts @@ -29,7 +29,6 @@ export class EventStagingRepository { return this.collection.updateOne( { _id: eventId, - origin, }, { $setOnInsert: { @@ -38,6 +37,7 @@ export class EventStagingRepository { got: 0, }, $set: { + origin, event, from, }, diff --git a/packages/federation-sdk/src/repositories/event.repository.ts b/packages/federation-sdk/src/repositories/event.repository.ts index 03b6a090..f4378236 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -405,6 +405,29 @@ export class EventRepository { ); } + forceInsertOrUpdateEventWithStateId( + eventId: EventID, + event: Pdu, + stateId: StateID, + partial = false, + ): Promise { + return this.collection.updateOne( + { _id: eventId }, + { + $setOnInsert: { + nextEventId: '', + createdAt: new Date(), + }, + $set: { + event, + stateId, + partial, + }, + }, + { upsert: true }, + ); + } + async updateNextEventReferences( newEventId: EventID, previousEventIds: EventID[], diff --git a/packages/federation-sdk/src/repositories/user.repository.ts b/packages/federation-sdk/src/repositories/user.repository.ts new file mode 100644 index 00000000..edd69093 --- /dev/null +++ b/packages/federation-sdk/src/repositories/user.repository.ts @@ -0,0 +1,48 @@ +import type { Collection } from 'mongodb'; +import { inject, singleton } from 'tsyringe'; + +export type User = { + _id: string; + username?: string; + name?: string; + avatarUrl?: string; + avatarETag?: string; + federated?: boolean; + federation?: { + version?: number; + mui?: string; + origin?: string; + avatarUrl?: string; + }; + createdAt: Date; + _updatedAt: Date; +}; + +@singleton() +export class UserRepository { + constructor( + @inject('UserCollection') private readonly collection: Collection, + ) {} + + async findByUsername(username: string): Promise { + return this.collection.findOne( + { + username, + $or: [{ federated: { $exists: false } }, { federated: false }], + }, + { + projection: { + _id: 1, + username: 1, + name: 1, + avatarUrl: 1, + avatarETag: 1, + federation: 1, + federated: 1, + createdAt: 1, + _updatedAt: 1, + }, + }, + ); + } +} diff --git a/packages/federation-sdk/src/sdk.ts b/packages/federation-sdk/src/sdk.ts index 085f0940..53abf290 100644 --- a/packages/federation-sdk/src/sdk.ts +++ b/packages/federation-sdk/src/sdk.ts @@ -5,6 +5,7 @@ import { singleton } from 'tsyringe'; import { AppConfig, ConfigService } from './services/config.service'; import { EduService } from './services/edu.service'; import { EventAuthorizationService } from './services/event-authorization.service'; +import { EventEmitterService } from './services/event-emitter.service'; import { EventService } from './services/event.service'; import { FederationRequestService } from './services/federation-request.service'; import { FederationService } from './services/federation.service'; @@ -37,6 +38,7 @@ export class FederationSDK { private readonly wellKnownService: WellKnownService, private readonly federationRequestService: FederationRequestService, private readonly federationService: FederationService, + private readonly eventEmitterService: EventEmitterService, ) {} createDirectMessageRoom( @@ -99,6 +101,12 @@ export class FederationSDK { return this.messageService.updateMessage(...args); } + updateMemberProfile( + ...args: Parameters + ) { + return this.roomService.updateMemberProfile(...args); + } + updateRoomName(...args: Parameters) { return this.roomService.updateRoomName(...args); } @@ -145,6 +153,20 @@ export class FederationSDK { return this.roomService.joinUser(...args); } + acceptInvite(...args: Parameters) { + return this.roomService.acceptInvite(...args); + } + + rejectInvite(...args: Parameters) { + return this.roomService.rejectInvite(...args); + } + + updateUserProfile( + ...args: Parameters + ) { + return this.roomService.updateUserProfile(...args); + } + getLatestRoomState2( ...args: Parameters ) { @@ -293,4 +315,8 @@ export class FederationSDK { ) { return this.eduService.sendPresenceUpdateToRooms(...args); } + + emit(...args: Parameters) { + return this.eventEmitterService.emit(...args); + } } diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 4045bbc9..8ac4320f 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -379,6 +379,12 @@ export class EventAuthorizationService { } if (entityType === 'media') { + // avatars are publicly accessible and don't require access control + if (entityId.startsWith('avatar')) { + // TODO: add avatar access control once we have a way to check if the user is allowed to access the avatar + return true; + } + return this.canAccessMedia(entityId, serverName); } diff --git a/packages/federation-sdk/src/services/federation.service.ts b/packages/federation-sdk/src/services/federation.service.ts index 911f350a..fc7e7f86 100644 --- a/packages/federation-sdk/src/services/federation.service.ts +++ b/packages/federation-sdk/src/services/federation.service.ts @@ -98,6 +98,50 @@ export class FederationService { } } + async makeLeave( + domain: string, + roomId: string, + userId: string, + ): Promise<{ event: Pdu; room_version: string }> { + try { + const uri = FederationEndpoints.makeLeave(roomId, userId); + return await this.requestService.get<{ + event: Pdu; + room_version: string; + }>(domain, uri); + } catch (error: any) { + this.logger.error({ msg: 'makeLeave failed', err: error }); + throw error; + } + } + + async sendLeave(leaveEvent: PersistentEventBase): Promise { + try { + const uri = FederationEndpoints.sendLeave( + leaveEvent.roomId, + leaveEvent.eventId, + ); + + const residentServer = leaveEvent.roomId.split(':').pop(); + + if (!residentServer) { + this.logger.debug({ msg: 'invalid room_id', event: leaveEvent.event }); + throw new Error( + `invalid room_id ${leaveEvent.roomId}, no server_name part`, + ); + } + + await this.requestService.put( + residentServer, + uri, + leaveEvent.event, + ); + } catch (error: any) { + this.logger.error({ msg: 'sendLeave failed', err: error }); + throw error; + } + } + /** * Send a transaction to a remote server */ diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index db8b32eb..8f72c8a6 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -6,14 +6,18 @@ import { PersistentEventFactory, RoomID, RoomVersion, + StateID, UserID, extractDomainFromId, } from '@rocket.chat/federation-room'; -import { singleton } from 'tsyringe'; +import { delay, inject, singleton } from 'tsyringe'; +import { EventRepository } from '../repositories/event.repository'; +import { UserRepository } from '../repositories/user.repository'; import { ConfigService } from './config.service'; import { EventAuthorizationService } from './event-authorization.service'; +import { EventEmitterService } from './event-emitter.service'; import { FederationService } from './federation.service'; -import { StateService, UnknownRoomError } from './state.service'; +import { StateService } from './state.service'; // TODO: Have better (detailed/specific) event input type export type ProcessInviteEvent = { event: EventBase; @@ -37,8 +41,38 @@ export class InviteService { private readonly stateService: StateService, private readonly configService: ConfigService, private readonly eventAuthorizationService: EventAuthorizationService, + @inject(delay(() => UserRepository)) + private readonly userRepository: UserRepository, + private readonly eventEmitterService: EventEmitterService, + @inject(delay(() => EventRepository)) + private readonly eventRepository: EventRepository, ) {} + /** + * Get avatar URL for a user (local users only) + */ + private async getAvatarUrlForUser( + userId: UserID, + ): Promise { + const userDomain = extractDomainFromId(userId); + const localDomain = this.configService.serverName; + + if (userDomain !== localDomain) { + return undefined; + } + + const username = userId.split(':')[0]?.slice(1); + if (!username) { + return undefined; + } + + // Fetch user to get avatarETag + const user = await this.userRepository.findByUsername(username); + const avatarIdentifier = user?.avatarETag || username; + + return `mxc://${localDomain}/avatar${avatarIdentifier}`; + } + /** * Invite a user to an existing room */ @@ -64,6 +98,8 @@ export class InviteService { ? userId.split(':').shift()?.slice(1) : undefined; + const avatarUrl = await this.getAvatarUrlForUser(userId); + const inviteEvent = await stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', @@ -73,6 +109,7 @@ export class InviteService { is_direct: true, displayname: displayname, }), + ...(avatarUrl && { avatar_url: avatarUrl }), }, room_id: roomId, state_key: userId, @@ -152,6 +189,7 @@ export class InviteService { | 'm.room.join_rules' | 'm.room.canonical_alias' | 'm.room.encryption' + | 'm.room.member' >[], ): Promise { const isRoomNonPrivate = strippedStateEvents.some( @@ -191,6 +229,7 @@ export class InviteService { | 'm.room.join_rules' | 'm.room.canonical_alias' | 'm.room.encryption' + | 'm.room.member' >[], ) { // SPEC: when a user invites another user on a different homeserver, a request to that homeserver to have the event signed and verified must be made @@ -221,13 +260,30 @@ export class InviteService { ); // attempt to persist the invite event as we already have the state - await this.stateService.handlePdu(inviteEvent); // we do not send transaction here // the asking server will handle the transactions + } else { + await this.eventRepository.forceInsertOrUpdateEventWithStateId( + inviteEvent.eventId, + inviteEvent.event, + '' as StateID, + true, // partial = true + ); } + this.eventEmitterService.emit('homeserver.matrix.membership', { + event_id: inviteEvent.eventId, + event: inviteEvent.event, + room_id: roomId, + sender: inviteEvent.sender, + state_key: inviteEvent.stateKey ?? '', + origin_server_ts: inviteEvent.originServerTs, + content: inviteEvent.getContent(), + stripped_state: strippedStateEvents, + }); + // we are not the host of the server // so being the origin of the user, we sign the event and send it to the asking server, let them handle the transactions return inviteEvent; diff --git a/packages/federation-sdk/src/services/profiles.service.ts b/packages/federation-sdk/src/services/profiles.service.ts index 6b505340..f953a04d 100644 --- a/packages/federation-sdk/src/services/profiles.service.ts +++ b/packages/federation-sdk/src/services/profiles.service.ts @@ -10,7 +10,8 @@ import { RoomVersion, UserID, } from '@rocket.chat/federation-room'; -import { singleton } from 'tsyringe'; +import { delay, inject, singleton } from 'tsyringe'; +import { UserRepository } from '../repositories/user.repository'; import { StateService } from './state.service'; @singleton() @@ -21,14 +22,37 @@ export class ProfilesService { private readonly configService: ConfigService, private readonly eventService: EventService, private readonly stateService: StateService, + @inject(delay(() => UserRepository)) + private readonly userRepository: UserRepository, ) {} async queryProfile(userId: string): Promise<{ avatar_url: string; - displayname: string; - }> { + displayname?: string; + } | null> { + const [username, serverName] = userId.startsWith('@') + ? userId.split(':', 2) + : [userId, this.configService.serverName]; + + if (serverName !== this.configService.serverName) { + return null; + } + + const usernameWithoutAt = username.replace('@', ''); + const user = await this.userRepository.findByUsername(usernameWithoutAt); + + if (!user) { + this.logger.debug(`Local user ${userId} not found in repository`); + return null; + } + + // construct MXC URL based on avatarETag (or fallback to username for backwards compatibility) + // RC stores avatars in GridFS accessed via /avatar/{username} + // for Matrix, we use the pattern: mxc://{server}/avatar{avatarETag} + // Using avatarETag ensures remote servers re-fetch when avatar changes + const avatarIdentifier = user.avatarETag || usernameWithoutAt; return { - avatar_url: 'mxc://matrix.org/MyC00lAvatar', - displayname: userId, + avatar_url: `mxc://${this.configService.serverName}/avatar${avatarIdentifier}`, + displayname: user.name || user.username, }; } @@ -90,10 +114,16 @@ export class ProfilesService { throw new Error(`User ${userId} is not invited`); } + const profile = await this.queryProfile(userId); + const membershipEvent = await stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', - content: { membership: 'join' }, + content: { + membership: 'join', + ...(profile?.displayname && { displayname: profile.displayname }), + ...(profile?.avatar_url && { avatar_url: profile.avatar_url }), + }, room_id: roomId, state_key: userId, auth_events: [], diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index 5caf4363..553370be 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -27,12 +27,14 @@ import { EventStore as RoomEventStore, RoomID, RoomVersion, + StateID, UserID, extractDomainFromId, } from '@rocket.chat/federation-room'; import { EventStagingRepository } from '../repositories/event-staging.repository'; import { EventRepository } from '../repositories/event.repository'; import { RoomRepository } from '../repositories/room.repository'; +import { UserRepository } from '../repositories/user.repository'; import { ConfigService } from './config.service'; import { EventEmitterService } from './event-emitter.service'; import { EventFetcherService } from './event-fetcher.service'; @@ -57,8 +59,35 @@ export class RoomService { private readonly eventRepository: EventRepository, @inject(delay(() => EventStagingRepository)) private readonly eventStagingRepository: EventStagingRepository, + @inject(delay(() => UserRepository)) + private readonly userRepository: UserRepository, ) {} + /** + * Get avatar URL for a user (local users only) + */ + private async getAvatarUrlForUser( + userId: UserID, + ): Promise { + const userDomain = extractDomainFromId(userId); + const localDomain = this.configService.serverName; + + if (userDomain !== localDomain) { + return undefined; + } + + const username = userId.split(':')[0]?.slice(1); + if (!username) { + return undefined; + } + + // Fetch user to get avatarETag + const user = await this.userRepository.findByUsername(username); + const avatarIdentifier = user?.avatarETag || username; + + return `mxc://${localDomain}/avatar${avatarIdentifier}`; + } + private validatePowerLevelChange( currentPowerLevelsContent: PduForType<'m.room.power_levels'>['content'], senderId: string, @@ -249,11 +278,18 @@ export class RoomService { await stateService.handlePdu(roomCreateEvent); + const avatarUrl = await this.getAvatarUrlForUser(username); + const displayname = username.split(':')[0]?.slice(1); + const creatorMembershipEvent = await stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', - content: { membership: 'join' }, + content: { + membership: 'join', + ...(displayname && { displayname }), + ...(avatarUrl && { avatar_url: avatarUrl }), + }, room_id: roomCreateEvent.roomId, state_key: username, auth_events: [], @@ -862,10 +898,17 @@ export class RoomService { ); } + const avatarUrl = await this.getAvatarUrlForUser(userId); + const displayname = userId.split(':')[0]?.slice(1); + const membershipEvent = await stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', - content: { membership: 'join' }, + content: { + membership: 'join', + ...(displayname && { displayname }), + ...(avatarUrl && { avatar_url: avatarUrl }), + }, room_id: roomId, state_key: userId, auth_events: [], @@ -884,7 +927,11 @@ export class RoomService { event: membershipEvent.event, room_id: roomId, state_key: userId, - content: { membership: 'join' }, + content: { + membership: 'join', + ...(displayname && { displayname }), + ...(avatarUrl && { avatar_url: avatarUrl }), + }, sender: userId, origin_server_ts: Date.now(), }); @@ -898,6 +945,7 @@ export class RoomService { return membershipEvent.eventId; } + // Resident server is remote, need to do join flow const roomVersion = '10' as const; // trying to join room from another server @@ -1042,7 +1090,6 @@ export class RoomService { event: joinEventFinal.event, }); - // try to persist the join event now, should succeed with state in place void this.eventService.processIncomingPDUs( residentServer || joinEventFinal.origin, [...state, joinEventFinal.event], @@ -1051,6 +1098,122 @@ export class RoomService { return joinEventFinal.eventId; } + async acceptInvite(inviteEventId: EventID, userId: UserID) { + const inviteEventStore = + await this.eventService.getEventById(inviteEventId); + if (!inviteEventStore) { + throw new Error(`Invite event not found: ${inviteEventId}`); + } + + const roomVersion = PersistentEventFactory.defaultRoomVersion; + const inviteEvent = PersistentEventFactory.createFromRawEvent( + inviteEventStore.event, + roomVersion, + ); + + return this.joinUser(inviteEvent, userId); + } + + async rejectInvite(inviteEventId: EventID, userId: UserID): Promise { + const inviteEventStore = + await this.eventService.getEventById(inviteEventId); + if (!inviteEventStore) { + throw new Error(`Invite event not found: ${inviteEventId}`); + } + + const roomId = inviteEventStore.event.room_id; + const invitingServer = extractDomainFromId(inviteEventStore.event.sender); + + if (!invitingServer) { + throw new Error( + `Invalid sender in invite event: ${inviteEventStore.event.sender}`, + ); + } + + const { event: leaveTemplate, room_version } = + await this.federationService.makeLeave(invitingServer, roomId, userId); + + const leaveEvent = + PersistentEventFactory.createFromRawEvent<'m.room.member'>( + leaveTemplate, + room_version, + ); + + await this.stateService.signEvent(leaveEvent); + + await this.federationService.sendLeave(leaveEvent); + + await this.eventRepository.forceInsertOrUpdateEventWithStateId( + leaveEvent.eventId, + leaveEvent.event, + '' as StateID, + true, + ); + + this.eventEmitterService.emit('homeserver.matrix.membership', { + event_id: leaveEvent.eventId, + event: leaveEvent.event, + room_id: roomId, + state_key: userId, + content: { membership: 'leave' }, + sender: userId, + origin_server_ts: leaveEvent.originServerTs, + }); + } + + /** + * Update user profile (displayname/avatar) in a room by sending a membership event + */ + async updateUserProfile( + roomId: RoomID, + userId: UserID, + profile: { displayname?: string; avatar_url?: string }, + ): Promise { + const stateService = this.stateService; + const federationService = this.federationService; + + const roomInfo = await stateService.getRoomInformation(roomId); + + const membershipEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + content: { + membership: 'join', + ...profile, + }, + room_id: roomId, + state_key: userId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: userId, + }, + roomInfo.room_version, + ); + + await stateService.handlePdu(membershipEvent); + + this.eventEmitterService.emit('homeserver.matrix.membership', { + event_id: membershipEvent.eventId, + event: membershipEvent.event, + room_id: roomId, + state_key: userId, + content: { + membership: 'join', + ...profile, + }, + sender: userId, + origin_server_ts: Date.now(), + }); + + if (membershipEvent.rejected) { + throw new Error(membershipEvent.rejectReason); + } + + void federationService.sendEventToAllServersInRoom(membershipEvent); + } + private async _fetchFullBranch( eventIds: EventID[], residentServer: string, @@ -1405,6 +1568,7 @@ export class RoomService { // Extract displayname from userId for direct messages const creatorDisplayname = creatorUserId.split(':').shift()?.slice(1); + const creatorAvatarUrl = await this.getAvatarUrlForUser(creatorUserId); const creatorMembershipEvent = await stateService.buildEvent<'m.room.member'>( @@ -1414,6 +1578,7 @@ export class RoomService { membership: 'join', is_direct: true, displayname: creatorDisplayname, + ...(creatorAvatarUrl && { avatar_url: creatorAvatarUrl }), }, room_id: roomCreateEvent.roomId, state_key: creatorUserId, @@ -1522,6 +1687,7 @@ export class RoomService { } else { // Extract displayname from userId for direct messages const displayname = targetUserId.split(':').shift()?.slice(1); + const targetAvatarUrl = await this.getAvatarUrlForUser(targetUserId); const targetMembershipEvent = await stateService.buildEvent<'m.room.member'>( @@ -1531,6 +1697,7 @@ export class RoomService { membership: 'join', is_direct: true, displayname: displayname, + ...(targetAvatarUrl && { avatar_url: targetAvatarUrl }), }, room_id: roomCreateEvent.roomId, state_key: targetUserId, diff --git a/packages/federation-sdk/src/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index a744402b..c3bd6aad 100644 --- a/packages/federation-sdk/src/services/state.service.ts +++ b/packages/federation-sdk/src/services/state.service.ts @@ -1131,7 +1131,7 @@ export class StateService { async getAllPublicRoomIdsAndNames() { const createEvents = await this.eventRepository.findByType('m.room.create'); - const result = [] as { name: string; room_id: RoomID }[]; + const result = [] as { name?: string; room_id: RoomID }[]; for await (const create of createEvents) { const roomId = create.event.room_id; @@ -1141,7 +1141,7 @@ export class StateService { continue; } - result.push({ name: state.name, room_id: roomId }); + result.push({ name: state.name ?? '', room_id: roomId }); } return result; diff --git a/packages/homeserver/src/controllers/federation/profiles.controller.ts b/packages/homeserver/src/controllers/federation/profiles.controller.ts index 2e94c3a2..ef22904e 100644 --- a/packages/homeserver/src/controllers/federation/profiles.controller.ts +++ b/packages/homeserver/src/controllers/federation/profiles.controller.ts @@ -7,13 +7,13 @@ import { ErrorResponseDto, EventAuthParamsDto, EventAuthResponseDto, + FederationErrorResponseDto, GetDevicesParamsDto, GetDevicesResponseDto, GetMissingEventsBodyDto, GetMissingEventsParamsDto, GetMissingEventsResponseDto, MakeJoinParamsDto, - MakeJoinQueryDto, MakeJoinResponseDto, QueryKeysBodyDto, QueryKeysResponseDto, @@ -28,12 +28,27 @@ export const profilesPlugin = (app: Elysia) => { .use(isAuthenticatedMiddleware()) .get( '/federation/v1/query/profile', - ({ query: { user_id } }) => - federationSDK.queryProfile(user_id as UserID), + async ({ query: { user_id }, set }) => { + const response = await federationSDK.queryProfile(user_id); + + if (!response) { + set.status = 404; + return { + errcode: 'M_NOT_FOUND', + error: `User ${user_id} not found`, + }; + } + + return { + displayname: response.displayname, + avatar_url: response.avatar_url, + }; + }, { query: QueryProfileQueryDto, response: { 200: QueryProfileResponseDto, + 404: FederationErrorResponseDto, }, detail: { tags: ['Federation'], diff --git a/packages/homeserver/src/controllers/federation/rooms.controller.ts b/packages/homeserver/src/controllers/federation/rooms.controller.ts index dcf5b70f..3cf74edc 100644 --- a/packages/homeserver/src/controllers/federation/rooms.controller.ts +++ b/packages/homeserver/src/controllers/federation/rooms.controller.ts @@ -13,6 +13,7 @@ export const roomPlugin = (app: Elysia) => { guest_can_join: false, // trying to reduce requried endpoint hits world_readable: false, // ^^^ avatar_url: '', // ?? don't have any yet + name: '', }; const { limit: _limit } = query; @@ -23,6 +24,7 @@ export const roomPlugin = (app: Elysia) => { chunk: publicRooms.map((room) => ({ ...defaultObj, ...room, + name: room.name ?? '', })), }; }, @@ -57,6 +59,7 @@ export const roomPlugin = (app: Elysia) => { guest_can_join: false, // trying to reduce requried endpoint hits world_readable: false, // ^^^ avatar_url: '', // ?? don't have any yet + name: '', }; const { filter } = body; @@ -68,7 +71,7 @@ export const roomPlugin = (app: Elysia) => { .filter((r) => { if (filter.generic_search_term) { return r.name - .toLowerCase() + ?.toLowerCase() .includes(filter.generic_search_term.toLowerCase()); } @@ -81,6 +84,7 @@ export const roomPlugin = (app: Elysia) => { .map((room) => ({ ...defaultObj, ...room, + name: room.name ?? '', })), }; }, diff --git a/packages/homeserver/src/dtos/federation/error.dto.ts b/packages/homeserver/src/dtos/federation/error.dto.ts index 2b9674a1..17d81b64 100644 --- a/packages/homeserver/src/dtos/federation/error.dto.ts +++ b/packages/homeserver/src/dtos/federation/error.dto.ts @@ -6,6 +6,7 @@ export const FederationErrorResponseDto = t.Object({ M_UNAUTHORIZED: 'M_UNAUTHORIZED', M_FORBIDDEN: 'M_FORBIDDEN', M_UNKNOWN: 'M_UNKNOWN', + M_NOT_FOUND: 'M_NOT_FOUND', }), error: t.String(), }); diff --git a/packages/homeserver/src/dtos/federation/profiles.dto.ts b/packages/homeserver/src/dtos/federation/profiles.dto.ts index 935aeafd..82a6753f 100644 --- a/packages/homeserver/src/dtos/federation/profiles.dto.ts +++ b/packages/homeserver/src/dtos/federation/profiles.dto.ts @@ -8,11 +8,14 @@ import { export const QueryProfileQueryDto = t.Object({ user_id: UsernameDto, + field: t.Optional( + t.Union([t.Literal('displayname'), t.Literal('avatar_url')]), + ), }); export const QueryProfileResponseDto = t.Object({ - displayname: t.Optional(t.Union([t.String(), t.Null()])), - avatar_url: t.Optional(t.Union([t.String(), t.Null()])), + displayname: t.Optional(t.String({ description: 'User display name' })), + avatar_url: t.String({ description: 'User avatar URL (MXC URL)' }), }); export const QueryKeysBodyDto = t.Object({ diff --git a/packages/room/src/manager/room-state.ts b/packages/room/src/manager/room-state.ts index e7107ef8..eb0a4d2c 100644 --- a/packages/room/src/manager/room-state.ts +++ b/packages/room/src/manager/room-state.ts @@ -66,8 +66,10 @@ export class RoomState { const nameEvent = getStateByMapKey(this.stateMap, { type: 'm.room.name', }); + + // DMs do not have a name event, so we return undefined if (!nameEvent || !nameEvent.isNameEvent()) { - throw new Error('Room name event not found'); + return undefined; } return nameEvent.getContent().name;