diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt index 4c0221dbd..6946dca48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt @@ -126,7 +126,9 @@ class HlsUploadPipeline( // Blossom servers typically return bare-hash URLs (https://server/), but HLS parsers // and ExoPlayer's Util.inferContentType sniff the URL extension to pick the right source - // factory. Append a hint unless the upload server already baked one in. + // factory. Append a hint unless the upload server already baked one in. Trailing dots are + // trimmed and any pre-existing doubled extension (e.g. "..m3u8") collapsed, so a server that + // echoes our empty-extension upload filename does not produce unreachable URLs. private fun withExtensionHint( url: String, contentType: String, @@ -137,7 +139,11 @@ class HlsUploadPipeline( CONTENT_TYPE_HLS -> ".m3u8" else -> return url } - return if (url.endsWith(ext, ignoreCase = true)) url else url + ext + val sanitised = + url + .replace(Regex("""\.{2,}""" + Regex.escape(ext.trimStart('.')) + "$", RegexOption.IGNORE_CASE), ext) + .trimEnd('.') + return if (sanitised.endsWith(ext, ignoreCase = true)) sanitised else sanitised + ext } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 5b4317988..2950a3fc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -144,7 +144,10 @@ class Nip96Uploader { checkNotInMainThread() val fileName = RandomInstance.randomChars(16) - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val extension = + contentType?.let { + MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: fallbackExtensionForMimeType(it) + } ?: "" val client = okHttpClient(server.apiUrl) val requestBuilder = Request.Builder() @@ -231,6 +234,18 @@ class Nip96Uploader { fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + // Android's MimeTypeMap does not know every MIME we upload (notably HLS playlist types). + // When it returns null we fall back to a small static table so the multipart filename still + // carries a real extension — otherwise the server gets "name." and echoes it back, which + // breaks HLS URL rewriting. + private fun fallbackExtensionForMimeType(mimeType: String): String? = + when (mimeType.lowercase()) { + "application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/x-mpegurl", "audio/mpegurl" -> "m3u8" + "video/mp2t" -> "ts" + "video/iso.segment", "video/mp4" -> "mp4" + else -> null + } + fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult { // Images don't seem to be ready immediately after upload val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt index 06a6a9951..346abb454 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt @@ -212,6 +212,32 @@ class HlsUploadPipelineTest { assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) } + @Test + fun trailingDotUrlsAreSanitisedIntoCleanExtension() { + val bundle = createBundle(listOf("360p")) + val uploader = + object : HlsBlobUploader { + var count = 0 + + override suspend fun upload( + file: File, + contentType: String, + ): MediaUploadResult { + count++ + return MediaUploadResult(url = "https://blossom.test/bare-$count.", sha256 = "sha-$count", size = file.length()) + } + } + val pipeline = HlsUploadPipeline(uploader) + + val result = runBlocking { pipeline.upload(bundle) } + + assertTrue("masterUrl must not contain '..': ${result.masterUrl}", !result.masterUrl.contains("..")) + assertTrue("masterUrl must not end with '.': ${result.masterUrl}", !result.masterUrl.endsWith(".")) + assertTrue(result.masterUrl.endsWith(".m3u8")) + assertTrue(!result.renditions[0].combinedUrl.contains("..")) + assertTrue(result.renditions[0].combinedUrl.endsWith(".mp4")) + } + @Test fun doesNotDoubleAppendExtensionWhenAlreadyPresent() { val bundle = createBundle(listOf("360p"))