Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-feet-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes an issue where audio and video messages would stop playing if left idle past their link expiration. Now the player automatically refreshes expired links so users can continue listening or watching without reloading the chat.
21 changes: 21 additions & 0 deletions apps/meteor/app/file-upload/server/lib/FileUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,27 @@ export const FileUpload = {
res.end();
},

respondWithRedirectUrlInfo(
redirectUrl: string | false,
file: IUpload,
_req: http.IncomingMessage,
res: http.ServerResponse,
expiresInSeconds?: number | null,
) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.writeHead(200);
res.end(
JSON.stringify({
redirectUrl,
name: file.name,
type: file.type,
size: file.size,
...(expiresInSeconds && { expires: Date.now() / 1000 + expiresInSeconds }),
Comment thread
abhinavkrin marked this conversation as resolved.
Outdated
}),
);
},

proxyFile(
fileName: string,
fileUrl: string,
Expand Down
34 changes: 34 additions & 0 deletions apps/meteor/app/file-upload/server/lib/requests.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import type { IncomingMessage } from 'http';

import { Uploads } from '@rocket.chat/models';
import { WebApp } from 'meteor/webapp';

import { FileUpload } from './FileUpload';
import { SystemLogger } from '../../../../server/lib/logger/system';

const hasReplyWithRedirectUrlParam = (req: IncomingMessage) => {
if (!req.url) {
return false;
}
const [, params] = req.url.split('?');
if (!params) {
return false;
}
const searchParams = new URLSearchParams(params);
const replyWithRedirectUrl = searchParams.get('replyWithRedirectUrl');
return replyWithRedirectUrl === 'true' || replyWithRedirectUrl === '1';
};
Comment thread
gabriellsh marked this conversation as resolved.

WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {
const match = /^\/([^\/]+)\/(.*)/.exec(req.url || '');
Expand All @@ -16,6 +32,24 @@ WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {
return;
}

if (hasReplyWithRedirectUrlParam(req)) {
if (!file.store) {
res.writeHead(404);
res.end();
return;
}
const store = FileUpload.getStoreByName(file.store);
let url: string | false = false;
let expiryTimespan: number | null = null;
try {
url = await store.getStore().getRedirectURL(file, false);
expiryTimespan = await store.getStore().getUrlExpiryTimeSpan();
} catch (e) {
SystemLogger.debug(e);
}
return FileUpload.respondWithRedirectUrlInfo(url, file, req, res, expiryTimespan);
}

res.setHeader('Content-Security-Policy', "default-src 'none'");
res.setHeader('Cache-Control', 'max-age=31536000');
await FileUpload.get(file, req, res, next);
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/app/file-upload/ufs/AmazonS3/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ class AmazonS3Store extends UploadFS.Store {

return writeStream;
};

this.getUrlExpiryTimeSpan = async () => {
return options.URLExpiryTimeSpan || null;
};
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { AudioAttachmentProps } from '@rocket.chat/core-typings';
import { AudioPlayer } from '@rocket.chat/fuselage';
import { useMediaUrl } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';

import { useReloadOnError } from './hooks/useReloadOnError';
import MarkdownText from '../../../../MarkdownText';
import MessageCollapsible from '../../../MessageCollapsible';
import MessageContentBody from '../../../MessageContentBody';
Expand All @@ -18,11 +20,14 @@ const AudioAttachment = ({
collapsed,
}: AudioAttachmentProps) => {
const getURL = useMediaUrl();
const src = useMemo(() => getURL(url), [getURL, url]);
const { mediaRef } = useReloadOnError(src, 'audio');

return (
<>
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
<AudioPlayer src={getURL(url)} type={type} />
<AudioPlayer src={src} type={type} ref={mediaRef} />
</MessageCollapsible>
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { VideoAttachmentProps } from '@rocket.chat/core-typings';
import { Box, MessageGenericPreview } from '@rocket.chat/fuselage';
import { useMediaUrl } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';

import { useReloadOnError } from './hooks/useReloadOnError';
import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback';
import MarkdownText from '../../../../MarkdownText';
import MessageCollapsible from '../../../MessageCollapsible';
Expand All @@ -19,13 +21,15 @@ const VideoAttachment = ({
collapsed,
}: VideoAttachmentProps) => {
const getURL = useMediaUrl();
const src = useMemo(() => getURL(url), [getURL, url]);
const { mediaRef } = useReloadOnError(src, 'video');

return (
<>
{descriptionMd ? <MessageContentBody md={descriptionMd} /> : <MarkdownText parseEmoji content={description} />}
<MessageCollapsible title={title} hasDownload={hasDownload} link={getURL(link || url)} size={size} isCollapsed={collapsed}>
<MessageGenericPreview style={{ maxWidth: 368, width: '100%' }}>
<Box is='video' controls preload='metadata'>
<Box is='video' controls preload='metadata' ref={mediaRef}>
<source src={getURL(url)} type={userAgentMIMETypeFallback(type)} />
</Box>
</MessageGenericPreview>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useSafeRefCallback } from '@rocket.chat/ui-client';
import { useCallback, useRef, useState } from 'react';

const events = ['error', 'stalled', 'play'];
export const useReloadOnError = (url: string, type: 'video' | 'audio') => {
const [expiresAt, setExpiresAt] = useState<number | null>(null);
const isRecovering = useRef(false);
const firstRecoveryAttempted = useRef(false);

const getRedirectURLInfo = useCallback(async (url: string): Promise<{ redirectUrl: string | false; expires: number | null }> => {
const [path, query] = url.split('?');
const params = new URLSearchParams(query);
params.set('replyWithRedirectUrl', 'true');
const response = await fetch(`${path}?${params.toString()}`, {
credentials: 'same-origin',
});
Comment thread
abhinavkrin marked this conversation as resolved.
Outdated

if (!response.ok) {
throw new Error(`Failed to fetch URL info: ${response.statusText}`);
}

const data = await response.json();

return {
redirectUrl: data.redirectUrl,
expires: data.expires ? new Date(data.expires * 1000).getTime() : null,
};
}, []);

const renderBufferingUIFallback = useCallback((vidEl: HTMLVideoElement) => {
const computed = getComputedStyle(vidEl);

const videoTempStyles = {
width: vidEl.style.width,
height: vidEl.style.height,
};
Object.assign(vidEl.style, {
width: computed.width,
height: computed.height,
});

return () => {
Object.assign(vidEl.style, videoTempStyles);
};
}, []);
Comment thread
abhinavkrin marked this conversation as resolved.
Outdated

const handleMediaURLRecovery = useEffectEvent(async (event: Event) => {
if (isRecovering.current) {
console.debug(`Media URL recovery already in progress, skipping ${event.type} event`);
return;
}
isRecovering.current = true;

const node = event.target as HTMLMediaElement | null;
if (!node) {
isRecovering.current = false;
return;
}

if (firstRecoveryAttempted.current && !expiresAt) {
console.debug('No expiration time set, skipping recovery');
isRecovering.current = false;
return;
}
firstRecoveryAttempted.current = true;

if (expiresAt && Date.now() < expiresAt) {
console.debug('Media URL is still valid, skipping recovery');
isRecovering.current = false;
return;
}

console.debug('Handling media URL recovery for event:', event.type);

let cleanup: (() => void) | undefined;
if (type === 'video') {
cleanup = renderBufferingUIFallback(node as HTMLVideoElement);
}

const wasPlaying = !node.paused;
const { currentTime } = node;

try {
const { redirectUrl: newUrl, expires: newExpiresAt } = await getRedirectURLInfo(url);
setExpiresAt(newExpiresAt);
node.src = newUrl || url;

const onCanPlay = async () => {
node.removeEventListener('canplay', onCanPlay);

node.currentTime = currentTime;
if (wasPlaying) {
try {
await node.play();
} catch (playError) {
console.warn('Failed to resume playback after URL recovery:', playError);
} finally {
isRecovering.current = false;
}
}
};

const onMetaDataLoaded = () => {
node.removeEventListener('loadedmetadata', onMetaDataLoaded);
isRecovering.current = false;
cleanup?.();
};

node.addEventListener('canplay', onCanPlay, { once: true });
node.addEventListener('loadedmetadata', onMetaDataLoaded, { once: true });
node.load();
} catch (err) {
console.error('Error during URL recovery:', err);
isRecovering.current = false;
cleanup?.();
}
});

const mediaRefCallback = useSafeRefCallback(
useCallback(
(node: HTMLAudioElement | null) => {
if (!node) {
return;
}

events.forEach((event) => {
node.addEventListener(event, handleMediaURLRecovery);
});
return () => {
if (!node) {
return;
}
Comment thread
abhinavkrin marked this conversation as resolved.
Outdated
events.forEach((event) => {
node.removeEventListener(event, handleMediaURLRecovery);
});
};
},
[handleMediaURLRecovery],
),
);

return { mediaRef: mediaRefCallback };
};
4 changes: 4 additions & 0 deletions apps/meteor/server/ufs/ufs-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,4 +368,8 @@ export class Store {
await this.onValidate(file, options);
}
}

async getUrlExpiryTimeSpan(): Promise<number | null> {
throw new Error('getUrlExpiryTimeSpan is not implemented');
}
}
Loading