diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 5728bab2e0..193a275694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType +import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required @@ -202,6 +203,32 @@ class UploadOrchestrator { } } + /** + * Tries [selected], then the rest of the servers the picker offered (see + * [blossomUploadOrder]) until one stores the blob. A failure that no other server would + * fix (a read-only login) stops at once. When every server fails, the error shown is the + * selected server's: that is the one the user chose and will recognize. + */ + private suspend fun uploadBlossomWithFallback( + selected: ServerName, + account: Account, + uploadTo: suspend (serverBaseUrl: String) -> UploadingFinalState, + ): UploadingFinalState { + val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value) + var firstError: UploadingState.Error? = null + for (server in order) { + when (val result = uploadTo(server.baseUrl)) { + is UploadingState.Finished -> return result + is UploadingState.Error -> { + if (firstError == null) firstError = result + if (result.errorResource == Res.string.login_with_a_private_key_to_be_able_to_upload) return result + Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server") + } + } + } + return firstError!!.also { updateState(0.0, it) } + } + private suspend fun uploadBlossom( fileUri: Uri, contentType: String?, @@ -482,7 +509,10 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl -> + uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context) + } } } finally { deleteTempUri(finalUri, uri) @@ -527,7 +557,12 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + // The same encrypted file goes to every server tried, so its key, nonce and + // hash stay valid whichever one ends up holding it. + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl -> + uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + } } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt new file mode 100644 index 0000000000..fd16921495 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.mediaServers + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl + +/** + * The Blossom servers an upload tries, in order: the one the user picked, then the rest + * of the list the picker offered them (their kind-10063 servers, or the defaults when + * they have none), top down. + * + * Servers differ in what they accept, and nothing tells the user in advance: primal, + * azzamo and blossom.band answer an encrypted (application/octet-stream) blob with + * 415, some demand payment, some are simply down. With a single target, picking the + * wrong one meant a failed upload and a retry by hand; the settings screen already told + * users uploads "try each server from the top down". + * + * Blossom only: a NIP-96 or NIP-95 upload keeps its single target, because falling back + * from one would change the kind of event the upload produces. + */ +fun blossomUploadOrder( + selected: ServerName, + offered: List, +): List { + if (selected.type != ServerType.Blossom) return listOf(selected) + val seen = mutableSetOf(BlossomServerUrl.domain(selected.baseUrl)) + val rest = offered.filter { it.type == ServerType.Blossom && seen.add(BlossomServerUrl.domain(it.baseUrl)) } + return listOf(selected) + rest +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt new file mode 100644 index 0000000000..9aeaee304e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.mediaServers + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UploadFallbackTest { + private val band = ServerName("Nostr.Build", "https://blossom.band/") + private val primal = ServerName("Primal", "https://blossom.primal.net/") + private val yaki = ServerName("YakiHonne", "https://blossom.yakihonne.com/") + + @Test + fun `the picked server goes first and the rest follow in list order`() { + assertEquals(listOf(primal, band, yaki), blossomUploadOrder(primal, listOf(band, primal, yaki))) + } + + @Test + fun `a server is never tried twice, even under another spelling`() { + val primalAgain = ServerName("primal", "https://BLOSSOM.PRIMAL.NET") + assertEquals(listOf(primal, band), blossomUploadOrder(primal, listOf(primalAgain, band, band))) + } + + @Test + fun `a server picked from outside the list is still first`() { + val custom = ServerName("mine", "https://blobs.example.com") + assertEquals(listOf(custom, band), blossomUploadOrder(custom, listOf(band))) + } + + @Test + fun `non-Blossom uploads do not fall back`() { + val nip96 = ServerName("nostr.build", "https://nostr.build", ServerType.NIP96) + assertEquals(listOf(nip96), blossomUploadOrder(nip96, listOf(band, nip96))) + // Nor does a Blossom upload fall back onto a NIP-96 server. + assertEquals(listOf(band), blossomUploadOrder(band, listOf(nip96))) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 9f28a12765..c8b47520d9 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -24,12 +24,15 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.Reencode import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.File +import kotlin.coroutines.cancellation.CancellationException data class UploadResult( val blossom: BlossomUploadResult, @@ -76,6 +79,8 @@ class UploadOrchestrator( quality: CompressionQuality? = null, bypassReencode: Boolean = false, preCompressed: File? = null, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): UploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -133,12 +138,14 @@ class UploadOrchestrator( // 5. Upload. val result = - client.upload( - file = finalFile, - contentType = metadata.mimeType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + file = finalFile, + contentType = metadata.mimeType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return UploadResult(blossom = result, metadata = metadata) } finally { @@ -171,6 +178,8 @@ class UploadOrchestrator( // When true it's uploaded with the real media type — less private, but // required by strict Blossom servers that reject octet-stream (HTTP 415). declareRealMimeType: Boolean = false, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): EncryptedUploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -230,13 +239,17 @@ class UploadOrchestrator( // 415) also work, at the cost of leaking the media category. val uploadContentType = if (declareRealMimeType) metadata.mimeType else "application/octet-stream" + // The same ciphertext goes to every server tried, so the cipher and the + // encrypted hash stay valid wherever it lands. val result = - client.upload( - bytes = encrypted, - contentType = uploadContentType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + bytes = encrypted, + contentType = uploadContentType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return EncryptedUploadResult( blossom = result, @@ -251,4 +264,31 @@ class UploadOrchestrator( } } } + + /** + * [serverBaseUrl] first, then each of [fallbacks] (skipping duplicates by domain) until + * one accepts. Servers differ in what they take -- several answer an opaque encrypted + * blob with 415, paid ones with 402 -- and the user can't see that in advance. The auth + * header is not server-scoped, so the same one serves every attempt. When all fail, the + * first server's error is thrown: that is the server the user chose. + */ + private suspend fun uploadToFirstAccepting( + serverBaseUrl: String, + fallbacks: List, + upload: suspend (String) -> BlossomUploadResult, + ): BlossomUploadResult { + val seen = mutableSetOf() + val order = (listOf(serverBaseUrl) + fallbacks).filter { seen.add(BlossomServerUrl.domain(it)) } + var firstError: Exception? = null + for (server in order) { + try { + return upload(server) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (firstError == null) firstError = e + Log.w("UploadOrchestrator") { "Upload to $server failed (${e.message}), trying the next server" } + } + } + throw firstError!! + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index fe7a29c773..c44212493f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -336,6 +336,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -350,6 +351,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -361,6 +363,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -371,6 +374,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c34aea979b..cd139fe605 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -1410,7 +1410,7 @@ private suspend fun sendEncryptedFiles( // unsupported file can't abort the whole send. val fileQuality = if (file.extension.lowercase() in IMAGE_EXTENSIONS) quality else null val result = - orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType) + orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) val url = result.blossom.url ?: continue val template = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 317b36f1c4..44cb45d946 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -172,7 +172,8 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + val blossomServers = LocalBlossomServers.current + val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, @@ -182,7 +183,7 @@ fun EditProfileDialog( scope.launch(Dispatchers.IO) { setUploading(true) try { - val result = orchestrator.upload(file, null, serverBaseUrl, account.signer) + val result = orchestrator.upload(file, null, serverBaseUrl, account.signer, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) result.blossom.url?.let { onUrl(it) } } catch (e: CancellationException) { throw e diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt index 04ba37a589..326b036f98 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -222,4 +223,80 @@ class UploadOrchestratorTest { file.delete() } } + + @Test + fun encryptedUploadFallsBackToTheNextServerWithTheSameCiphertext() = + runTest { + // A server that refuses opaque blobs (415) must not fail the upload while + // another listed server would take it -- and the retry has to send the very + // bytes the cipher and hash describe. + val mockClient = mockk() + val servers = mutableListOf() + val bodies = mutableListOf() + coEvery { + mockClient.upload(bytes = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { + val server = arg(2) + servers += server + bodies += arg(0) + if (server.contains("primal")) { + throw IllegalStateException("Unsupported Media Type") + } + BlossomUploadResult(url = "$server/blob") + } + + val file = File.createTempFile("test_", ".bin") + file.deleteOnExit() + file.writeBytes(ByteArray(64) { it.toByte() }) + val mockSigner = mockk(relaxed = true) + + try { + val result = + UploadOrchestrator(mockClient).uploadEncrypted( + file = file, + cipher = AESGCM(), + serverBaseUrl = "https://blossom.primal.net", + signer = mockSigner, + fallbackServerBaseUrls = listOf("https://BLOSSOM.PRIMAL.NET/", "https://nostr.download"), + ) + + assertEquals(listOf("https://blossom.primal.net", "https://nostr.download"), servers) + assertTrue(bodies[0].contentEquals(bodies[1])) + assertEquals("https://nostr.download/blob", result.blossom.url) + } finally { + file.delete() + } + } + + @Test + fun whenEveryServerFailsTheSelectedServersErrorIsThrown() = + runTest { + val mockClient = mockk() + coEvery { + mockClient.upload(file = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { throw IllegalStateException("refused by ${arg(2)}") } + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("content") + val mockSigner = mockk(relaxed = true) + + try { + val error = + kotlin + .runCatching { + UploadOrchestrator(mockClient).upload( + file = file, + alt = null, + serverBaseUrl = "https://a.example", + signer = mockSigner, + stripExif = false, + fallbackServerBaseUrls = listOf("https://b.example"), + ) + }.exceptionOrNull() + assertEquals("refused by https://a.example", error?.message) + } finally { + file.delete() + } + } }