mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post form): publish twimg query-format links with path extension
Normalize pbs.twimg.com media URLs that use ?format= to their file-extension form at publish time without rewriting the input field.
This commit is contained in:
@@ -313,37 +313,57 @@ vi.mock('../../loading-ellipsis', () => ({
|
|||||||
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../lib/utils/media-utils', () => ({
|
vi.mock('../../../lib/utils/media-utils', () => {
|
||||||
getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type),
|
const getTwimgMediaFilePublishUrl = (link: string) => {
|
||||||
getLinkMediaInfo: (link: string) => {
|
|
||||||
if (link.endsWith('.gif')) {
|
|
||||||
return { type: 'gif', url: link };
|
|
||||||
}
|
|
||||||
if (link.endsWith('.jpg') || link.endsWith('.jpeg') || link.endsWith('.png')) {
|
|
||||||
return { type: 'image', url: link };
|
|
||||||
}
|
|
||||||
if (link.endsWith('.mp4')) {
|
|
||||||
return { type: 'video', 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 {
|
try {
|
||||||
const url = new URL(link);
|
const url = new URL(link.trim());
|
||||||
if (!url.hostname.includes('youtube.com')) return undefined;
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||||
const videoId = url.searchParams.get('v');
|
const [mediaPath, mediaId] = pathParts;
|
||||||
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
|
const format = url.searchParams.get('format')?.toLowerCase();
|
||||||
|
if (url.hostname !== 'pbs.twimg.com' || pathParts.length !== 2 || mediaPath !== 'media' || !mediaId || mediaId.includes('.')) return undefined;
|
||||||
|
if (!format || !['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(format)) return undefined;
|
||||||
|
return `https://pbs.twimg.com/media/${mediaId}.${format}`;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
}));
|
|
||||||
|
return {
|
||||||
|
getDisplayMediaInfoType: (type: string, t: (key: string) => string) => t(type),
|
||||||
|
getLinkMediaInfo: (link: string) => {
|
||||||
|
if (getTwimgMediaFilePublishUrl(link)) {
|
||||||
|
return { type: 'image', url: link };
|
||||||
|
}
|
||||||
|
if (link.endsWith('.gif')) {
|
||||||
|
return { type: 'gif', url: link };
|
||||||
|
}
|
||||||
|
if (link.endsWith('.jpg') || link.endsWith('.jpeg') || link.endsWith('.png')) {
|
||||||
|
return { type: 'image', url: link };
|
||||||
|
}
|
||||||
|
if (link.endsWith('.mp4')) {
|
||||||
|
return { type: 'video', 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 };
|
||||||
|
},
|
||||||
|
getTwimgMediaFilePublishUrl,
|
||||||
|
getYouTubeThumbnailUrlFromLink: (link: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(link);
|
||||||
|
if (!url.hostname.includes('youtube.com')) return undefined;
|
||||||
|
const videoId = url.searchParams.get('v');
|
||||||
|
return videoId ? `https://img.youtube.com/vi/${videoId}/0.jpg` : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||||
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
||||||
@@ -718,6 +738,32 @@ describe('PostForm', () => {
|
|||||||
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
|
expect(testState.publishedPostOptions?.content).toBe(youtubeLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('publishes known twimg query-format post links with a path extension without editing the field', async () => {
|
||||||
|
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
|
||||||
|
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
|
||||||
|
|
||||||
|
await renderPostForm('/all');
|
||||||
|
await clickByText(container, 'start_new_thread');
|
||||||
|
|
||||||
|
const table = container.querySelector('table') as HTMLTableElement;
|
||||||
|
const select = table.querySelector('select') as HTMLSelectElement;
|
||||||
|
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[3];
|
||||||
|
|
||||||
|
await dispatchChange(select, 'music-posting.eth');
|
||||||
|
await dispatchInput(textarea, 'Twimg thread');
|
||||||
|
await dispatchInput(linkInput, twimgLink);
|
||||||
|
await clickByText(table, 'post');
|
||||||
|
|
||||||
|
expect(linkInput.value).toBe(twimgLink);
|
||||||
|
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testState.publishPostMock).toHaveBeenCalledWith({
|
||||||
|
content: 'Twimg thread',
|
||||||
|
link: publishLink,
|
||||||
|
});
|
||||||
|
expect(testState.publishedPostOptions?.link).toBe(publishLink);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows Oekaki draw controls only on the /i/ board form', async () => {
|
it('shows Oekaki draw controls only on the /i/ board form', async () => {
|
||||||
testState.directories.push({
|
testState.directories.push({
|
||||||
address: 'oekaki-posting.bso',
|
address: 'oekaki-posting.bso',
|
||||||
@@ -1430,6 +1476,36 @@ describe('PostForm', () => {
|
|||||||
|
|
||||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('publishes known twimg query-format reply links with a path extension without editing the field', async () => {
|
||||||
|
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
|
||||||
|
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
|
||||||
|
|
||||||
|
testState.comments = {
|
||||||
|
'thread-cid': {
|
||||||
|
postCid: 'thread-cid',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||||
|
|
||||||
|
await renderPostForm('/mu/thread/thread-cid');
|
||||||
|
await clickByText(container, 'post_a_reply');
|
||||||
|
|
||||||
|
const table = container.querySelector('table') as HTMLTableElement;
|
||||||
|
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const linkInput = table.querySelectorAll<HTMLInputElement>('input[type="text"]')[2];
|
||||||
|
|
||||||
|
await dispatchInput(textarea, 'Twimg reply');
|
||||||
|
await dispatchInput(linkInput, twimgLink);
|
||||||
|
await clickByText(table, 'post');
|
||||||
|
|
||||||
|
expect(linkInput.value).toBe(twimgLink);
|
||||||
|
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(testState.publishReplyMock).toHaveBeenCalledWith({
|
||||||
|
content: 'Twimg reply',
|
||||||
|
link: publishLink,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('LinkTypePreviewer', () => {
|
describe('LinkTypePreviewer', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
|
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
import getShortAddress from '../../lib/get-short-address';
|
||||||
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||||
import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
|
||||||
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
|
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
|
||||||
import {
|
import {
|
||||||
type DiceRoll,
|
type DiceRoll,
|
||||||
@@ -72,6 +72,15 @@ const isPostFormFileMediaType = (type: string | undefined): boolean => Boolean(t
|
|||||||
|
|
||||||
const isPostFormFileMediaLink = (link: string): boolean => isPostFormFileMediaType(getLinkMediaInfo(link)?.type);
|
const isPostFormFileMediaLink = (link: string): boolean => isPostFormFileMediaType(getLinkMediaInfo(link)?.type);
|
||||||
|
|
||||||
|
const getPublishLinkOptions = (link: string, includeCurrentLink: boolean): Partial<Pick<Comment, 'link'>> => {
|
||||||
|
const twimgPublishUrl = getTwimgMediaFilePublishUrl(link);
|
||||||
|
if (twimgPublishUrl) {
|
||||||
|
return { link: twimgPublishUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
return includeCurrentLink && link ? { link } : {};
|
||||||
|
};
|
||||||
|
|
||||||
export const LinkTypePreviewer = ({ link, requireFile = false }: { link: string; requireFile?: boolean }) => {
|
export const LinkTypePreviewer = ({ link, requireFile = false }: { link: string; requireFile?: boolean }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mediaInfo = getLinkMediaInfo(link);
|
const mediaInfo = getLinkMediaInfo(link);
|
||||||
@@ -673,7 +682,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
};
|
};
|
||||||
|
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
publishPost({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...publishOptions });
|
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
|
||||||
};
|
};
|
||||||
|
|
||||||
// redirect to pending page when pending comment is created
|
// redirect to pending page when pending comment is created
|
||||||
@@ -790,7 +799,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
const flagPublishOptions = getCommentFlagPublishOptionsForDirectory(directoryEntry, flagRef.current?.value);
|
||||||
|
|
||||||
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
|
||||||
publishReply({ content: publishContent, ...(appliedYouTubeConversion ? { link: currentUrl } : {}), ...flagPublishOptions });
|
publishReply({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...flagPublishOptions });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setLinkValue = (nextUrl: string) => {
|
const setLinkValue = (nextUrl: string) => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
getLinkMediaInfo,
|
getLinkMediaInfo,
|
||||||
getMediaDimensions,
|
getMediaDimensions,
|
||||||
getPostMediaTypeLabel,
|
getPostMediaTypeLabel,
|
||||||
|
getTwimgMediaFilePublishUrl,
|
||||||
getYouTubeEmbedPostMediaFileLink,
|
getYouTubeEmbedPostMediaFileLink,
|
||||||
getYouTubeThumbnailUrlFromLink,
|
getYouTubeThumbnailUrlFromLink,
|
||||||
} from '../media-utils';
|
} from '../media-utils';
|
||||||
@@ -192,6 +193,14 @@ describe('media-utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes known twimg query-format media links for publishing', () => {
|
||||||
|
expect(getTwimgMediaFilePublishUrl('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium')).toBe('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg');
|
||||||
|
expect(getTwimgMediaFilePublishUrl('http://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=PNG&name=small')).toBe('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.png');
|
||||||
|
expect(getTwimgMediaFilePublishUrl('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg?format=png&name=medium')).toBeUndefined();
|
||||||
|
expect(getTwimgMediaFilePublishUrl('https://example.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium')).toBeUndefined();
|
||||||
|
expect(getTwimgMediaFilePublishUrl('https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=txt&name=medium')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('builds comment media info and strips thumbnails for blacklisted domains', () => {
|
it('builds comment media info and strips thumbnails for blacklisted domains', () => {
|
||||||
testState.canEmbedHosts = new Set(['www.youtube.com']);
|
testState.canEmbedHosts = new Set(['www.youtube.com']);
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ const KNOWN_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv'
|
|||||||
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
|
const KNOWN_AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma'];
|
||||||
const KNOWN_SWF_EXTENSIONS = ['swf'];
|
const KNOWN_SWF_EXTENSIONS = ['swf'];
|
||||||
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS, ...KNOWN_SWF_EXTENSIONS]);
|
const KNOWN_MEDIA_EXTENSIONS = new Set([...KNOWN_IMAGE_EXTENSIONS, ...KNOWN_VIDEO_EXTENSIONS, ...KNOWN_AUDIO_EXTENSIONS, ...KNOWN_SWF_EXTENSIONS]);
|
||||||
|
const TWIMG_MEDIA_FORMAT_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif']);
|
||||||
|
|
||||||
// some sites don't show thumbnails, so the backend-side thumbnail fetching needs to be disabled, or it might fetch non-thumbnails such as emojis
|
// some sites don't show thumbnails, so the backend-side thumbnail fetching needs to be disabled, or it might fetch non-thumbnails such as emojis
|
||||||
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
|
const THUMBNAIL_BLACKLISTED_DOMAINS = ['twitter.com', 'x.com'];
|
||||||
@@ -157,6 +158,27 @@ const getDirectMediaExtension = (url: URL): string => {
|
|||||||
return pathParts.length > 1 ? pathParts[pathParts.length - 1] : '';
|
return pathParts.length > 1 ? pathParts[pathParts.length - 1] : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTwimgMediaFilePublishUrl = (link: string): string | undefined => {
|
||||||
|
const url = parseHttpUrl(link.trim());
|
||||||
|
if (!url || url.hostname.toLowerCase() !== 'pbs.twimg.com') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||||
|
const [mediaPath, mediaId] = pathParts;
|
||||||
|
if (pathParts.length !== 2 || mediaPath !== 'media' || !mediaId || mediaId.includes('.')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = url.searchParams.get('format')?.toLowerCase();
|
||||||
|
if (!format || !TWIMG_MEDIA_FORMAT_EXTENSIONS.has(format)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
url.protocol = 'https:';
|
||||||
|
return `${url.origin}/media/${mediaId}.${format}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const getLinkMediaInfo = memoize(
|
export const getLinkMediaInfo = memoize(
|
||||||
(link: string): CommentMediaInfo | undefined => {
|
(link: string): CommentMediaInfo | undefined => {
|
||||||
if (!isValidURL(link)) {
|
if (!isValidURL(link)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user