From fb19ee47c8d9bfdc1ddf85031fc4ce7f66ebacf2 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 21 Apr 2026 14:23:52 +0700 Subject: [PATCH] fix(publishing): normalize media links to https --- src/components/post-form/post-form.tsx | 24 ++++++++++++++----- src/components/reply-modal/reply-modal.tsx | 6 ++--- src/lib/utils/__tests__/url-utils.test.ts | 20 +++++++++++++++- src/lib/utils/url-utils.ts | 26 ++++++++++++++++++++- src/stores/__tests__/publish-stores.test.ts | 26 +++++++++++++++++++++ src/stores/use-publish-post-store.ts | 4 +++- src/stores/use-publish-reply-store.ts | 4 +++- 7 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 67ef7c20..324ccd0b 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -5,7 +5,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 { getLinkMediaInfo } from '../../lib/utils/media-utils'; -import { isValidURL } from '../../lib/utils/url-utils'; +import { 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'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; @@ -220,7 +220,11 @@ const PostFormFields = ({ disabled={isUploading} onChange={(e) => { setUrl(e.target.value); - isInPostView ? setPublishReplyOptions({ link: e.target.value }) : setPublishPostOptions({ link: e.target.value }); + if (isInPostView) { + setPublishReplyOptions({ link: e.target.value }); + } else { + setPublishPostOptions({ link: e.target.value }); + } }} /> {url && } @@ -364,7 +368,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: alert(t('empty_comment_alert')); return; } - if (currentUrl && !isValidURL(currentUrl)) { + if (currentUrl && !isValidPublishURL(currentUrl)) { alert(t('invalid_url_alert')); return; } @@ -400,7 +404,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const handleContentChange = (e: React.ChangeEvent) => { const content = e.target.value; - isInPostView ? setPublishReplyOptions({ content }) : setPublishPostOptions({ content }); + if (isInPostView) { + setPublishReplyOptions({ content }); + } else { + setPublishPostOptions({ content }); + } checkContentLength(content, t); }; @@ -416,7 +424,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: return; } - if (currentUrl && !isValidURL(currentUrl)) { + if (currentUrl && !isValidPublishURL(currentUrl)) { alert(t('invalid_url_alert')); return; } @@ -444,7 +452,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: if (urlRef.current) { urlRef.current.value = uploadedUrl; } - isInPostView ? setPublishReplyOptions({ link: uploadedUrl }) : setPublishPostOptions({ link: uploadedUrl }); + if (isInPostView) { + setPublishReplyOptions({ link: uploadedUrl }); + } else { + setPublishPostOptions({ link: uploadedUrl }); + } } }, }); diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 436e1dc7..327e6678 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks'; -import { isValidURL } from '../../lib/utils/url-utils'; +import { isValidPublishURL } from '../../lib/utils/url-utils'; import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils'; import useSelectedTextStore from '../../stores/use-selected-text-store'; import useReplyModalStore from '../../stores/use-reply-modal-store'; @@ -84,7 +84,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa return; } - if (currentUrl && !isValidURL(currentUrl)) { + if (currentUrl && !isValidPublishURL(currentUrl)) { setError(t('error') + ': ' + t('invalid_url_alert')); return; } @@ -154,7 +154,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const parentCidRef = useRef(null); useEffect(() => { - if (parentCidRef.current && parentCidRef.current) { + if (parentCidRef.current) { const cidWidth = parentCidRef.current.offsetWidth; parentCidRef.current.style.width = `${cidWidth}px`; } diff --git a/src/lib/utils/__tests__/url-utils.test.ts b/src/lib/utils/__tests__/url-utils.test.ts index eb966aca..115e0d79 100644 --- a/src/lib/utils/__tests__/url-utils.test.ts +++ b/src/lib/utils/__tests__/url-utils.test.ts @@ -8,7 +8,16 @@ vi.mock('../clipboard-utils', () => ({ copyToClipboard: (text: string) => testState.copyToClipboardMock(text), })); -import { copyShareLinkToClipboard, getHostname, is5chanLink, isValidCrossboardPattern, isValidURL, transform5chanLinkToInternal } from '../url-utils'; +import { + copyShareLinkToClipboard, + getHostname, + is5chanLink, + isValidCrossboardPattern, + isValidPublishURL, + isValidURL, + normalizePublishURL, + transform5chanLinkToInternal, +} from '../url-utils'; describe('url-utils', () => { beforeEach(() => { @@ -22,6 +31,15 @@ describe('url-utils', () => { expect(isValidURL('not-a-url')).toBe(false); }); + it('normalizes publish links to the https URLs accepted by communities', () => { + expect(normalizePublishURL(' http://i.imgur.com/YpB7qfa.jpg ')).toBe('https://i.imgur.com/YpB7qfa.jpg'); + expect(normalizePublishURL('https://i.imgur.com/YpB7qfa.jpg')).toBe('https://i.imgur.com/YpB7qfa.jpg'); + expect(isValidPublishURL('http://i.imgur.com/YpB7qfa.jpg')).toBe(true); + expect(isValidPublishURL('https://i.imgur.com/YpB7qfa.jpg')).toBe(true); + expect(isValidPublishURL('ftp://example.com/file.jpg')).toBe(false); + expect(isValidPublishURL('not-a-url')).toBe(false); + }); + 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'); diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index 4c81ce2f..c9d505f3 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -5,7 +5,7 @@ export const QUOTE_NUMBER_REGEX = /(?/\w])>>(\d+)/g; export const getHostname = (url: string) => { try { return new URL(url).hostname.replace(/^www\./, ''); - } catch (e) { + } catch { return ''; } }; @@ -19,6 +19,30 @@ export const isValidURL = (url: string) => { } }; +export const normalizePublishURL = (url: string) => { + const trimmedUrl = url.trim(); + + try { + const parsedUrl = new URL(trimmedUrl); + if (parsedUrl.protocol === 'http:') { + parsedUrl.protocol = 'https:'; + return parsedUrl.toString(); + } + } catch { + return trimmedUrl; + } + + return trimmedUrl; +}; + +export const isValidPublishURL = (url: string) => { + try { + return new URL(normalizePublishURL(url)).protocol === 'https:'; + } catch { + return false; + } +}; + const CHAN_5_HOSTNAMES = ['5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app']; function getShareBaseUrl(): string { diff --git a/src/stores/__tests__/publish-stores.test.ts b/src/stores/__tests__/publish-stores.test.ts index 19e22a99..268cb7d9 100644 --- a/src/stores/__tests__/publish-stores.test.ts +++ b/src/stores/__tests__/publish-stores.test.ts @@ -66,6 +66,19 @@ describe('publish stores', () => { expect(usePublishPostStore.getState().title).toBeUndefined(); }); + it('normalizes post publish links to https before building the payload', () => { + usePublishPostStore.getState().setPublishPostStore({ + content: 'post body', + link: 'http://i.imgur.com/YpB7qfa.jpg', + communityAddress: 'history-posting.bso', + title: 'Hello', + }); + + const state = usePublishPostStore.getState(); + expect(state.link).toBe('https://i.imgur.com/YpB7qfa.jpg'); + expect(state.publishCommentOptions.link).toBe('https://i.imgur.com/YpB7qfa.jpg'); + }); + it('usePublishReplyStore stores reply data per parentCid and resets a single thread', () => { const comment: PublishReplyInput = { author: { address: '0x123', displayName: 'Author Name', role: 'mod' }, @@ -97,4 +110,17 @@ describe('publish stores', () => { expect(usePublishReplyStore.getState().publishCommentOptions['parent-1']).toBeUndefined(); expect(usePublishReplyStore.getState().content['parent-1']).toBeUndefined(); }); + + it('normalizes reply publish links to https before building the payload', () => { + usePublishReplyStore.getState().setPublishReplyStore({ + content: 'reply body', + link: 'http://i.imgur.com/YpB7qfa.jpg', + parentCid: 'parent-1', + communityAddress: 'history-posting.bso', + }); + + const state = usePublishReplyStore.getState(); + expect(state.link['parent-1']).toBe('https://i.imgur.com/YpB7qfa.jpg'); + expect(state.publishCommentOptions['parent-1']?.link).toBe('https://i.imgur.com/YpB7qfa.jpg'); + }); }); diff --git a/src/stores/use-publish-post-store.ts b/src/stores/use-publish-post-store.ts index 29b0bfb6..8f732de3 100644 --- a/src/stores/use-publish-post-store.ts +++ b/src/stores/use-publish-post-store.ts @@ -2,6 +2,7 @@ import { ChallengeVerification, Comment, PublishCommentOptions } from '@bitsocia import { create } from 'zustand'; import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; +import { normalizePublishURL } from '../lib/utils/url-utils'; type SubmitState = { author?: any | undefined; @@ -27,7 +28,8 @@ const usePublishPostStore = create((set) => ({ publishCommentOptions: {}, setPublishPostStore: (comment: Comment) => set(() => { - const { author, content, link, spoiler, title } = comment; + const { author, content, spoiler, title } = comment; + const link = comment.link ? normalizePublishURL(comment.link) : undefined; const communityAddress = getCommentCommunityAddress(comment); const displayName = 'displayName' in comment ? comment.displayName || undefined : author?.displayName; diff --git a/src/stores/use-publish-reply-store.ts b/src/stores/use-publish-reply-store.ts index c6e74aa5..b85e4df8 100644 --- a/src/stores/use-publish-reply-store.ts +++ b/src/stores/use-publish-reply-store.ts @@ -2,6 +2,7 @@ import { ChallengeVerification, Comment, PublishCommentOptions } from '@bitsocia import { create } from 'zustand'; import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; +import { normalizePublishURL } from '../lib/utils/url-utils'; type ReplyState = { author: { [parentCid: string]: any | undefined }; @@ -24,7 +25,8 @@ const usePublishReplyStore = create((set) => ({ setPublishReplyStore: (comment: Comment) => set((state) => { - const { parentCid, author, content, link, spoiler } = comment; + const { parentCid, author, content, spoiler } = comment; + const link = comment.link ? normalizePublishURL(comment.link) : undefined; const communityAddress = getCommentCommunityAddress(comment); const displayName = 'displayName' in comment ? comment.displayName || undefined : author?.displayName;