diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java index 2c148649d..8df4fac58 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java @@ -18,6 +18,8 @@ import static it.niedermann.android.markdown.MarkdownUtil.removeMarkdown; import static it.niedermann.android.markdown.MarkdownUtil.replaceCheckboxesWithEmojis; +import java.util.regex.Pattern; + /** * Provides basic functionality for Note operations. */ @@ -69,18 +71,49 @@ private static String truncateString(@NonNull String str, @SuppressWarnings("Sam */ @NonNull public static String generateNoteExcerpt(@NonNull String content, @Nullable String title) { - content = removeMarkdown(replaceCheckboxesWithEmojis(content.trim())); - if (TextUtils.isEmpty(content)) { + final var trimmedContent = content.trim(); + + if (isHtml(trimmedContent)) { + return sanitizeHtml(trimmedContent); + } + + final var emojiReplacedWithCheckBoxesContent = replaceCheckboxesWithEmojis(trimmedContent); + var result = removeMarkdown(emojiReplacedWithCheckBoxesContent); + if (TextUtils.isEmpty(result)) { return ""; } + if (!TextUtils.isEmpty(title)) { - assert title != null; final String trimmedTitle = removeMarkdown(replaceCheckboxesWithEmojis(title.trim())); - if (content.startsWith(trimmedTitle)) { - content = content.substring(trimmedTitle.length()); + if (result.startsWith(trimmedTitle)) { + result = result.substring(trimmedTitle.length()); } } - return truncateString(content.trim(), 200).replace("\n", EXCERPT_LINE_SEPARATOR); + + return truncateString(result.trim(), 200).replace("\n", EXCERPT_LINE_SEPARATOR); + } + + private static final Pattern HTML_PATTERN = Pattern.compile( + "(?is)<(?:!DOCTYPE|/?(?:[a-z][a-z0-9]*))[^>]*>" + ); + + private static boolean isHtml(String content) { + if (content == null || content.isEmpty()) { + return false; + } + + return HTML_PATTERN.matcher(content).find(); + } + + private static String sanitizeHtml(String html) { + // Remove script tags and their content + String sanitized = html.replaceAll("(?is)]*>.*?", ""); + + // Remove event handlers (onclick, onerror, onload, etc.) + sanitized = sanitized.replaceAll("(?i)\\s+on\\w+\\s*=\\s*['\"][^'\"]*['\"]", ""); + sanitized = sanitized.replaceAll("(?i)\\s+on\\w+\\s*=\\s*[^\\s>]+", ""); + + return sanitized.trim(); } @NonNull diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.java b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.java deleted file mode 100644 index 24d2a3307..000000000 --- a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Nextcloud Notes - Android Client - * - * SPDX-FileCopyrightText: 2015-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package it.niedermann.owncloud.notes.shared.util; - -import android.os.Build; - -import androidx.core.text.HtmlCompat; - -import junit.framework.TestCase; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -import it.niedermann.android.markdown.MarkdownUtil; - -/** - * Tests the NoteUtil. - */ -@RunWith(RobolectricTestRunner.class) -public class NoteUtilTest extends TestCase { - - @Test - public void testIsEmptyLine() { - assertTrue(NoteUtil.isEmptyLine(" ")); - assertTrue(NoteUtil.isEmptyLine("\n")); - assertTrue(NoteUtil.isEmptyLine("\n ")); - assertTrue(NoteUtil.isEmptyLine(" \n")); - assertTrue(NoteUtil.isEmptyLine(" \n ")); - assertFalse(NoteUtil.isEmptyLine("a \n ")); - } - - @Test - public void testGetLineWithoutMarkdown() { - assertEquals("Test", NoteUtil.getLineWithoutMarkdown("Test", 0)); - assertEquals("Test", NoteUtil.getLineWithoutMarkdown("\nTest", 0)); - assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 0)); - assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 1)); - assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("* Foo\n* Bar", 0)); - assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("- Foo\nBar", 1)); - assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("# Foo", 0)); - } - - @Test - public void testGenerateNoteTitle() { - assertEquals("Test", NoteUtil.generateNoteTitle("Test")); - assertEquals("Test", NoteUtil.generateNoteTitle("Test\n")); - assertEquals("Test", NoteUtil.generateNoteTitle("Test\nFoo")); - assertEquals("Test", NoteUtil.generateNoteTitle("\nTest")); - assertEquals("Test", NoteUtil.generateNoteTitle("\n\nTest")); - - // https://github.com/nextcloud/notes-android/issues/1104 - assertEquals("2021-03-24 - Example title", MarkdownUtil.removeMarkdown("2021-03-24 - Example title")); - } - - @Test - public void testGenerateNoteExcerpt() { - // title is different from content → return max. 200 characters starting with the first line which is not empty - assertEquals("Test", NoteUtil.generateNoteExcerpt("Test", "Title")); - assertEquals("Test Foo", NoteUtil.generateNoteExcerpt("Test\nFoo", "Title")); - assertEquals("Test Foo Bar", NoteUtil.generateNoteExcerpt("Test\nFoo\nBar", "Title")); - assertEquals("", NoteUtil.generateNoteExcerpt("", "Title")); - - // content actually starts with title → return max. 200 characters starting with the first character after the title - assertEquals("", NoteUtil.generateNoteExcerpt("Title", "Title")); - assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "Title")); - assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "Title")); - assertEquals("", NoteUtil.generateNoteExcerpt("", "Title")); - - // some empty lines between the actual contents → Should be ignored - assertEquals("", NoteUtil.generateNoteExcerpt("\nTitle", "Title")); - assertEquals("Foo", NoteUtil.generateNoteExcerpt("\n\n\n\nTitle\nFoo", "Title")); - assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("\nTitle\n\n\nTitle\nBar", "\n\nTitle")); - assertEquals("", NoteUtil.generateNoteExcerpt("\n\n\n", "\nTitle")); - - // content has markdown while titles markdown is already stripped - assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "Title")); - assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\n- Foo", "Title")); - - // title has markdown while contents markdown is stripped - assertEquals("", NoteUtil.generateNoteExcerpt("Title", "# Title")); - assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "- Title")); - assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "- Title")); - - // content and title have markdown - assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "# Title")); - assertEquals("Foo", NoteUtil.generateNoteExcerpt("# Title\n- Foo", "- Title")); - assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("- Title\nTitle\nBar", "- Title")); - } - - /** - * Has known issues on {@link Build.VERSION_CODES#LOLLIPOP_MR1} and - * {@link Build.VERSION_CODES#M} due to incompatibilities of - * {@link HtmlCompat#fromHtml(String, int)} - */ - @Test - @Config(sdk = {30}) - public void testGenerateNoteExcerpt_sdk_30() { - // content has markdown while titles markdown is already stripped - assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("# Title\n- Title\n- Bar", "Title")); - } -} diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt new file mode 100644 index 000000000..bc6b87180 --- /dev/null +++ b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt @@ -0,0 +1,267 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2015-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.shared.util + +import android.os.Build +import androidx.core.text.HtmlCompat +import it.niedermann.android.markdown.MarkdownUtil +import junit.framework.TestCase +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests the NoteUtil. + */ +@RunWith(RobolectricTestRunner::class) +class NoteUtilTest : TestCase() { + + @Test + fun testIsEmptyLine() { + assertTrue(NoteUtil.isEmptyLine(" ")) + assertTrue(NoteUtil.isEmptyLine("\n")) + assertTrue(NoteUtil.isEmptyLine("\n ")) + assertTrue(NoteUtil.isEmptyLine(" \n")) + assertTrue(NoteUtil.isEmptyLine(" \n ")) + assertFalse(NoteUtil.isEmptyLine("a \n ")) + } + + @Test + fun testGetLineWithoutMarkdown() { + assertEquals("Test", NoteUtil.getLineWithoutMarkdown("Test", 0)) + assertEquals("Test", NoteUtil.getLineWithoutMarkdown("\nTest", 0)) + assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 0)) + assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("Foo\nBar", 1)) + assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("* Foo\n* Bar", 0)) + assertEquals("Bar", NoteUtil.getLineWithoutMarkdown("- Foo\nBar", 1)) + assertEquals("Foo", NoteUtil.getLineWithoutMarkdown("# Foo", 0)) + } + + @Test + fun testGenerateNoteTitle() { + assertEquals("Test", NoteUtil.generateNoteTitle("Test")) + assertEquals("Test", NoteUtil.generateNoteTitle("Test\n")) + assertEquals("Test", NoteUtil.generateNoteTitle("Test\nFoo")) + assertEquals("Test", NoteUtil.generateNoteTitle("\nTest")) + assertEquals("Test", NoteUtil.generateNoteTitle("\n\nTest")) + + // https://github.com/nextcloud/notes-android/issues/1104 + assertEquals("2021-03-24 - Example title", MarkdownUtil.removeMarkdown("2021-03-24 - Example title")) + } + + @Test + fun testGenerateNoteExcerpt() { + testBasicExcerpts() + testTitleMatchingExcerpts() + testEmptyLineHandling() + testMarkdownInContent() + testMarkdownInTitle() + testMarkdownInBoth() + testHtmlSanitization() + testEdgeCases() + testRealisticUserNotes() + } + + private fun testBasicExcerpts() { + // title is different from content → return max. 200 characters starting with the first line which is not empty + assertEquals("Test", NoteUtil.generateNoteExcerpt("Test", "Title")) + assertEquals("Test Foo", NoteUtil.generateNoteExcerpt("Test\nFoo", "Title")) + assertEquals("Test Foo Bar", NoteUtil.generateNoteExcerpt("Test\nFoo\nBar", "Title")) + assertEquals("", NoteUtil.generateNoteExcerpt("", "Title")) + } + + private fun testTitleMatchingExcerpts() { + // content actually starts with title → return max. 200 characters starting with the first character after the title + assertEquals("", NoteUtil.generateNoteExcerpt("Title", "Title")) + assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "Title")) + assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "Title")) + assertEquals("", NoteUtil.generateNoteExcerpt("", "Title")) + } + + private fun testEmptyLineHandling() { + // some empty lines between the actual contents → Should be ignored + assertEquals("", NoteUtil.generateNoteExcerpt("\nTitle", "Title")) + assertEquals("Foo", NoteUtil.generateNoteExcerpt("\n\n\n\nTitle\nFoo", "Title")) + assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("\nTitle\n\n\nTitle\nBar", "\n\nTitle")) + assertEquals("", NoteUtil.generateNoteExcerpt("\n\n\n", "\nTitle")) + } + + private fun testMarkdownInContent() { + // content has markdown while titles, markdown is already stripped + assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "Title")) + assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\n- Foo", "Title")) + } + + private fun testMarkdownInTitle() { + // title has markdown while contents markdown is stripped + assertEquals("", NoteUtil.generateNoteExcerpt("Title", "# Title")) + assertEquals("Foo", NoteUtil.generateNoteExcerpt("Title\nFoo", "- Title")) + assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("Title\nTitle\nBar", "- Title")) + } + + private fun testMarkdownInBoth() { + // content and title have markdown + assertEquals("", NoteUtil.generateNoteExcerpt("# Title", "# Title")) + assertEquals("Foo", NoteUtil.generateNoteExcerpt("# Title\n- Foo", "- Title")) + assertEquals("Title Bar", NoteUtil.generateNoteExcerpt("- Title\nTitle\nBar", "- Title")) + } + + private fun testHtmlSanitization() { + val html = """ + click + + + """.trimIndent() + + val expectedHtml = "click\n" + assertEquals(expectedHtml, NoteUtil.generateNoteExcerpt(html, "Title")) + assertEquals(expectedHtml, NoteUtil.generateNoteExcerpt(html, null)) + + val scriptHtml = "title" + val scriptExcerpt = NoteUtil.generateNoteExcerpt(scriptHtml, "Test") + assertFalse("Excerpts should never contain executable scripts", scriptExcerpt.contains("