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;