mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post form): block temporary media links
This commit is contained in:
@@ -52,7 +52,7 @@ const testState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, options?: Record<string, unknown>) => (options?.domain ? `${key}:${options.domain}` : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -401,8 +401,14 @@ describe('PostForm', () => {
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('invalid_url_alert');
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await dispatchInput(linkInput as HTMLInputElement, '');
|
||||
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');
|
||||
expect(globalThis.alert).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain('error: expiring_media_link_alert:i.4cdn.org');
|
||||
expect(testState.publishPostMock).not.toHaveBeenCalled();
|
||||
|
||||
await dispatchInput(linkInput as HTMLInputElement, '');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('no_board_selected_warning');
|
||||
|
||||
|
||||
@@ -188,6 +188,11 @@
|
||||
color: red;
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 8px 0;
|
||||
padding: 6px 5px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bi
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import { getDisplayMediaInfoType, getLinkMediaInfo } from '../../lib/utils/media-utils';
|
||||
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
|
||||
import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||
@@ -346,6 +347,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const accountCommunityAddresses = useAccountCommunityAddresses();
|
||||
|
||||
const [lengthError, setLengthError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const checkContentLength = useRef(
|
||||
debounce((content: string, t: TFunction) => {
|
||||
@@ -377,6 +379,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
|
||||
checkContentLength.cancel();
|
||||
setLengthError(null);
|
||||
setFormError(null);
|
||||
|
||||
if (!currentTitle && !currentContent && !currentUrl) {
|
||||
alert(t('empty_comment_alert'));
|
||||
@@ -386,6 +389,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
alert(t('invalid_url_alert'));
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setFormError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentContent.length > 2000) {
|
||||
alert(t('error') + ': ' + t('field_too_long'));
|
||||
@@ -432,6 +440,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
|
||||
checkContentLength.cancel();
|
||||
setLengthError(null);
|
||||
setFormError(null);
|
||||
|
||||
if (!currentContent && !currentUrl) {
|
||||
alert(t('empty_comment_alert'));
|
||||
@@ -442,6 +451,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
alert(t('invalid_url_alert'));
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setFormError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentContent.length > 2000) {
|
||||
alert(t('error') + ': ' + t('field_too_long'));
|
||||
@@ -527,8 +541,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
{publishPostError && <div className={styles.error}>{publishPostError}</div>}
|
||||
{publishReplyError && <div className={styles.error}>{publishReplyError}</div>}
|
||||
{formError && <div className={`${styles.error} ${styles.formError}`}>{formError}</div>}
|
||||
{publishPostError && <div className={`${styles.error} ${styles.formError}`}>{publishPostError}</div>}
|
||||
{publishReplyError && <div className={`${styles.error} ${styles.formError}`}>{publishReplyError}</div>}
|
||||
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -414,6 +414,12 @@ describe('ReplyModal', () => {
|
||||
expect(container.textContent).toContain('error: invalid_url_alert');
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ spoiler: true });
|
||||
|
||||
await dispatchInput(linkInput, 'https://temp.sh/example.png');
|
||||
await clickButtonByText('post');
|
||||
|
||||
expect(container.textContent).toContain('error: expiring_media_link_alert:{"domain":"temp.sh"}');
|
||||
expect(testState.publishReplyMock).not.toHaveBeenCalled();
|
||||
|
||||
await dispatchInput(linkInput, 'https://example.com/file.png');
|
||||
await clickButtonByText('post');
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLocation, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation-utils';
|
||||
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
||||
@@ -103,6 +104,11 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
setError(t('error') + ': ' + t('invalid_url_alert'));
|
||||
return;
|
||||
}
|
||||
const expiringMediaLinkAlert = currentUrl ? getExpiringMediaLinkAlert(currentUrl, t) : null;
|
||||
if (expiringMediaLinkAlert) {
|
||||
setError(expiringMediaLinkAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
checkContentLengthRef.current.cancel();
|
||||
setLengthError(null);
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../clipboard-utils', () => ({
|
||||
|
||||
import {
|
||||
copyShareLinkToClipboard,
|
||||
getExpiringMediaLinkHostname,
|
||||
getHostname,
|
||||
getPublishURLFilename,
|
||||
is5chanLink,
|
||||
@@ -62,6 +63,15 @@ describe('url-utils', () => {
|
||||
expect(getPublishURLFilename('not-a-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects publish media hosts with temporary links', () => {
|
||||
expect(getExpiringMediaLinkHostname('https://i.4cdn.org/gif/1712345678900.jpg')).toBe('i.4cdn.org');
|
||||
expect(getExpiringMediaLinkHostname('http://litterbox.catbox.moe/u/example.png')).toBe('litterbox.catbox.moe');
|
||||
expect(getExpiringMediaLinkHostname('https://www.tmpfiles.org/dl/123/file.mp4')).toBe('tmpfiles.org');
|
||||
expect(getExpiringMediaLinkHostname('https://cdn.file.kiwi/example')).toBe('file.kiwi');
|
||||
expect(getExpiringMediaLinkHostname('https://example.com/file.png')).toBeNull();
|
||||
expect(getExpiringMediaLinkHostname('not-a-url')).toBeNull();
|
||||
});
|
||||
|
||||
it('copies share links for threads and catalog pages using the production fallback base url', async () => {
|
||||
await copyShareLinkToClipboard('music.eth', 'thread', 'cid-123');
|
||||
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('https://5chan.app/#/music.eth/thread/cid-123');
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getExpiringMediaLinkHostname } from './url-utils';
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export const getExpiringMediaLinkAlert = (url: string, t: TranslateFn): string | null => {
|
||||
const expiringMediaLinkHostname = getExpiringMediaLinkHostname(url);
|
||||
return expiringMediaLinkHostname ? `${t('error')}: ${t('expiring_media_link_alert', { domain: expiringMediaLinkHostname })}` : null;
|
||||
};
|
||||
@@ -67,6 +67,41 @@ export const normalizePublishURL = (url: string) => {
|
||||
return trimmedUrl;
|
||||
};
|
||||
|
||||
const EXPIRING_MEDIA_LINK_HOSTNAMES = [
|
||||
// 4chan CDN media disappears when threads are pruned, usually after a few hours or days.
|
||||
'i.4cdn.org',
|
||||
// Litterbox temporary uploads can expire after 1 hour, 12 hours, 1 day, or 3 days.
|
||||
'litterbox.catbox.moe',
|
||||
// tmpfiles.org uploads expire after 60 minutes, 6 hours, 12 hours, or 24 hours.
|
||||
'tmpfiles.org',
|
||||
// Filebin uploads are deleted automatically after about 6 days.
|
||||
'filebin.net',
|
||||
// temp.sh files expire after 3 days.
|
||||
'temp.sh',
|
||||
// Termbin pastes are automatically deleted after 1 week.
|
||||
'termbin.com',
|
||||
// Uguu files expire after 3 hours.
|
||||
'uguu.se',
|
||||
// file.kiwi encrypted files are deleted after about 4 days by default.
|
||||
'file.kiwi',
|
||||
] as const;
|
||||
|
||||
const normalizeHostnameForMatching = (hostname: string) => hostname.toLowerCase().replace(/^www\./, '');
|
||||
|
||||
export const getExpiringMediaLinkHostname = (url: string): string | null => {
|
||||
try {
|
||||
const parsedUrl = new URL(normalizePublishURL(url));
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = normalizeHostnameForMatching(parsedUrl.hostname);
|
||||
return EXPIRING_MEDIA_LINK_HOSTNAMES.find((expiringHostname) => hostname === expiringHostname || hostname.endsWith(`.${expiringHostname}`)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isValidPublishURL = (url: string) => {
|
||||
try {
|
||||
return new URL(normalizePublishURL(url)).protocol === 'https:';
|
||||
|
||||
Reference in New Issue
Block a user