diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 7fa43160aa..ad72c822d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -106,7 +106,6 @@ import org.thoughtcrime.securesms.jobs.MmsSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.ImageSlide; import org.thoughtcrime.securesms.mms.PartAuthority; @@ -123,6 +122,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.revealable.ViewOnceMessageView; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan; +import org.thoughtcrime.securesms.util.LinkUtil; import org.thoughtcrime.securesms.util.LongClickMovementMethod; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.PlaceholderURLSpan; @@ -1362,7 +1362,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (hasLinks) { Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class)) - .filterNot(url -> LinkPreviewUtil.isLegalUrl(url.getURL())) + .filterNot(url -> LinkUtil.isLegalUrl(url.getURL())) .forEach(messageBody::removeSpan); URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java index 8fcc7c36e4..f4fa0fb9c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java @@ -19,7 +19,7 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; +import org.thoughtcrime.securesms.util.LinkUtil; import org.thoughtcrime.securesms.util.LongClickCopySpan; public final class GroupDescriptionUtil { @@ -43,7 +43,7 @@ public final class GroupDescriptionUtil { if (hasLinks) { Stream.of(descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class)) - .filterNot(url -> LinkPreviewUtil.isLegalUrl(url.getURL())) + .filterNot(url -> LinkUtil.isLegalUrl(url.getURL())) .forEach(descriptionSpannable::removeSpan); URLSpan[] urlSpans = descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java index 6ad919195b..03b076fcbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewRepository.java @@ -11,6 +11,7 @@ import androidx.core.util.Consumer; import com.bumptech.glide.load.engine.DiskCacheStrategy; +import org.signal.core.util.Hex; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.InvalidMessageException; @@ -45,8 +46,8 @@ import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.ByteUnit; -import org.signal.core.util.Hex; import org.thoughtcrime.securesms.util.ImageCompressionUtil; +import org.thoughtcrime.securesms.util.LinkUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.OkHttpUtil; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; @@ -95,7 +96,7 @@ public class LinkPreviewRepository { CompositeRequestController compositeController = new CompositeRequestController(); - if (!LinkPreviewUtil.isValidPreviewUrl(url)) { + if (!LinkUtil.isValidPreviewUrl(url)) { Log.w(TAG, "Tried to get a link preview for a non-whitelisted domain."); callback.onError(Error.PREVIEW_NOT_AVAILABLE); return compositeController; @@ -164,7 +165,7 @@ public class LinkPreviewRepository { Optional imageUrl = openGraph.getImageUrl(); long date = openGraph.getDate(); - if (imageUrl.isPresent() && !LinkPreviewUtil.isValidPreviewUrl(imageUrl.get())) { + if (imageUrl.isPresent() && !LinkUtil.isValidPreviewUrl(imageUrl.get())) { Log.i(TAG, "Image URL was invalid or for a non-whitelisted domain. Skipping."); imageUrl = Optional.empty(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java index eed1d3ca73..9a50d21def 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtil.java @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.linkpreview; import android.annotation.SuppressLint; import android.text.SpannableString; -import android.text.TextUtils; import android.text.style.URLSpan; import android.text.util.Linkify; @@ -14,10 +13,8 @@ import androidx.core.text.util.LinkifyCompat; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.stickers.StickerUrl; import org.thoughtcrime.securesms.util.DateUtils; -import org.signal.core.util.SetUtil; +import org.thoughtcrime.securesms.util.LinkUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.util.OptionalUtil; @@ -34,12 +31,6 @@ import okhttp3.HttpUrl; public final class LinkPreviewUtil { - private static final String TAG = Log.tag(LinkPreviewUtil.class); - - private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$"); - private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$"); - private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$"); - private static final Pattern ILLEGAL_CHARACTERS_PATTERN = Pattern.compile("[\u202C\u202D\u202E\u2500-\u25FF]"); private static final Pattern OPEN_GRAPH_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*og:([^\"]+)\"[^>]*/?\\s*>"); private static final Pattern ARTICLE_TAG_PATTERN = Pattern.compile("<\\s*meta[^>]*property\\s*=\\s*\"\\s*article:([^\"]+)\"[^>]*/?\\s*>"); private static final Pattern OPEN_GRAPH_CONTENT_PATTERN = Pattern.compile("content\\s*=\\s*\"([^\"]*)\""); @@ -47,8 +38,6 @@ public final class LinkPreviewUtil { private static final Pattern FAVICON_PATTERN = Pattern.compile("<\\s*link[^>]*rel\\s*=\\s*\".*icon.*\"[^>]*>"); private static final Pattern FAVICON_HREF_PATTERN = Pattern.compile("href\\s*=\\s*\"([^\"]*)\""); - private static final Set INVALID_TOP_LEVEL_DOMAINS = SetUtil.newHashSet("onion", "i2p"); - public static @Nullable String getTopLevelDomain(@Nullable String urlString) { if (!Util.isEmpty(urlString)) { HttpUrl url = HttpUrl.parse(urlString); @@ -61,7 +50,7 @@ public final class LinkPreviewUtil { } /** - * @return All whitelisted URLs in the source text. + * @return All URLs allowed as previews in the source text. */ public static @NonNull Links findValidPreviewUrls(@NonNull String text) { SpannableString spannable = new SpannableString(text); @@ -73,47 +62,10 @@ public final class LinkPreviewUtil { return new Links(Stream.of(spannable.getSpans(0, spannable.length(), URLSpan.class)) .map(span -> new Link(span.getURL(), spannable.getSpanStart(span))) - .filter(link -> isValidPreviewUrl(link.getUrl())) + .filter(link -> LinkUtil.isValidPreviewUrl(link.getUrl())) .toList()); } - /** - * @return True if the host is present in the link whitelist. - */ - public static boolean isValidPreviewUrl(@Nullable String linkUrl) { - if (linkUrl == null) return false; - if (StickerUrl.isValidShareLink(linkUrl)) return true; - - HttpUrl url = HttpUrl.parse(linkUrl); - return url != null && - !TextUtils.isEmpty(url.scheme()) && - "https".equals(url.scheme()) && - isLegalUrl(linkUrl); - } - - public static boolean isLegalUrl(@NonNull String url) { - if (ILLEGAL_CHARACTERS_PATTERN.matcher(url).find()) { - return false; - } - - Matcher matcher = DOMAIN_PATTERN.matcher(url); - - if (matcher.matches()) { - String domain = matcher.group(2); - String cleanedDomain = domain.replaceAll("\\.", ""); - String topLevelDomain = parseTopLevelDomain(domain); - - boolean validCharacters = ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || - ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(); - - boolean validTopLevelDomain = !INVALID_TOP_LEVEL_DOMAINS.contains(topLevelDomain); - - return validCharacters && validTopLevelDomain; - } else { - return false; - } - } - public static @NonNull OpenGraph parseOpenGraphFields(@Nullable String html) { if (html == null) { return new OpenGraph(Collections.emptyMap(), null, null); @@ -169,16 +121,6 @@ public final class LinkPreviewUtil { return new OpenGraph(openGraphTags, htmlTitle, faviconUrl); } - private static @Nullable String parseTopLevelDomain(@NonNull String domain) { - int periodIndex = domain.lastIndexOf("."); - - if (periodIndex >= 0 && periodIndex < domain.length() - 1) { - return domain.substring(periodIndex + 1); - } else { - return null; - } - } - private static @NonNull String fromDoubleEncoded(@NonNull String html) { return HtmlCompat.fromHtml(HtmlCompat.fromHtml(html, 0).toString(), 0).toString(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java index e36fcd4d32..8877c8bf51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java @@ -26,8 +26,8 @@ import org.thoughtcrime.securesms.components.FullScreenDialogFragment; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.conversation.colors.ColorizerView; import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.LinkUtil; import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.views.Stub; @@ -152,7 +152,7 @@ public class LongMessageFragment extends FullScreenDialogFragment { if (hasLinks) { Stream.of(messageBody.getSpans(0, messageBody.length(), URLSpan.class)) - .filterNot(url -> LinkPreviewUtil.isLegalUrl(url.getURL())) + .filterNot(url -> LinkUtil.isLegalUrl(url.getURL())) .forEach(messageBody::removeSpan); } return messageBody; diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 47c9322046..aeb8147bdb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -128,6 +128,7 @@ import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.IdentityUtil; +import org.thoughtcrime.securesms.util.LinkUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -2702,7 +2703,7 @@ public final class MessageContentProcessor { Optional description = Optional.ofNullable(preview.getDescription()); boolean hasTitle = !TextUtils.isEmpty(title.orElse("")); boolean presentInBody = url.isPresent() && urlsInMessage.containsUrl(url.get()); - boolean validDomain = url.isPresent() && LinkPreviewUtil.isValidPreviewUrl(url.get()); + boolean validDomain = url.isPresent() && LinkUtil.isValidPreviewUrl(url.get()); if (hasTitle && (presentInBody || isStoryEmbed) && validDomain) { LinkPreview linkPreview = new LinkPreview(url.get(), title.orElse(""), description.orElse(""), preview.getDate(), thumbnail); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LinkUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/LinkUtil.java new file mode 100644 index 0000000000..ce8dcf5f2b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LinkUtil.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.util; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.SetUtil; +import org.thoughtcrime.securesms.stickers.StickerUrl; + +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import okhttp3.HttpUrl; + +public final class LinkUtil { + + private static final Pattern DOMAIN_PATTERN = Pattern.compile("^(https?://)?([^/]+).*$"); + private static final Pattern ALL_ASCII_PATTERN = Pattern.compile("^[\\x00-\\x7F]*$"); + private static final Pattern ALL_NON_ASCII_PATTERN = Pattern.compile("^[^\\x00-\\x7F]*$"); + private static final Pattern ILLEGAL_CHARACTERS_PATTERN = Pattern.compile("[\u202C\u202D\u202E\u2500-\u25FF]"); + + private static final Set INVALID_TOP_LEVEL_DOMAINS = SetUtil.newHashSet("onion", "i2p"); + + private LinkUtil() {} + + /** + * @return True if URL is valid for link previews. + */ + public static boolean isValidPreviewUrl(@Nullable String linkUrl) { + if (linkUrl == null) { + return false; + } + + if (StickerUrl.isValidShareLink(linkUrl)) { + return true; + } + + HttpUrl url = HttpUrl.parse(linkUrl); + return url != null && + !TextUtils.isEmpty(url.scheme()) && + "https".equals(url.scheme()) && + isLegalUrl(linkUrl, false); + } + + /** + * @return True if URL is valid, mostly useful for linkifying. + */ + public static boolean isLegalUrl(@NonNull String url) { + return isLegalUrl(url, true); + } + + private static boolean isLegalUrl(@NonNull String url, boolean skipTopLevelDomainValidation) { + if (ILLEGAL_CHARACTERS_PATTERN.matcher(url).find()) { + return false; + } + + Matcher matcher = DOMAIN_PATTERN.matcher(url); + + if (matcher.matches()) { + String domain = Objects.requireNonNull(matcher.group(2)); + String cleanedDomain = domain.replaceAll("\\.", ""); + String topLevelDomain = parseTopLevelDomain(domain); + + boolean validCharacters = ALL_ASCII_PATTERN.matcher(cleanedDomain).matches() || + ALL_NON_ASCII_PATTERN.matcher(cleanedDomain).matches(); + + boolean validTopLevelDomain = skipTopLevelDomainValidation || !INVALID_TOP_LEVEL_DOMAINS.contains(topLevelDomain); + + return validCharacters && validTopLevelDomain; + } else { + return false; + } + } + + private static @Nullable String parseTopLevelDomain(@NonNull String domain) { + int periodIndex = domain.lastIndexOf("."); + + if (periodIndex >= 0 && periodIndex < domain.length() - 1) { + return domain.substring(periodIndex + 1); + } else { + return null; + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest_isLegal.java b/app/src/test/java/org/thoughtcrime/securesms/util/LinkUtilTest_isLegal.java similarity index 82% rename from app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest_isLegal.java rename to app/src/test/java/org/thoughtcrime/securesms/util/LinkUtilTest_isLegal.java index caf8e8e98c..a229fed2e5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/linkpreview/LinkPreviewUtilTest_isLegal.java +++ b/app/src/test/java/org/thoughtcrime/securesms/util/LinkUtilTest_isLegal.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.linkpreview; +package org.thoughtcrime.securesms.util; import org.junit.Test; import org.junit.runner.RunWith; @@ -10,7 +10,7 @@ import java.util.Collection; import static junit.framework.TestCase.assertEquals; @RunWith(Parameterized.class) -public class LinkPreviewUtilTest_isLegal { +public class LinkUtilTest_isLegal { private final String input; private final boolean output; @@ -24,12 +24,12 @@ public class LinkPreviewUtilTest_isLegal { { "https://foo.google.com/some/path.html", true }, { "кц.рф", true }, { "https://кц.рф/some/path", true }, + { "https://abcdefg.onion", true }, + { "https://abcdefg.i2p", true }, { "http://кц.com", false }, { "кц.com", false }, { "http://asĸ.com", false }, { "http://foo.кц.рф", false }, - { "https://abcdefg.onion", false }, - { "https://abcdefg.i2p", false }, { "кц.рф\u202C", false }, { "кц.рф\u202D", false }, { "кц.рф\u202E", false }, @@ -40,13 +40,13 @@ public class LinkPreviewUtilTest_isLegal { }); } - public LinkPreviewUtilTest_isLegal(String input, boolean output) { + public LinkUtilTest_isLegal(String input, boolean output) { this.input = input; this.output = output; } @Test public void isLegal() { - assertEquals(output, LinkPreviewUtil.isLegalUrl(input)); + assertEquals(output, LinkUtil.isLegalUrl(input)); } }