-
Notifications
You must be signed in to change notification settings - Fork 19
feat: adds canAccessMedia middleware and upload repo #198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7affc88
4b7b3c7
6e2367c
dbd9e4f
194e5a4
15c00c0
75392e3
cc2c99a
3f1fcbd
cda1968
b6b15f2
5d57911
a8d1023
710a94a
c8ee8fe
b580320
b995362
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,62 +1,182 @@ | ||||||||||||||||||||||||||||||||||||||||||
| import { type OutgoingHttpHeaders } from 'node:http'; | ||||||||||||||||||||||||||||||||||||||||||
| import { type IncomingHttpHeaders } from 'node:http'; | ||||||||||||||||||||||||||||||||||||||||||
| import https from 'node:https'; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| type RequestOptions = Parameters<typeof https.request>[1]; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export type MultipartResult = { | ||||||||||||||||||||||||||||||||||||||||||
| content: Buffer; | ||||||||||||||||||||||||||||||||||||||||||
| headers?: Record<string, string>; | ||||||||||||||||||||||||||||||||||||||||||
| 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<T>(contentType: string, body: Buffer): Promise<T | null> { | ||||||||||||||||||||||||||||||||||||||||||
| 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<string> { | ||||||||||||||||||||||||||||||||||||||||||
| if (!contentType.includes('text/')) { | ||||||||||||||||||||||||||||||||||||||||||
| return Promise.resolve(''); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return Promise.resolve(body.toString()); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| function handleMultipart( | ||||||||||||||||||||||||||||||||||||||||||
| contentType: string, | ||||||||||||||||||||||||||||||||||||||||||
| body: Buffer, | ||||||||||||||||||||||||||||||||||||||||||
| ): Promise<MultipartResult | null> { | ||||||||||||||||||||||||||||||||||||||||||
| if (!/\bmultipart\b/i.test(contentType)) { | ||||||||||||||||||||||||||||||||||||||||||
| return Promise.resolve(null); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // extract boundary from content-type header | ||||||||||||||||||||||||||||||||||||||||||
| const boundaryMatch = contentType.match(/boundary=([^;,\s]+)/i); | ||||||||||||||||||||||||||||||||||||||||||
| 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<T>(url: URL, options: RequestInit) { | ||||||||||||||||||||||||||||||||||||||||||
| const serverName = new URL( | ||||||||||||||||||||||||||||||||||||||||||
| `http://${(options.headers as OutgoingHttpHeaders).Host}` as string, | ||||||||||||||||||||||||||||||||||||||||||
| `http://${(options.headers as IncomingHttpHeaders).Host}` as string, | ||||||||||||||||||||||||||||||||||||||||||
| ).hostname; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const requestParams: RequestOptions = { | ||||||||||||||||||||||||||||||||||||||||||
| host: url.hostname, // IP | ||||||||||||||||||||||||||||||||||||||||||
| port: url.port, | ||||||||||||||||||||||||||||||||||||||||||
| method: options.method, | ||||||||||||||||||||||||||||||||||||||||||
| path: url.pathname + url.search, | ||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||
| ...(options.headers as OutgoingHttpHeaders), | ||||||||||||||||||||||||||||||||||||||||||
| 'content-type': 'application/json', | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| headers: options.headers as IncomingHttpHeaders, | ||||||||||||||||||||||||||||||||||||||||||
| 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: IncomingHttpHeaders; | ||||||||||||||||||||||||||||||||||||||||||
| } = await new Promise((resolve, reject) => { | ||||||||||||||||||||||||||||||||||||||||||
| const request = https.request(requestParams, (res) => { | ||||||||||||||||||||||||||||||||||||||||||
| const chunks: Buffer[] = []; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| res.once('error', reject); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // TODO: Make @hs/core fetch size limit configurable | ||||||||||||||||||||||||||||||||||||||||||
| let total = 0; | ||||||||||||||||||||||||||||||||||||||||||
| const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; // 50 MB | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| res.on('data', (chunk) => { | ||||||||||||||||||||||||||||||||||||||||||
| total += chunk.length; | ||||||||||||||||||||||||||||||||||||||||||
| if (total > MAX_RESPONSE_BYTES) { | ||||||||||||||||||||||||||||||||||||||||||
| request.destroy(new Error('Response exceeds size limit')); | ||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| chunks.push(chunk); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| request.on('error', (err) => { | ||||||||||||||||||||||||||||||||||||||||||
| reject(err); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+118
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stop the response stream when size limit is exceeded Destroying only the request may not halt the inbound response; explicitly destroy the response to stop I/O and free memory earlier. Apply this diff: - res.on('data', (chunk) => {
+ res.on('data', (chunk) => {
total += chunk.length;
if (total > MAX_RESPONSE_BYTES) {
- request.destroy(new Error('Response exceeds size limit'));
+ const err = new Error('Response exceeds size limit');
+ res.destroy(err);
+ request.destroy(err);
return;
}
chunks.push(chunk);
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| res.on('end', () => { | ||||||||||||||||||||||||||||||||||||||||||
| resolve({ | ||||||||||||||||||||||||||||||||||||||||||
| statusCode: res.statusCode, | ||||||||||||||||||||||||||||||||||||||||||
| body: Buffer.concat(chunks), | ||||||||||||||||||||||||||||||||||||||||||
| headers: res.headers, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const signal = options.signal; | ||||||||||||||||||||||||||||||||||||||||||
| if (signal) { | ||||||||||||||||||||||||||||||||||||||||||
| const onAbort = () => request.destroy(new Error('Aborted')); | ||||||||||||||||||||||||||||||||||||||||||
| signal.addEventListener('abort', onAbort, { once: true }); | ||||||||||||||||||||||||||||||||||||||||||
| request.once('close', () => | ||||||||||||||||||||||||||||||||||||||||||
| signal.removeEventListener('abort', onAbort), | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| request.end(options.body); | ||||||||||||||||||||||||||||||||||||||||||
| request.on('error', (err) => { | ||||||||||||||||||||||||||||||||||||||||||
| reject(err); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // TODO: Make @hs/core fetch timeout configurable | ||||||||||||||||||||||||||||||||||||||||||
| request.setTimeout(20_000, () => { | ||||||||||||||||||||||||||||||||||||||||||
| request.destroy(new Error('Request timed out after 20s')); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| request.end(options.body); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+154
to
+155
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard unsupported RequestInit.body types RequestInit.body can be Headers API types (ReadableStream, URLSearchParams, etc.). https.request only accepts string | Buffer. Without guarding, this can throw at runtime. Apply this diff: - request.end(options.body);
+ const payload =
+ typeof options.body === 'string' || Buffer.isBuffer(options.body)
+ ? (options.body as string | Buffer)
+ : undefined;
+ if (options.body && !payload) {
+ request.destroy(new Error('Unsupported request body type'));
+ return;
+ }
+ request.end(payload);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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: () => response.body, | ||||||||||||||||||||||||||||||||||||||||||
| json: () => handleJson<T>(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: () => Buffer.from(''), | ||||||||||||||||||||||||||||||||||||||||||
| json: () => Promise.resolve(null), | ||||||||||||||||||||||||||||||||||||||||||
| text: () => | ||||||||||||||||||||||||||||||||||||||||||
| Promise.resolve(err instanceof Error ? err.message : String(err)), | ||||||||||||||||||||||||||||||||||||||||||
| multipart: () => Promise.resolve(null), | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MatrixBridgedRoom>, | ||
| ) {} | ||
|
|
||
| async findMatrixRoomId(rocketChatRoomId: string): Promise<string | null> { | ||
| const bridgedRoom = await this.collection.findOne({ | ||
| rid: rocketChatRoomId, | ||
| }); | ||
|
|
||
| return bridgedRoom?.mri || null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Upload>, | ||
| ) {} | ||
|
|
||
| async findRocketChatRoomIdByMediaId(mediaId: string): Promise<string | null> { | ||
| const upload = await this.collection.findOne({ | ||
| 'federation.mediaId': mediaId, | ||
| }); | ||
|
|
||
| return upload?.rid || null; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multipart parsing via Buffer.toString risks corruption; use Buffer-safe boundary scanning
Converting the whole payload to string can mangle binary content and mis-detect boundaries. Parse by boundary using Buffer indices and only decode header sections.
Apply this diff to replace parseMultipart:
I can also add tests covering binary bodies and Location redirects on request.
📝 Committable suggestion