Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
40 changes: 40 additions & 0 deletions komf-core/src/commonMain/kotlin/snd/komf/util/WebLinkSanitizer.kt
Original file line number Diff line number Diff line change
@@ -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<WebLink>.sanitizeHttpLinks(): List<WebLink> {
return mapNotNull { link ->
val sanitizedUrl = link.url.toValidHttpUrlOrNull() ?: return@mapNotNull null
if (sanitizedUrl == link.url) link else WebLink(link.label, sanitizedUrl)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -461,4 +462,23 @@ class KomgaMediaServerClientAdapter(
)

private fun <T> patchIfNotNull(value: T?) = value?.let { PatchValue.Some(it) } ?: PatchValue.Unset

private fun Collection<WebLink>.sanitizeKomgaLinks(): List<WebLink> {
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) + "…"
}
}