diff --git a/packages/core/src/utils/authentication.ts b/packages/core/src/utils/authentication.ts index 5ca2c7c8a..cc8e13c5e 100644 --- a/packages/core/src/utils/authentication.ts +++ b/packages/core/src/utils/authentication.ts @@ -85,18 +85,24 @@ export const validateAuthorizationHeader = async ( destination, ...(content && { content }), }); + const signature = Uint8Array.from(atob(hash as string), (c) => c.charCodeAt(0), ); + const signingKeyBytes = Uint8Array.from(atob(signingKey as string), (c) => + c.charCodeAt(0), + ); + const messageBytes = new TextEncoder().encode(canonicalJson); + const isValid = nacl.sign.detached.verify( + messageBytes, + signature, + signingKeyBytes, + ); - if ( - !nacl.sign.detached.verify( - new TextEncoder().encode(canonicalJson), - signature, - Uint8Array.from(atob(signingKey as string), (c) => c.charCodeAt(0)), - ) - ) { - throw new Error(`Invalid signature for ${destination}`); + if (!isValid) { + throw new Error( + `Invalid signature from ${origin} for request to ${destination}`, + ); } return true; diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index 46f0c4a24..b5553fd60 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -3,8 +3,90 @@ import https from 'node:https'; type RequestOptions = Parameters[1]; +export type MultipartResult = { + content: Buffer; + headers?: Record; + redirect?: string; +}; + +/** + * parses Matrix federation multipart/mixed media responses according to spec. + * https://spec.matrix.org/v1.15/server-server-api/#get_matrixfederationv1mediadownloadmediaid + */ +function parseMultipart(buffer: Buffer, boundary: string): MultipartResult { + const bufferStr = buffer.toString(); + + // check if the second part contains a Location header (CDN redirect) + // pattern: after first boundary and JSON part, look for Location header + const parts = bufferStr.split(`--${boundary}`); + if (parts.length >= 3) { + const secondPart = parts[2]; + const locationMatch = secondPart.match(/\r?\nLocation:\s*(.+)\r?\n/i); + + if (locationMatch) { + return { + content: Buffer.from(''), + redirect: locationMatch[1].trim(), + }; + } + } + + // find where the last part's content starts (after the last \r\n\r\n) + const lastHeaderEnd = buffer.lastIndexOf('\r\n\r\n'); + if (lastHeaderEnd === -1) return { content: buffer }; + + const binaryStart = lastHeaderEnd + 4; + const closingBoundary = buffer.lastIndexOf(`\r\n--${boundary}`); + + const content = + closingBoundary > binaryStart + ? buffer.subarray(binaryStart, closingBoundary) + : buffer.subarray(binaryStart); + + return { content }; +} + +function handleJson(contentType: string, body: Buffer): Promise { + if (!contentType.includes('application/json')) { + return Promise.resolve(null); + } + + try { + return Promise.resolve(JSON.parse(body.toString())); + } catch { + return Promise.resolve(null); + } +} + +function handleText(contentType: string, body: Buffer): Promise { + if (!contentType.includes('text/')) { + return Promise.resolve(''); + } + + return Promise.resolve(body.toString()); +} + +function handleMultipart( + contentType: string, + body: Buffer, +): Promise { + if (!contentType.includes('multipart')) { + return Promise.resolve(null); + } + + // extract boundary from content-type header + const boundaryMatch = contentType.match(/boundary=([^;,\s]+)/); + if (!boundaryMatch) { + return Promise.resolve(null); + } + + // remove quotes if present + const boundary = boundaryMatch[1].replace(/^["']|["']$/g, ''); + return Promise.resolve(parseMultipart(body, boundary)); +} + // this fetch is used when connecting to a multihome server, same server hosting multiple homeservers, and we need to verify the cert with the right SNI (hostname), or else, cert check will fail due to connecting through ip and not hostname (due to matrix spec). -export async function fetch(url: URL, options: RequestInit) { +export async function fetch(url: URL, options: RequestInit) { const serverName = new URL( `http://${(options.headers as OutgoingHttpHeaders).Host}` as string, ).hostname; @@ -14,49 +96,62 @@ export async function fetch(url: URL, options: RequestInit) { port: url.port, method: options.method, path: url.pathname + url.search, - headers: { - ...(options.headers as OutgoingHttpHeaders), - 'content-type': 'application/json', - }, + headers: options.headers as OutgoingHttpHeaders, servername: serverName, }; try { - const response: { statusCode: number | undefined; body: string } = - await new Promise((resolve, reject) => { - const request = https.request(requestParams, (res) => { - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - res.on('end', () => { - resolve({ - statusCode: res.statusCode, - body: data, - }); - }); + const response: { + statusCode: number | undefined; + body: Buffer; + headers: OutgoingHttpHeaders; + } = await new Promise((resolve, reject) => { + const request = https.request(requestParams, (res) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => { + chunks.push(chunk); }); - request.on('error', (err) => { - reject(err); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: Buffer.concat(chunks), + headers: res.headers, + }); }); + }); - request.end(options.body); + request.on('error', (err) => { + reject(err); }); + request.end(options.body); + }); + + const contentType = response.headers['content-type'] || ''; + return { ok: response.statusCode ? response.statusCode >= 200 && response.statusCode < 300 : false, - json: () => JSON.parse(response.body), - text: () => response.body, + buffer: () => Promise.resolve(response.body), + json: () => handleJson(contentType, response.body), + text: () => handleText(contentType, response.body), + multipart: () => handleMultipart(contentType, response.body), status: response.statusCode, + headers: response.headers, }; } catch (err) { return { ok: false, - json: () => undefined, - text: () => (err instanceof Error ? err.message : String(err)), status: undefined, + headers: {}, + buffer: () => Promise.resolve(Buffer.from('')), + json: () => Promise.resolve(null), + text: () => + Promise.resolve(err instanceof Error ? err.message : String(err)), + multipart: () => Promise.resolve(null), }; } } diff --git a/packages/federation-sdk/src/container.ts b/packages/federation-sdk/src/container.ts index 33d0947eb..91e29c962 100644 --- a/packages/federation-sdk/src/container.ts +++ b/packages/federation-sdk/src/container.ts @@ -12,9 +12,14 @@ import { MissingEventsQueue } from './queues/missing-event.queue'; import { StagingAreaQueue } from './queues/staging-area.queue'; import { EventRepository } from './repositories/event.repository'; import { Key, KeyRepository } from './repositories/key.repository'; +import { + MatrixBridgedRoom, + MatrixBridgedRoomRepository, +} from './repositories/matrix-bridged-room.repository'; import { Room, RoomRepository } from './repositories/room.repository'; import { Server, ServerRepository } from './repositories/server.repository'; import { StateRepository, StateStore } from './repositories/state.repository'; +import { Upload, UploadRepository } from './repositories/upload.repository'; import { ConfigService } from './services/config.service'; import { DatabaseConnectionService } from './services/database-connection.service'; import { EduService } from './services/edu.service'; @@ -81,11 +86,26 @@ export async function createFederationContainer( useValue: db.collection('servers'), }); + container.register>('UploadCollection', { + useValue: db.collection('rocketchat_uploads'), + }); + + container.register>( + 'MatrixBridgedRoomCollection', + { + useValue: db.collection( + 'rocketchat_matrix_bridged_rooms', + ), + }, + ); + container.registerSingleton(EventRepository); container.registerSingleton(KeyRepository); container.registerSingleton(RoomRepository); container.registerSingleton(StateRepository); container.registerSingleton(ServerRepository); + container.registerSingleton(MatrixBridgedRoomRepository); + container.registerSingleton(UploadRepository); container.registerSingleton(FederationRequestService); container.registerSingleton(SignatureVerificationService); diff --git a/packages/federation-sdk/src/repositories/matrix-bridged-room.repository.ts b/packages/federation-sdk/src/repositories/matrix-bridged-room.repository.ts new file mode 100644 index 000000000..5d5a07b32 --- /dev/null +++ b/packages/federation-sdk/src/repositories/matrix-bridged-room.repository.ts @@ -0,0 +1,24 @@ +import { Collection } from 'mongodb'; +import { inject, singleton } from 'tsyringe'; + +export type MatrixBridgedRoom = { + rid: string; // Rocket.Chat room ID + mri: string; // Matrix room ID + fromServer: string; +}; + +@singleton() +export class MatrixBridgedRoomRepository { + constructor( + @inject('MatrixBridgedRoomCollection') + private readonly collection: Collection, + ) {} + + async findMatrixRoomId(rocketChatRoomId: string): Promise { + const bridgedRoom = await this.collection.findOne({ + rid: rocketChatRoomId, + }); + + return bridgedRoom?.mri || null; + } +} diff --git a/packages/federation-sdk/src/repositories/upload.repository.ts b/packages/federation-sdk/src/repositories/upload.repository.ts new file mode 100644 index 000000000..f9b8e2c5d --- /dev/null +++ b/packages/federation-sdk/src/repositories/upload.repository.ts @@ -0,0 +1,26 @@ +import { Collection } from 'mongodb'; +import { inject, singleton } from 'tsyringe'; + +export type Upload = { + rid: string; + federation: { + mxcUri: string; + serverName: string; + mediaId: string; + }; +}; + +@singleton() +export class UploadRepository { + constructor( + @inject('UploadCollection') private readonly collection: Collection, + ) {} + + async findRocketChatRoomIdByMediaId(mediaId: string): Promise { + const upload = await this.collection.findOne({ + 'federation.mediaId': mediaId, + }); + + return upload?.rid || null; + } +} diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 79f636446..c2d5d2bc9 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -6,6 +6,8 @@ import { } from '@hs/core'; import type { Pdu } from '@hs/room'; import { singleton } from 'tsyringe'; +import { MatrixBridgedRoomRepository } from '../repositories/matrix-bridged-room.repository'; +import { UploadRepository } from '../repositories/upload.repository'; import { ConfigService } from './config.service'; import { EventService } from './event.service'; import { SignatureVerificationService } from './signature-verification.service'; @@ -20,6 +22,8 @@ export class EventAuthorizationService { private readonly eventService: EventService, private readonly signatureVerificationService: SignatureVerificationService, private readonly configService: ConfigService, + private readonly uploadRepository: UploadRepository, + private readonly matrixBridgedRoomRepository: MatrixBridgedRoomRepository, ) {} async authorizeEvent(event: Pdu, authEvents: Pdu[]): Promise { @@ -125,20 +129,15 @@ export class EventAuthorizationService { extractSignaturesFromHeader(authorizationHeader); if (!origin || !key || !signature) { - this.logger.warn('Missing required fields in X-Matrix header'); return; } if (destination && destination !== this.configService.serverName) { - this.logger.warn( - `Request destination ${destination} does not match server name ${this.configService.serverName}`, - ); return; } const [algorithm] = key.split(':'); if (algorithm !== 'ed25519') { - this.logger.warn(`Unsupported key algorithm: ${algorithm}`); return; } @@ -152,10 +151,11 @@ export class EventAuthorizationService { return; } + const actualDestination = destination || this.configService.serverName; const isValid = await validateAuthorizationHeader( origin, publicKey.verify_keys[key].key, - destination || this.configService.serverName, + actualDestination, method, uri, signature, @@ -168,10 +168,7 @@ export class EventAuthorizationService { return origin; } catch (error) { - this.logger.error( - { error, method, uri, authorizationHeader, body }, - 'Error verifying request signature', - ); + this.logger.error(error, 'Error verifying request signature'); return; } } @@ -292,15 +289,15 @@ export class EventAuthorizationService { roomId: string, serverName: string, ): Promise { - const [serverAcl] = await this.stateService.getStateEventsByType( + const [aclEvent] = await this.stateService.getStateEventsByType( roomId, 'm.room.server_acl', ); - if (!serverAcl) { + if (!aclEvent) { return true; } - const serverAclContent = serverAcl.getContent() as { + const serverAclContent = aclEvent.getContent() as { allow?: string[]; deny?: string[]; allow_ip_literals?: boolean; @@ -368,4 +365,112 @@ export class EventAuthorizationService { return false; } } + + async canAccessMedia(mediaId: string, serverName: string): Promise { + try { + const rcRoomId = + await this.uploadRepository.findRocketChatRoomIdByMediaId(mediaId); + if (!rcRoomId) { + this.logger.debug(`Media ${mediaId} not found in any room`); + return false; + } + + const matrixRoomId = + await this.matrixBridgedRoomRepository.findMatrixRoomId(rcRoomId); + if (!matrixRoomId) { + this.logger.debug(`Media ${mediaId} not found in any room`); + return false; + } + + const isServerAllowed = await this.checkServerAcl( + matrixRoomId, + serverName, + ); + if (!isServerAllowed) { + this.logger.warn( + `Server ${serverName} is denied by room ACL for media in room ${matrixRoomId}`, + ); + return false; + } + + const serversInRoom = + await this.stateService.getServersInRoom(matrixRoomId); + if (serversInRoom.includes(serverName)) { + this.logger.debug( + `Server ${serverName} is in room ${matrixRoomId}, allowing media access`, + ); + return true; + } + + const roomState = await this.stateService.getFullRoomState(matrixRoomId); + const historyVisibility = this.getHistoryVisibility(roomState); + if (historyVisibility === 'world_readable') { + this.logger.debug( + `Room ${matrixRoomId} is world_readable, allowing media access to ${serverName}`, + ); + return true; + } + + this.logger.debug( + `Server ${serverName} not authorized for media ${mediaId}: not in room and room not world_readable`, + ); + return false; + } catch (error) { + this.logger.error( + { error, mediaId, serverName }, + 'Error checking media access', + ); + return false; + } + } + + async canAccessMediaFromAuthorizationHeader( + mediaId: string, + authorizationHeader: string, + method: string, + uri: string, + body?: Record, + ): Promise< + | { authorized: true } + | { + authorized: false; + errorCode: 'M_UNAUTHORIZED' | 'M_FORBIDDEN' | 'M_UNKNOWN'; + } + > { + try { + const signatureResult = await this.verifyRequestSignature( + method, + uri, + authorizationHeader, + body, + ); + if (!signatureResult) { + return { + authorized: false, + errorCode: 'M_UNAUTHORIZED', + }; + } + + const authorized = await this.canAccessMedia(mediaId, signatureResult); + if (!authorized) { + return { + authorized: false, + errorCode: 'M_FORBIDDEN', + }; + } + + return { + authorized: true, + }; + } catch (error) { + this.logger.error( + { error, mediaId, authorizationHeader, method, uri, body }, + 'Error checking media access', + ); + return { + authorized: false, + errorCode: 'M_UNKNOWN', + }; + } + } } diff --git a/packages/federation-sdk/src/services/federation-request.service.ts b/packages/federation-sdk/src/services/federation-request.service.ts index c9e2e9b97..5a8f564fb 100644 --- a/packages/federation-sdk/src/services/federation-request.service.ts +++ b/packages/federation-sdk/src/services/federation-request.service.ts @@ -1,10 +1,13 @@ import type { SigningKey } from '@hs/core'; -import { authorizationHeaders, computeAndMergeHash } from '@hs/core'; -import { extractURIfromURL } from '@hs/core'; -import { EncryptionValidAlgorithm } from '@hs/core'; -import { signJson } from '@hs/core'; -import { createLogger } from '@hs/core'; -import { fetch } from '@hs/core'; +import { + EncryptionValidAlgorithm, + authorizationHeaders, + computeAndMergeHash, + createLogger, + extractURIfromURL, + fetch, + signJson, +} from '@hs/core'; import { singleton } from 'tsyringe'; import * as nacl from 'tweetnacl'; import { getHomeserverFinalAddress } from '../server-discovery/discovery'; @@ -26,13 +29,28 @@ export class FederationRequestService { constructor(private readonly configService: ConfigService) {} + // the redirect URL should be fetched without Matrix auth + // and will only occur for media downloads as per Matrix spec + private async handleRedirect(redirect: string): Promise { + const redirectResponse = await fetch(new URL(redirect), { + method: 'GET', + headers: {}, + }); + + if (!redirectResponse.ok) { + throw new Error(`Failed to fetch media from redirect: ${redirect}`); + } + + return await redirectResponse.buffer(); + } + async makeSignedRequest({ method, domain, uri, body, queryString, - }: SignedRequest): Promise { + }: SignedRequest): Promise { try { const serverName = this.configService.serverName; const signingKeyBase64 = await this.configService.getSigningKeyBase64(); @@ -82,19 +100,10 @@ export class FederationRequestService { const headers = { Authorization: auth, ...discoveryHeaders, + ...(signedBody && { 'Content-Type': 'application/json' }), }; - this.logger.debug( - { - method, - body: signedBody, - headers, - url: url.toString(), - }, - 'making http request', - ); - - const response = await fetch(url, { + const response = await fetch(url, { method, ...(signedBody && { body: JSON.stringify(signedBody) }), headers, @@ -104,7 +113,7 @@ export class FederationRequestService { const errorText = await response.text(); let errorDetail = errorText; try { - errorDetail = JSON.stringify(JSON.parse(errorText)); + errorDetail = JSON.stringify(JSON.parse(errorText || '')); } catch { /* use raw text if parsing fails */ } @@ -113,13 +122,20 @@ export class FederationRequestService { ); } + const multipart = await response.multipart(); + if (multipart?.redirect) { + return this.handleRedirect(multipart.redirect); + } + if (multipart !== null) { + return multipart.content; + } + return response.json(); - } catch (error: any) { + } catch (err) { this.logger.error( - `Federation request failed: ${error.message}`, - error.stack, + `Federation request failed: ${err instanceof Error ? err.message : String(err)}`, ); - throw error; + throw err; } } @@ -129,7 +145,7 @@ export class FederationRequestService { endpoint: string, body?: Record, queryParams?: Record, - ): Promise { + ) { let queryString = ''; if (queryParams) { @@ -146,7 +162,7 @@ export class FederationRequestService { uri: endpoint, body, queryString, - }); + }) as Promise; } async get( @@ -181,60 +197,19 @@ export class FederationRequestService { return this.request('POST', targetServer, endpoint, body, queryParams); } - async prepareSignedRequest( + async requestBinaryData( + method: string, targetServer: string, endpoint: string, - method: string, - body?: Record, - ): Promise<{ url: URL; headers: Record }> { - const serverName = this.configService.serverName; - const signingKeyBase64 = await this.configService.getSigningKeyBase64(); - const signingKeyId = await this.configService.getSigningKeyId(); - const privateKeyBytes = Buffer.from(signingKeyBase64, 'base64'); - const keyPair = nacl.sign.keyPair.fromSecretKey(privateKeyBytes); - - const signingKey: SigningKey = { - algorithm: EncryptionValidAlgorithm.ed25519, - version: signingKeyId.split(':')[1] || '1', - privateKey: keyPair.secretKey, - publicKey: keyPair.publicKey, - sign: async (data: Uint8Array) => - nacl.sign.detached(data, keyPair.secretKey), - }; - - const [address, discoveryHeaders] = await getHomeserverFinalAddress( - targetServer, - this.logger, - ); - - const url = new URL(`${address}${endpoint}`); - - let signedBody: Record | undefined; - if (body) { - signedBody = await signJson( - body.hashes ? body : computeAndMergeHash({ ...body, signatures: {} }), - signingKey, - serverName, - ); - } - - const auth = await authorizationHeaders( - serverName, - signingKey, - targetServer, + queryParams?: Record, + ) { + return this.makeSignedRequest({ method, - extractURIfromURL(url), - signedBody, - ); - - return { - url, - headers: { - Authorization: auth, - 'User-Agent': 'Rocket.Chat Federation', - 'Content-Type': 'application/json', - ...discoveryHeaders, - }, - }; + domain: targetServer, + uri: endpoint, + queryString: queryParams + ? new URLSearchParams(queryParams).toString() + : '', + }) as Promise; } } diff --git a/packages/federation-sdk/src/services/media.service.ts b/packages/federation-sdk/src/services/media.service.ts index e3c74aa2e..774f339e9 100644 --- a/packages/federation-sdk/src/services/media.service.ts +++ b/packages/federation-sdk/src/services/media.service.ts @@ -12,174 +12,24 @@ export class MediaService { async downloadFromRemoteServer( serverName: string, mediaId: string, - ): Promise { - try { - const buffer = await this.downloadWithAuth(serverName, mediaId); - if (buffer) { - this.logger.info( - `Downloaded media ${mediaId} from ${serverName} via authenticated endpoint`, - ); - return buffer; - } - } catch (error: any) { - this.logger.debug(`Authenticated download failed: ${error.message}`); - } - - return this.downloadLegacy(serverName, mediaId); - } - - private async downloadWithAuth( - serverName: string, - mediaId: string, ): Promise { - const endpoint = `/_matrix/federation/v1/media/download/${mediaId}`; - - const { url, headers } = await this.federationRequest.prepareSignedRequest( - serverName, - endpoint, - 'GET', - ); - - const response = await this.httpsRequest(url, { method: 'GET', headers }); - if (!response || response.statusCode < 200 || response.statusCode >= 300) { - return null; - } - - return this.extractMediaFromResponse(response); - } - - private httpsRequest( - url: URL, - options: { method: string; headers: Record }, - ): Promise<{ - statusCode: number; - headers: Record; - body: Buffer; - } | null> { - return new Promise((resolve) => { - const req = https.request( - { - hostname: url.hostname, - port: url.port || 443, - path: url.pathname + url.search, - method: options.method, - headers: options.headers, - }, - (res) => { - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - resolve({ - statusCode: res.statusCode || 500, - headers: res.headers as Record, - body: Buffer.concat(chunks), - }); - }); - }, - ); - - req.on('error', (error) => { - this.logger.error(`HTTPS request failed: ${error.message}`); - resolve(null); - }); - - req.end(); - }); - } - - private extractMediaFromResponse(response: { - statusCode: number; - headers: Record; - body: Buffer; - }): Buffer { - const contentType = Array.isArray(response.headers['content-type']) - ? response.headers['content-type'][0] - : response.headers['content-type']; - - if (!contentType?.includes('multipart')) { - return response.body; - } - - const boundary = contentType.match(/boundary=([^;]+)/)?.[1]; - if (!boundary) { - throw new Error('No boundary in multipart response'); - } - - return this.parseMultipart(response.body, boundary); - } - - private parseMultipart(data: Buffer, boundary: string): Buffer { - const boundaryBuffer = Buffer.from(`--${boundary}`); - const headerEnd = Buffer.from('\r\n\r\n'); - - let start = 0; - while (start < data.length) { - const boundaryIndex = data.indexOf(boundaryBuffer, start); - if (boundaryIndex === -1) break; - - const partStart = boundaryIndex + boundaryBuffer.length; - const nextBoundary = data.indexOf(boundaryBuffer, partStart); - const partEnd = nextBoundary === -1 ? data.length : nextBoundary; - - const part = data.subarray(partStart, partEnd); - const headerEndIndex = part.indexOf(headerEnd); - - if (headerEndIndex !== -1) { - const headers = part.subarray(0, headerEndIndex).toString('utf-8'); - if ( - headers.includes('Content-Type:') && - !headers.includes('application/json') - ) { - let content = part.subarray(headerEndIndex + headerEnd.length); - while ( - content.length > 0 && - (content[content.length - 1] === 0x0a || - content[content.length - 1] === 0x0d) - ) { - content = content.subarray(0, -1); - } - return content; - } - } - - start = partEnd; - } - - throw new Error('No media content in multipart response'); - } - - private async downloadLegacy( - serverName: string, - mediaId: string, - ): Promise { const endpoints = [ - `https://${serverName}/_matrix/media/v3/download/${serverName}/${mediaId}`, - `https://${serverName}/_matrix/media/r0/download/${serverName}/${mediaId}`, + `/_matrix/federation/v1/media/download/${mediaId}`, + `/_matrix/media/v3/download/${serverName}/${mediaId}`, + `/_matrix/media/r0/download/${serverName}/${mediaId}`, ]; for (const endpoint of endpoints) { try { - const url = new URL(endpoint); - const response = await this.httpsRequest(url, { - method: 'GET', - headers: { - 'User-Agent': 'Rocket.Chat Federation', - Accept: '*/*', - }, - }); - - if ( - response && - response.statusCode >= 200 && - response.statusCode < 300 - ) { - this.logger.info( - `Downloaded media ${mediaId} from ${serverName} via legacy endpoint`, - ); - return response.body; - } - } catch (error: any) { - this.logger.debug(`Legacy endpoint failed: ${error.message}`); + return this.federationRequest.requestBinaryData( + 'GET', + serverName, + endpoint, + ); + } catch (err) { + this.logger.debug( + `Endpoint ${endpoint} failed: ${err instanceof Error ? err.message : String(err)}`, + ); } }