fix(post form): reject non-media file links

This commit is contained in:
Tommaso Casaburi
2026-06-03 17:10:37 +07:00
parent 188c44a923
commit 95f3a54b9d
38 changed files with 171 additions and 43 deletions
@@ -319,13 +319,19 @@ vi.mock('../../../lib/utils/media-utils', () => ({
if (link.endsWith('.gif')) {
return { type: 'gif', url: link };
}
if (link.endsWith('.png')) {
if (link.endsWith('.jpg') || link.endsWith('.jpeg') || link.endsWith('.png')) {
return { type: 'image', url: link };
}
if (link.endsWith('.mp4')) {
return { type: 'video', url: link };
}
return { type: 'link', url: link };
if (link.endsWith('.mp3')) {
return { type: 'audio', url: link };
}
if (link.includes('youtube.com')) {
return { patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', type: 'iframe', url: link };
}
return { type: 'webpage', url: link };
},
getYouTubeThumbnailUrlFromLink: (link: string) => {
try {
@@ -617,6 +623,12 @@ describe('PostForm', () => {
expect(globalThis.alert).not.toHaveBeenCalled();
expect(container.textContent).toContain('error: invalid_url_alert');
await dispatchInput(linkInput as HTMLInputElement, 'https://example.com/page');
await clickByText(table as HTMLTableElement, 'post');
expect(globalThis.alert).not.toHaveBeenCalled();
expect(container.textContent).toContain('error: link_not_image_or_video_alert');
expect(testState.publishPostMock).not.toHaveBeenCalled();
await dispatchInput(textarea as HTMLTextAreaElement, 'A valid body');
await dispatchInput(linkInput as HTMLInputElement, 'https://i.4cdn.org/gif/file.jpg');
await clickByText(table as HTMLTableElement, 'post');
@@ -668,6 +680,7 @@ describe('PostForm', () => {
await dispatchInput(linkInput, youtubeLink);
expect(linkInput.value).toBe(youtubeLink);
expect(linkInput.disabled).toBe(true);
expect(container.textContent).toContain('youtube_thumbnail_link_conversion_notice');
expect(container.textContent).toContain('3');
expect(table.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
@@ -677,17 +690,20 @@ describe('PostForm', () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('2');
expect(linkInput.disabled).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(container.textContent).toContain('1');
expect(linkInput.disabled).toBe(true);
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(linkInput.value).toBe(thumbnailLink);
expect(linkInput.disabled).toBe(false);
expect(textarea.value).toBe(youtubeLink);
expect(container.textContent).not.toContain('youtube_thumbnail_link_conversion_notice');
} finally {
@@ -1433,16 +1449,34 @@ describe('LinkTypePreviewer', () => {
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.gif' }));
});
expect(container.textContent).toBe('type: animated_gif');
expect(container.textContent).toBe('file: animated_gif');
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.mp4' }));
});
expect(container.textContent).toBe('type: video');
expect(container.textContent).toBe('file: video');
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'not-a-url' }));
});
expect(container.textContent).toBe('invalid_url');
});
it('shows unsupported file links as not a file on media-only forms', async () => {
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.mp3', requireFile: true }));
});
expect(container.textContent).toBe('not_a_file');
expect(container.querySelector('span')?.className).toContain('linkTypeError');
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://www.youtube.com/watch?v=abc123', requireFile: true }));
});
expect(container.textContent).toBe('not_a_file');
await act(async () => {
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/page', requireFile: true }));
});
expect(container.textContent).toBe('not_a_file');
});
});
@@ -113,6 +113,10 @@
text-transform: lowercase;
}
.linkTypeError {
color: red;
}
.linkField input[type="text"] {
width: 190px !important;
}
+24 -4
View File
@@ -55,6 +55,7 @@ import debounce from 'lodash/debounce';
const FILE_LINK_PLACEHOLDER = 'https://website.com/image.jpg';
const POST_FORM_FILE_DISPLAY_MAX_LENGTH = 28;
const POST_FORM_FILE_MEDIA_TYPES = new Set(['gif', 'image', 'video']);
const mergeFlairs = (...flairGroups: Array<Comment['flairs'] | undefined>): Comment['flairs'] | undefined => {
const flairs = flairGroups.flatMap((group) => (Array.isArray(group) ? group : []));
@@ -67,12 +68,20 @@ const getPostFormFileDisplayLabel = (url: string, uploadedFileName: string | nul
return truncateWithEllipsisInMiddle(raw, POST_FORM_FILE_DISPLAY_MAX_LENGTH);
};
export const LinkTypePreviewer = ({ link }: { link: string }) => {
const isPostFormFileMediaType = (type: string | undefined): boolean => Boolean(type && POST_FORM_FILE_MEDIA_TYPES.has(type));
const isPostFormFileMediaLink = (link: string): boolean => isPostFormFileMediaType(getLinkMediaInfo(link)?.type);
export const LinkTypePreviewer = ({ link, requireFile = false }: { link: string; requireFile?: boolean }) => {
const { t } = useTranslation();
const mediaInfo = getLinkMediaInfo(link);
let type = mediaInfo?.type;
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined);
if (requireFile && isValidURL(link) && !isPostFormFileMediaType(type)) {
return <span className={styles.linkTypeError}>{t('not_a_file')}</span>;
}
if (type === 'gif' && gifFrameStatus === 'ready') {
type = t('animated_gif');
} else if (type === 'gif') {
@@ -81,7 +90,7 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => {
type = getDisplayMediaInfoType(type, t);
}
return isValidURL(link) ? `type: ${type}` : t('invalid_url');
return isValidURL(link) ? `${t('file')}: ${type}` : t('invalid_url');
};
const PostFormActions = ({
@@ -149,6 +158,7 @@ interface PostFormFieldsProps {
handleContentValueChange: (content: string, options?: string) => void;
handleLinkChange: (link: string) => void;
handleOptionsChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
disableLinkInput: boolean;
setPublishPostOptions: (opts: Record<string, unknown>) => void;
setPublishReplyOptions: (opts: Record<string, unknown>) => void;
isUploading: boolean;
@@ -200,6 +210,7 @@ const PostFormFields = ({
handleContentValueChange,
handleLinkChange,
handleOptionsChange,
disableLinkInput,
setPublishPostOptions,
setPublishReplyOptions,
isUploading,
@@ -357,12 +368,12 @@ const PostFormFields = ({
spellCheck='false'
placeholder={requirePostLinkIsMedia ? FILE_LINK_PLACEHOLDER : undefined}
ref={urlRef}
disabled={isUploading}
disabled={disableLinkInput}
onChange={(e) => {
handleLinkChange(e.target.value);
}}
/>
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} requireFile={requirePostLinkIsMedia} />}</span>
</td>
</tr>
{showUploadControls && (
@@ -633,6 +644,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPostFormFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setFormError(expiringMediaLinkAlert);
@@ -757,6 +772,10 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
setFormError(`${t('error')}: ${t('invalid_url_alert')}`);
return;
}
if (currentUrl && requirePostLinkIsMedia && !isPostFormFileMediaLink(currentUrl)) {
setFormError(`${t('error')}: ${t('link_not_image_or_video_alert')}`);
return;
}
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
if (expiringMediaLinkAlert) {
setFormError(expiringMediaLinkAlert);
@@ -873,6 +892,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
handleContentValueChange={handleContentValueChange}
handleLinkChange={handleLinkChange}
handleOptionsChange={handleOptionsChange}
disableLinkInput={isUploading || youtubeThumbnailConversionCountdown !== null}
setPublishPostOptions={setPublishPostOptions}
setPublishReplyOptions={setPublishReplyOptions}
isUploading={isUploading}