fix(blossom): don't let mirroring corrupt upload progress or fail the upload

Two regressions from the mirror-on-upload change:

1. Progress state stuck. verifyHeader() ends by calling finish() (state=Finished,
   progress=1.0), but mirrorToOtherServers then ran updateState(0.95,
   ServerProcessing) and never restored Finished — so the on-screen progress ended
   pinned at "processing / 95%" and isUploading never cleared. Mirroring is now
   fire-and-forget on account.scope AFTER the upload finishes: it never touches the
   progress state, never delays the post, and can never turn a completed upload into
   a failure.

2. Upload could fail outright. The upload token was BUD-11 server-scoped; some
   servers reject an upload whose auth carries a `server` tag, surfacing as a bare
   "Uploading error:" with no detail. Upload-token replay isn't the threat scoping
   guards against (delete tokens are — those stay scoped), so upload/media tokens
   are no longer scoped. Also: blank exception messages now fall back to the class
   name so the dialog is never empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-18 03:05:37 +00:00
parent 22396cf583
commit 6920dc6e82
@@ -39,8 +39,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -217,13 +219,16 @@ class UploadOrchestrator {
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Scope the token to the target server (BUD-11) so it can't be replayed elsewhere,
// and use a t=media token when optimizing via /media.
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth =
when {
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) }
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt, listOf(serverBaseUrl)) }
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) }
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) }
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }
},
context = context,
useMediaEndpoint = useMedia,
@@ -239,9 +244,18 @@ class UploadOrchestrator {
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Best-effort — a mirror failure never fails the upload the user already completed.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
mirrorToOtherServers(result, serverBaseUrl, account)
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
@@ -252,16 +266,16 @@ class UploadOrchestrator {
error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* after the primary upload is confirmed, so the user's post is never delayed by
* a slow/offline mirror; failures are swallowed per-server. Requires the blob's
* sha256 (to scope the mirror auth and let server B verify the download).
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
@@ -283,10 +297,9 @@ class UploadOrchestrator {
if (targets.isEmpty()) return
updateState(0.95, UploadingState.ServerProcessing)
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash", listOf(target)).toAuthorizationHeader()
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(sourceUrl, target, auth)
} catch (e: Exception) {