diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index db9281585c..2e0ab035dc 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -843,8 +843,13 @@ instance FromJSON ImageData where parseJSON = fmap ImageData . J.parseJSON instance ToJSON ImageData where - toJSON (ImageData t) = J.toJSON t - toEncoding (ImageData t) = J.toEncoding t + toJSON (ImageData t) = J.toJSON $ safeImageData t + toEncoding (ImageData t) = J.toEncoding $ safeImageData t + +safeImageData :: Text -> Text +safeImageData t + | "data:" `T.isPrefixOf` t = t + | otherwise = "" instance ToField ImageData where toField (ImageData t) = toField t diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 991398c463..96640cb430 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -20,6 +20,7 @@ module Simplex.Chat.Web channelContentChanged, channelProfileUpdated, channelRemoved, + extractOrigin, ) where @@ -34,6 +35,7 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB +import Data.Text.Encoding (encodeUtf8) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import qualified Data.Set as S @@ -76,6 +78,8 @@ import Simplex.Chat.Types ) import Simplex.Messaging.Agent.Store.Common (withTransaction) import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (safeDecodeUtf8) +import qualified URI.ByteString as U import Simplex.Messaging.Parsers (defaultJSON) import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) import System.FilePath (dropExtension, takeExtension, ()) @@ -369,13 +373,17 @@ corsEntry publicGroupId PublicGroupAccess {groupWebPage, allowEmbedding} = let fName = T.pack $ publicGroupIdFileName publicGroupId <> ".json" origin | allowEmbedding = CorsAny - | otherwise = CorsOrigins $ filter isSafeOrigin $ maybeToList groupWebPage + | otherwise = CorsOrigins $ mapMaybe extractOrigin $ maybeToList groupWebPage in (fName, origin) -isSafeOrigin :: Text -> Bool -isSafeOrigin t = - (T.isPrefixOf "https://" t || T.isPrefixOf "http://" t) - && T.all (\c -> c /= '"' && c /= '\n' && c /= '\r' && c /= ' ') t +extractOrigin :: Text -> Maybe Text +extractOrigin url = + case U.parseURI U.laxURIParserOptions (encodeUtf8 url) of + Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority = Just _} + | sch == "https" || sch == "http" -> + let originUri = uri {U.uriPath = "", U.uriQuery = U.Query [], U.uriFragment = Nothing} + in Just $ safeDecodeUtf8 $ U.serializeURIRef' originUri + _ -> Nothing channelPath :: Text channelPath = "/channel/" diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 5735700f26..57095fb28f 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -19,7 +19,7 @@ import ProtocolTests (testGroupProfile) import Simplex.Chat.Protocol (LinkOwnerSig, MsgChatLink (..), MsgContent (..)) import Simplex.Chat.Types (GroupProfile (..)) import Simplex.Chat.Controller (CorsOrigin (..)) -import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), removeStaleFiles, writeCorsConfig) +import Simplex.Chat.Web (WebChannelPreview (..), WebMessage (..), extractOrigin, removeStaleFiles, writeCorsConfig) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Util (decodeJSON) import qualified Data.Set as S @@ -47,6 +47,7 @@ chatRelayTests = do it "channel deletion removes preview file" testWebPreviewChannelDeleted it "removeStaleFiles preserves non-base64url files" testWebPreviewStaleCleanup it "generate CORS config" testWebPreviewCors + it "extractOrigin strips path from URL" testExtractOrigin describe "share channel card" $ do it "share channel card in direct chat" testShareChannelDirect it "share channel card in group" testShareChannelGroup @@ -566,6 +567,16 @@ testWebPreviewCors ps = do corsContent `shouldContain` "Access-Control-Allow-Origin" corsContent `shouldContain` "Access-Control-Allow-Methods" +testExtractOrigin :: HasCallStack => TestParams -> IO () +testExtractOrigin _ps = do + extractOrigin "https://owner.example.com/channel.html" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com/path/to/page?q=1#frag" `shouldBe` Just "https://owner.example.com" + extractOrigin "https://owner.example.com:8443/page" `shouldBe` Just "https://owner.example.com:8443" + extractOrigin "https://owner.example.com" `shouldBe` Just "https://owner.example.com" + extractOrigin "http://localhost:3000/preview" `shouldBe` Just "http://localhost:3000" + extractOrigin "ftp://example.com/file" `shouldBe` Nothing + extractOrigin "not-a-url" `shouldBe` Nothing + -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index 5d9713152b..80a5aa248e 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -327,6 +327,22 @@ const STYLE = ` width: 300px; } +.simplex-preview-image-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 80px; + background: var(--sp-quote); + border-radius: 12px; + color: var(--sp-text-secondary); +} + +.simplex-preview-image-placeholder svg { + width: 32px; + height: 32px; +} + .simplex-preview-link-card { display: block; max-width: 400px; @@ -714,6 +730,12 @@ const STYLE = ` const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent(''); +const IMAGE_PLACEHOLDER_SVG = ``; + +function isDataImage(src) { + return typeof src === 'string' && src.startsWith('data:image/'); +} + function tailSvg() { return ''; } @@ -881,7 +903,7 @@ function renderHeader(channel, channelLink, subscriberCount) { const avatar = document.createElement('img'); avatar.className = 'simplex-preview-header-avatar'; - avatar.src = channel.image || DEFAULT_AVATAR; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; avatar.alt = channel.displayName; header.appendChild(avatar); @@ -919,7 +941,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp const avatar = document.createElement('img'); avatar.className = 'simplex-preview-info-avatar'; - avatar.src = channel.image || DEFAULT_AVATAR; + avatar.src = isDataImage(channel.image) ? channel.image : DEFAULT_AVATAR; avatar.alt = channel.displayName; container.appendChild(avatar); @@ -1152,7 +1174,7 @@ function renderMessages(container, messages, membersMap, channel) { if (showAvatar) { const avatarImg = document.createElement('img'); avatarImg.className = 'simplex-preview-msg-avatar'; - avatarImg.src = senderImage || DEFAULT_AVATAR; + avatarImg.src = isDataImage(senderImage) ? senderImage : DEFAULT_AVATAR; avatarImg.alt = senderName; row.appendChild(avatarImg); } else { @@ -1262,7 +1284,7 @@ function renderQuote(quote) { quoteDiv.appendChild(contentDiv); if (quote.content) { - if (quote.content.type === 'image' || quote.content.type === 'video') { + if ((quote.content.type === 'image' || quote.content.type === 'video') && isDataImage(quote.content.image)) { const thumb = document.createElement('img'); thumb.className = 'simplex-preview-quote-thumb'; thumb.src = quote.content.image; @@ -1290,13 +1312,18 @@ function classifyImage(img) { } function renderImageContent(inner, mc, msg, mediaOnly) { - if (mc.image) { + if (isDataImage(mc.image)) { const img = document.createElement('img'); img.className = 'simplex-preview-image'; img.src = mc.image; img.alt = 'Image'; img.addEventListener('load', () => classifyImage(img)); inner.appendChild(img); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); } if (mc.text) { appendTextBlock(inner, msg); @@ -1306,7 +1333,7 @@ function renderImageContent(inner, mc, msg, mediaOnly) { } function renderVideoContent(inner, mc, msg, mediaOnly) { - if (mc.image) { + if (isDataImage(mc.image)) { const wrapper = document.createElement('div'); wrapper.style.position = 'relative'; const img = document.createElement('img'); @@ -1320,6 +1347,11 @@ function renderVideoContent(inner, mc, msg, mediaOnly) { dur.textContent = formatDuration(mc.duration || 0); wrapper.appendChild(dur); inner.appendChild(wrapper); + } else { + const ph = document.createElement('div'); + ph.className = 'simplex-preview-image-placeholder'; + ph.innerHTML = IMAGE_PLACEHOLDER_SVG; + inner.appendChild(ph); } if (mc.text) { appendTextBlock(inner, msg); @@ -1332,7 +1364,7 @@ function renderLinkContent(bubble, mc, msg) { if (mc.preview) { const card = document.createElement('div'); card.className = 'simplex-preview-link-card'; - if (mc.preview.image) { + if (isDataImage(mc.preview.image)) { const img = document.createElement('img'); img.className = 'simplex-preview-link-card-image'; img.src = mc.preview.image;