From c2565e81d5b6560a0411147304dcb049f88cb019 Mon Sep 17 00:00:00 2001 From: gregoryn22 Date: Wed, 28 Jan 2026 16:54:40 -0500 Subject: [PATCH 1/2] Fix URL formatting in MangaDexMetadataMapper.kt --- .../snd/komf/providers/mangadex/MangaDexMetadataMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataMapper.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataMapper.kt index dce77434..d4b9ddb7 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataMapper.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataMapper.kt @@ -172,7 +172,7 @@ class MangaDexMetadataMapper( "amz" -> parseUrl(value)?.let { url -> links[AMAZON] = WebLink("Amazon", url.toStingEncoded()) } "ebj" -> { val url = if (value.toIntOrNull() != null) { - "https://ebookjapan.yahoo.co.jp/books/${value}}" + "https://ebookjapan.yahoo.co.jp/books/${value}" } else { parseUrl(value)?.toString() } From 6e6ac7ae4b9cd186233c3dcebc907e1ee8bcd01a Mon Sep 17 00:00:00 2001 From: gregoryn22 Date: Wed, 28 Jan 2026 16:55:00 -0500 Subject: [PATCH 2/2] Add WebLinkSanitizer for URL validation and sanitization - Introduced `WebLinkSanitizer.kt` with functions to validate and sanitize HTTP/HTTPS URLs. - Added `toValidHttpUrlOrNull` for URL parsing and normalization. - Implemented `sanitizeHttpLinks` to filter and normalize a list of `WebLink` entries. - Created unit tests in `WebLinkSanitizerTest.kt` to ensure functionality and correctness of URL handling. --- .../kotlin/snd/komf/util/WebLinkSanitizer.kt | 40 ++++++++++++++++ .../snd/komf/util/WebLinkSanitizerTest.kt | 48 +++++++++++++++++++ .../komga/KomgaMediaServerClientAdapter.kt | 24 +++++++++- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/util/WebLinkSanitizer.kt create mode 100644 komf-core/src/commonTest/kotlin/snd/komf/util/WebLinkSanitizerTest.kt diff --git a/komf-core/src/commonMain/kotlin/snd/komf/util/WebLinkSanitizer.kt b/komf-core/src/commonMain/kotlin/snd/komf/util/WebLinkSanitizer.kt new file mode 100644 index 00000000..b93a09c0 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/util/WebLinkSanitizer.kt @@ -0,0 +1,40 @@ +package snd.komf.util + +import io.ktor.http.parseUrl +import snd.komf.model.WebLink + +/** + * Parse and validate a URL string for use as a web link. + * + * Policy: + * - Only allow http/https URLs. + * - Require a non-blank host. + * - Return a normalized, properly-encoded URL string (see [toStingEncoded]). + */ +fun String.toValidHttpUrlOrNull(): String? { + val raw = trim() + if (raw.isBlank()) return null + + // Guard against empty authority like "https:///path" which some parsers may interpret oddly. + val lower = raw.lowercase() + if (lower.startsWith("http:///") || lower.startsWith("https:///")) return null + + val url = parseUrl(raw) ?: return null + val scheme = url.protocol.name.lowercase() + if (scheme != "http" && scheme != "https") return null + if (url.host.isBlank()) return null + + return url.toStingEncoded() +} + +/** + * Filter out invalid/unsupported links and normalize URLs for valid entries. + * + * This helper is intentionally logging-free so callers can decide how/where to report drops. + */ +fun Iterable.sanitizeHttpLinks(): List { + return mapNotNull { link -> + val sanitizedUrl = link.url.toValidHttpUrlOrNull() ?: return@mapNotNull null + if (sanitizedUrl == link.url) link else WebLink(link.label, sanitizedUrl) + } +} diff --git a/komf-core/src/commonTest/kotlin/snd/komf/util/WebLinkSanitizerTest.kt b/komf-core/src/commonTest/kotlin/snd/komf/util/WebLinkSanitizerTest.kt new file mode 100644 index 00000000..feadff87 --- /dev/null +++ b/komf-core/src/commonTest/kotlin/snd/komf/util/WebLinkSanitizerTest.kt @@ -0,0 +1,48 @@ +package snd.komf.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import snd.komf.model.WebLink + +class WebLinkSanitizerTest { + + @Test + fun toValidHttpUrlOrNull_returnsEncodedUrl() { + val sanitized = "https://example.com/a b?x=y".toValidHttpUrlOrNull() + assertEquals("https://example.com/a%20b?x=y", sanitized) + } + + @Test + fun toValidHttpUrlOrNull_trimsWhitespace() { + val sanitized = " https://example.com ".toValidHttpUrlOrNull() + assertEquals("https://example.com", sanitized) + } + + @Test + fun toValidHttpUrlOrNull_rejectsInvalidScheme() { + val sanitized = "htttps://www.alpha-manga.com/manga/121000501".toValidHttpUrlOrNull() + assertNull(sanitized) + } + + @Test + fun toValidHttpUrlOrNull_rejectsMissingHost() { + val sanitized = "https:///path".toValidHttpUrlOrNull() + assertNull(sanitized) + } + + @Test + fun sanitizeHttpLinks_filtersAndNormalizes() { + val links = listOf( + WebLink("Good", "https://example.com/a b"), + WebLink("Bad", "htttps://example.com"), + ) + + val sanitized = links.sanitizeHttpLinks() + assertEquals(1, sanitized.size) + assertEquals("Good", sanitized.first().label) + assertNotNull(sanitized.first().url) + assertEquals("https://example.com/a%20b", sanitized.first().url) + } +} diff --git a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/komga/KomgaMediaServerClientAdapter.kt b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/komga/KomgaMediaServerClientAdapter.kt index 7af0b62c..f9d93da1 100644 --- a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/komga/KomgaMediaServerClientAdapter.kt +++ b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/komga/KomgaMediaServerClientAdapter.kt @@ -23,6 +23,7 @@ import snd.komf.model.ReadingDirection import snd.komf.model.SeriesStatus import snd.komf.model.TitleType import snd.komf.model.WebLink +import snd.komf.util.toValidHttpUrlOrNull import snd.komga.client.book.KomgaBook import snd.komga.client.book.KomgaBookClient import snd.komga.client.book.KomgaBookId @@ -399,7 +400,7 @@ class KomgaMediaServerClientAdapter( genres = patchIfNotNull(genres), tags = patchIfNotNull(tags), totalBookCount = patchIfNotNull(totalBookCount), - links = patchIfNotNull(links?.map { KomgaWebLink(it.label, it.url) }), + links = patchIfNotNull(links?.sanitizeKomgaLinks()?.map { KomgaWebLink(it.label, it.url) }), statusLock = patchIfNotNull(statusLock), titleLock = patchIfNotNull(titleLock), @@ -447,7 +448,7 @@ class KomgaMediaServerClientAdapter( authors = patchIfNotNull(authors?.map { KomgaAuthor(it.name, it.role) }), tags = patchIfNotNull(tags), isbn = patchIfNotNull(isbn), - links = patchIfNotNull(links?.map { KomgaWebLink(it.label, it.url) }), + links = patchIfNotNull(links?.sanitizeKomgaLinks()?.map { KomgaWebLink(it.label, it.url) }), titleLock = patchIfNotNull(titleLock), summaryLock = patchIfNotNull(summaryLock), @@ -461,4 +462,23 @@ class KomgaMediaServerClientAdapter( ) private fun patchIfNotNull(value: T?) = value?.let { PatchValue.Some(it) } ?: PatchValue.Unset + + private fun Collection.sanitizeKomgaLinks(): List { + return mapNotNull { link -> + val sanitizedUrl = link.url.toValidHttpUrlOrNull() + if (sanitizedUrl == null) { + logger.warn { "Dropping invalid Komga link label='${link.label}' url='${truncate(link.url)}' (only http/https supported)" } + null + } else if (sanitizedUrl == link.url) { + link + } else { + WebLink(link.label, sanitizedUrl) + } + } + } + + private fun truncate(value: String, maxChars: Int = 200): String { + if (value.length <= maxChars) return value + return value.take(maxChars) + "…" + } } \ No newline at end of file