Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: new Date(Date.now() + expiresInSeconds * 1000).toISOString() }),
}),
);
},

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';
};

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,235 @@
import { renderHook, act } from '@testing-library/react';

import { useReloadOnError } from './useReloadOnError';
import { FakeResponse } from '../../../../../../../tests/mocks/utils/FakeResponse';

interface ITestMediaElement extends HTMLAudioElement {
_emit: (type: string) => void;
}

function makeMediaEl(): ITestMediaElement {
const el = document.createElement('audio') as ITestMediaElement;
(el as any).play = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(el, 'paused', { value: false, configurable: true });
el._emit = (type: string) => el.dispatchEvent(new Event(type));
return el;
}

describe('useReloadOnError', () => {
const OLD_FETCH = global.fetch;

beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(console, 'debug').mockImplementation(() => null);
jest.spyOn(console, 'warn').mockImplementation(() => null);
jest.spyOn(console, 'error').mockImplementation(() => null);

// default mock: fresh redirect URL + ISO expiry 60s ahead
global.fetch = jest.fn().mockResolvedValue(
new FakeResponse(
JSON.stringify({
redirectUrl: '/sampleurl?token=xyz',
expires: new Date(Date.now() + 60_000).toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
) as any;
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
global.fetch = OLD_FETCH as any;
jest.restoreAllMocks();
jest.resetAllMocks();
});

it('refreshes media src on error and preserves playback position', async () => {
const original = '/sampleurl?token=abc';
const { result } = renderHook(() => useReloadOnError(original, 'audio'));

const media = makeMediaEl();
media.currentTime = 12;

act(() => {
result.current.mediaRef(media);
});

const loadSpy = jest.spyOn(media, 'load');

await act(async () => {
media._emit('error');
});

expect(global.fetch).toHaveBeenCalledTimes(1);

expect(media.src).toContain('/sampleurl?token=xyz');

await act(async () => {
media._emit('loadedmetadata');
media._emit('canplay');
});

expect(loadSpy).toHaveBeenCalled();
expect(media.currentTime).toBe(12);
expect((media as any).play).toHaveBeenCalled();
});

it('refreshes media src on stalled and preserves playback position', async () => {
const original = '/sampleurl?token=abc';
const { result } = renderHook(() => useReloadOnError(original, 'audio'));

const media = makeMediaEl();
media.currentTime = 12;

act(() => {
result.current.mediaRef(media);
});

const loadSpy = jest.spyOn(media, 'load');

await act(async () => {
media._emit('stalled');
});

expect(global.fetch).toHaveBeenCalledTimes(1);

expect(media.src).toContain('/sampleurl?token=xyz');

await act(async () => {
media._emit('loadedmetadata');
media._emit('canplay');
});

expect(loadSpy).toHaveBeenCalled();
expect(media.currentTime).toBe(12);
expect((media as any).play).toHaveBeenCalled();
});

it('does nothing when URL is not expired (second event before expiry)', async () => {
// Pin system time so Date.now() is deterministic under fake timers
const fixed = new Date('2030-01-01T00:00:00.000Z');
jest.setSystemTime(fixed);

// Backend replies with expiry 60s in the future (relative to pinned time)
global.fetch = jest.fn().mockResolvedValue(
new FakeResponse(
JSON.stringify({
redirectUrl: '/new?x=1',
expires: new Date(fixed.getTime() + 60_000).toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
) as any;

const { result } = renderHook(() => useReloadOnError('/sampleurl?token=abc', 'audio'));
const media = makeMediaEl();

act(() => {
result.current.mediaRef(media);
});

// First event → fetch + set expires
await act(async () => {
media._emit('stalled');
});
expect(global.fetch).toHaveBeenCalledTimes(1);

// Second event before expiry → early return, no new fetch
await act(async () => {
media._emit('stalled');
});
expect(global.fetch).toHaveBeenCalledTimes(1);
});

it('recovers on stalled after expiry and restores seek position', async () => {
// Pin time
const fixed = new Date('2030-01-01T00:00:00.000Z');
jest.setSystemTime(fixed);

// 1st fetch (first recovery) -> expires in 5s
const firstReply = new FakeResponse(
JSON.stringify({
redirectUrl: '/fresh?token=first',
expires: new Date(fixed.getTime() + 5_000).toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);

// 2nd fetch (after expiry) -> new url, further expiry
const secondReply = new FakeResponse(
JSON.stringify({
redirectUrl: '/fresh?token=second',
expires: new Date(fixed.getTime() + 65_000).toISOString(),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);

// Mock fetch to return first, then second
(global.fetch as jest.Mock) = jest.fn().mockResolvedValueOnce(firstReply).mockResolvedValueOnce(secondReply);

const { result } = renderHook(() => useReloadOnError('/sampleurl?token=old', 'audio'));
const media = makeMediaEl();
act(() => {
result.current.mediaRef(media);
});

// Initial recovery to set expiresAt (simulate an error)
await act(async () => {
media._emit('error');
});
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(media.src).toContain('/fresh?token=first');

// Complete the ready cycle
await act(async () => {
media._emit('loadedmetadata');
media._emit('canplay');
});

// Fast-forward time beyond expiry
jest.setSystemTime(new Date(fixed.getTime() + 6_000));

// User scrubs to a new position just before stall is detected
media.currentTime = 42;

const loadSpy = jest.spyOn(media, 'load');

// Now we stall after expiry -> should trigger a new fetch
await act(async () => {
media._emit('stalled');
});

expect(global.fetch).toHaveBeenCalledTimes(2);
expect(media.src).toContain('/fresh?token=second');

// Complete the ready cycle
await act(async () => {
media._emit('loadedmetadata');
media._emit('canplay');
});

// Ensure we reloaded and restored the seek position + playback
expect(loadSpy).toHaveBeenCalled();
expect(media.currentTime).toBe(42);
expect((media as any).play).toHaveBeenCalled();
});

it('ignores initial play when expiry is unknown', async () => {
// no fetch expected on first play because expiresAt is not known yet
global.fetch = jest.fn();

const { result } = renderHook(() => useReloadOnError('/foo', 'audio'));
const media = makeMediaEl();

act(() => {
result.current.mediaRef(media);
});

await act(async () => {
media._emit('play');
});

expect(global.fetch).not.toHaveBeenCalled();
});
});
Loading
Loading