From f4785d49243b3ccf687503cd013582b1749a7e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:05:38 +0000 Subject: [PATCH 1/6] fix: render NIP-34 status events instead of a blank note Kinds 1630-1633 (open/applied/closed/draft) reach the notifications tab through the p-tag filter but had no branch in RenderNoteRow, so they fell through to the text renderer. Their content is usually empty and they are not BaseThreadedEvents, so the card came out blank. Draw them as the status pill, the optional comment, and the targeted issue/patch/PR (already linked as replyTo via the marked-root e tag) quoted below. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PicvdWRJ33zh4MCfjZj2kQ --- .../commons/nip34Git/ui/GitStatusPill.kt | 2 +- .../amethyst/commons/ui/note/NoteCompose.kt | 6 ++ .../commons/ui/note/types/GitStatus.kt | 69 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusPill.kt index fafde36324..2de995c8d9 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusPill.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusPill.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent -private fun GitStatusEvent.statusKind(): StatusKind = +fun GitStatusEvent.statusKind(): StatusKind = when (this) { is GitStatusAppliedEvent -> StatusKind.APPLIED is GitStatusClosedEvent -> StatusKind.CLOSED diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt index b15ce3709b..682ee75b5a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt @@ -194,6 +194,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFundraiser import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocache import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocacheFoundLog +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGitStatusEvent import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGoal import com.vitorpamplona.amethyst.commons.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.commons.ui.note.types.RenderInteractiveStory @@ -362,6 +363,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -1393,6 +1395,10 @@ private fun RenderNoteRow( ) } + is GitStatusEvent -> { + RenderGitStatusEvent(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + is EncryptedDmEvent -> { RenderPrivateMessage( baseNote, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt new file mode 100644 index 0000000000..d4245938c4 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt @@ -0,0 +1,69 @@ +/* + * 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.ui.note.types + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.nip34Git.ui.statusKind +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.GitStatusPill +import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent + +/** + * Renders a NIP-34 status event (kinds 1630-1633). These usually carry an + * empty `content`, so the text fallback drew a blank note: show the new + * status as a pill, the optional comment, and the issue/patch/PR it + * targets (linked through the event's marked-`root` `e` tag) quoted below. + */ +@Composable +fun RenderGitStatusEvent( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event as? GitStatusEvent ?: return + val kind = remember(event) { event.statusKind() } + + GitStatusPill(kind) + + if (event.content.isNotBlank()) { + Spacer(modifier = StdVertSpacer) + Text( + text = event.content, + style = MaterialTheme.typography.bodyMedium, + ) + } + + if (note.replyTo?.lastOrNull() != null) { + Spacer(modifier = StdVertSpacer) + RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) + } +} From 0e3cfb351aca6c91a2a5394051ed5282daa38527 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:24:01 +0000 Subject: [PATCH 2/6] fix: render NIP-34 status events as the thread's master note Opening a status event (kinds 1630-1633) in the thread view fell through NoteMaster's dispatch to the text renderer, drawing the same blank body the notifications tab did. Route it to RenderGitStatusEvent too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PicvdWRJ33zh4MCfjZj2kQ --- .../amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e22773d3c0..71492e7f99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -186,6 +186,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.commons.ui.note.types.RenderFundraiser import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocache import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGeocacheFoundLog +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGitStatusEvent import com.vitorpamplona.amethyst.commons.ui.note.types.RenderGoal import com.vitorpamplona.amethyst.commons.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.commons.ui.note.types.RenderInteractiveStory @@ -332,6 +333,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -1026,6 +1028,8 @@ private fun FullBleedNoteCompose( RenderGitPullRequestEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is GitPullRequestUpdateEvent) { RenderGitPullRequestUpdateEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) + } else if (noteEvent is GitStatusEvent) { + RenderGitStatusEvent(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) } else if (noteEvent is AppRecommendationEvent) { From c922a0ea65a6c2c5699667cdb4eebae8fb649a93 Mon Sep 17 00:00:00 2001 From: Jameson Lopp Date: Wed, 30 Sep 2026 10:24:26 -0400 Subject: [PATCH 3/6] fix: harden download bridge --- .../loggedIn/browser/EmbeddedPageRequests.kt | 13 + .../browser/EmbeddedWebAppController.kt | 35 +++ .../screen/loggedIn/browser/WebAppScreen.kt | 25 +- .../composeResources/values-es/strings.xml | 3 + .../composeResources/values/strings.xml | 5 + .../browser/ui/pill/DownloadPromptCard.kt | 128 ++++++++++ .../commons/browser/ui/pill/PageSheets.kt | 2 +- .../amethyst/napplethost/BrowserChromeHost.kt | 38 +++ .../napplethost/BrowserDownloadGate.kt | 226 ++++++++++++++++++ .../amethyst/napplethost/BrowserDownloads.kt | 192 ++++++++++++--- .../napplethost/BrowserExtrasScript.kt | 8 +- .../napplethost/NappletBrowserActivity.kt | 134 +++++++++-- .../napplethost/NappletBrowserContract.kt | 29 +++ .../napplethost/NappletBrowserService.kt | 132 +++++++++- 14 files changed, 896 insertions(+), 74 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt index 65eedabf6d..dd1e18c379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -40,3 +40,16 @@ data class EmbeddedPermissionRequest( val origin: String, val permissions: Set, ) + +/** + * An inline download (`browser.download`) from an embedded page, waiting for consent before its bytes + * are written into the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and + * exact decoded size the sandbox reports; [origin] is the WebView-reported origin, not a page field. + */ +data class EmbeddedDownloadRequest( + val id: Long, + val origin: String, + val fileName: String, + val sizeBytes: Long, + val risky: Boolean, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 8df5a2000f..44707deeaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -129,6 +129,9 @@ class EmbeddedWebAppController( /** The camera / microphone / location request the page is waiting on, if any. */ val pendingPermission = mutableStateOf(null) + /** The inline download awaiting the user's consent, if any. */ + val pendingDownload = mutableStateOf(null) + /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ val pageCertificate = mutableStateOf(null) @@ -181,6 +184,7 @@ class EmbeddedWebAppController( consoleLogs.clear() pendingDialog.value = null pendingPermission.value = null + pendingDownload.value = null isFullscreen.value = false } @@ -367,6 +371,25 @@ class EmbeddedWebAppController( val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID) if (pendingPermission.value?.id == id) pendingPermission.value = null } + NappletBrowserContract.MSG_DOWNLOAD_CONSENT -> { + val data = msg.data ?: return true + val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN) + val name = data.getString(NappletBrowserContract.KEY_DOWNLOAD_NAME).orEmpty() + // An unnamed/absent origin means the sandbox couldn't even state who is asking: refuse. + if (origin == null || name.isEmpty() || pendingDownload.value != null) { + answerDownload(id, allowed = false) + return true + } + pendingDownload.value = + EmbeddedDownloadRequest( + id = id, + origin = origin, + fileName = name, + sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, 0L), + risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), + ) + } NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true @@ -486,6 +509,18 @@ class EmbeddedWebAppController( } } + /** Answers the download-consent card for [id]: true saves the bytes the sandbox already holds. */ + fun answerDownload( + id: Long, + allowed: Boolean, + ) { + if (pendingDownload.value?.id == id) pendingDownload.value = null + send(NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT) { + putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, allowed) + } + } + fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index d2947e9dfc..16b391755d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi +import com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard @@ -324,8 +325,9 @@ private fun EmbeddedWebAppTab( /** * Everything an embedded page asks the user for, drawn by the main process because the provider has no * window: JS dialogs, camera / microphone / location prompts (remembered per origin in - * [WebSitePermissionRegistry], then Android's own runtime permission), and page info — the shared - * [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet]. + * [WebSitePermissionRegistry], then Android's own runtime permission), inline-download consent, and + * page info — the shared [PageDialogCard], [PermissionPromptCard], [DownloadPromptCard] and + * [PageInfoSheet]. */ @RequiresApi(Build.VERSION_CODES.R) @Composable @@ -408,6 +410,25 @@ private fun EmbeddedPageUi( } } + // A page's inline download: nothing reaches the shared Downloads collection until this is answered. + // The card shows the sanitized file name and exact byte count the sandbox will write, headed by the + // WebView-reported origin (never a page-supplied field), with a warning for installer/script-like + // extensions — the social-engineering payload names the disclosure calls out ("invoice.apk"). + val downloadRequest by controller.pendingDownload + downloadRequest?.let { request -> + Dialog(onDismissRequest = { controller.answerDownload(request.id, allowed = false) }) { + DownloadPromptCard( + host = hostLabel(request.origin), + security = ui.security, + fileName = request.fileName, + sizeBytes = request.sizeBytes, + risky = request.risky, + onAllow = { controller.answerDownload(request.id, allowed = true) }, + onDeny = { controller.answerDownload(request.id, allowed = false) }, + ) + } + } + if (showPageInfo) { val certificate by controller.pageCertificate val origin = browserOrigin(ui.chrome.url) diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index c278f19263..52d054cc83 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -2997,4 +2997,7 @@ No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s. Buscar o introducir dirección NApplet sin título + ¿Descargar este archivo? + Este tipo de archivo puede ejecutar código. Comprueba que el nombre coincide con lo que querías obtener. + Guardar diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 5233867a0e..eb937376ff 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6355,4 +6355,9 @@ Ask Allowed Blocked + + + Download this file? + This file type can run code. Check that the name matches what you meant to get. + Save diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt new file mode 100644 index 0000000000..28b40df411 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt @@ -0,0 +1,128 @@ +/* + * 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.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_risky +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_save +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_title +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.util.DecimalPatternFormatter + +/** + * A page's inline download waiting for consent before its bytes are written to the shared Downloads + * collection. The bridge envelope is forgeable (any top-frame script can post it, no gesture needed), + * so the gate lives here: the card names the exact file the sink would write — the WebView-reported + * origin, never a page-supplied field — and nothing is saved until the user taps Save. + */ +@Composable +fun DownloadPromptCard( + host: String?, + security: BrowserChrome.Security, + fileName: String, + sizeBytes: Long, + risky: Boolean, + onAllow: () -> Unit, + onDeny: () -> Unit, +) { + PageCard { + if (host != null) { + OriginBadge(host, security) + Spacer(Modifier.height(20.dp)) + } + Text( + stringRes(Res.string.browser_pill_download_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.Download, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true) + } + Spacer(Modifier.width(14.dp)) + Column { + Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(formatBytes(sizeBytes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + if (risky) { + Spacer(Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillActionIcon(BrowserChrome.Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onErrorContainer, size = 18.dp) + Spacer(Modifier.width(10.dp)) + Text(stringRes(Res.string.browser_pill_download_risky), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Medium) + } + } + Spacer(Modifier.height(24.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDeny) { Text(stringRes(Res.string.browser_pill_cancel)) } + Spacer(Modifier.width(8.dp)) + Button(onClick = onAllow) { Text(stringRes(Res.string.browser_pill_download_save)) } + } + } +} + +/** Rounded byte count for the consent card ("412 B", "1.2 MB", "25.0 MB"). */ +private fun formatBytes(bytes: Long): String { + if (bytes < 1024L) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "${DecimalPatternFormatter("0.0").format(kb)} KB" + val mb = kb / 1024 + return "${DecimalPatternFormatter("0.0").format(mb)} MB" +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt index 8c28a74bc7..dff2b52fc3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt @@ -137,7 +137,7 @@ fun OriginBadge( /** The card frame every page-initiated prompt shares. */ @Composable -private fun PageCard(content: @Composable () -> Unit) { +internal fun PageCard(content: @Composable () -> Unit) { Surface( shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index a781eb4722..4a13007331 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet +import com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType @@ -130,6 +131,20 @@ class BrowserChromeHost( val answer: (allow: Boolean, remember: Boolean) -> Unit, ) + /** + * An inline download (`browser.download` bridge message) waiting for consent before its bytes are + * written into the shared Downloads collection. [fileName]/[sizeBytes] describe exactly what the + * native sink would write; nothing is saved until [answer] runs with true. + */ + class PendingDownload( + val host: String?, + val security: BrowserChrome.Security, + val fileName: String, + val sizeBytes: Long, + val risky: Boolean, + val answer: (allow: Boolean) -> Unit, + ) + var ui by mutableStateOf(initial) var expanded by mutableStateOf(false) private set @@ -146,6 +161,7 @@ class BrowserChromeHost( var dialog by mutableStateOf(null) var permissionPrompt by mutableStateOf(null) + var downloadPrompt by mutableStateOf(null) private var pageInfoOpen by mutableStateOf(false) private var accessInfo by mutableStateOf(null) private var certificate by mutableStateOf(null) @@ -345,6 +361,28 @@ class BrowserChromeHost( ) } } + downloadPrompt?.let { pending -> + Dialog(onDismissRequest = { + downloadPrompt = null + pending.answer(false) + }) { + DownloadPromptCard( + host = pending.host, + security = pending.security, + fileName = pending.fileName, + sizeBytes = pending.sizeBytes, + risky = pending.risky, + onAllow = { + downloadPrompt = null + pending.answer(true) + }, + onDeny = { + downloadPrompt = null + pending.answer(false) + }, + ) + } + } accessInfo?.let { info -> Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt new file mode 100644 index 0000000000..3ef91c013d --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt @@ -0,0 +1,226 @@ +/* + * 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.napplethost + +import android.os.SystemClock +import android.webkit.URLUtil +import com.vitorpamplona.quartz.utils.Log + +/** + * The ONE consent and gate for every page-initiated native file write into the shared public Downloads + * collection, so both entry paths into [BrowserDownloads] — the `browser.download` bridge envelope and + * the WebView `DownloadListener` — pass through exactly one gate. + * + * Why it lives in the `:napplet` sandbox and not in the injected script: the WebView bridge accepts + * envelopes from any origin the user browses (`addWebMessageListener(..., setOf("*"))`), and + * BrowserWebTools.share's own comment documents pages posting bridge envelopes directly, bypassing the + * injected script's checks. So a hostile top frame can ask for a native file write with no gesture and + * no consent. The gate is keyed on data the page cannot forge — the WebView-reported origin — and it is + * deliberately NOT the broker's per-origin launch token: that token is minted automatically for any + * logged-in origin that asks (NappletBrokerService's MSG_MINT_BROWSER_TOKEN handler shows no prompt), + * so it is a session handle, not a consent grant. + * + * Three layers: + * + * 1. **One-shot native consent** — every page-initiated save shows [com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard] + * first: the exact sanitized name, the true size (decoded base64 length, or the server's Content-Length + * when the page gave a network URL), and the WebView-reported origin. Nothing reaches [BrowserDownloads] + * until the user taps Save. The immediately-user-initiated context-menu "Download image" brushes past + * the prompt (that tap IS the gesture). + * 2. **One live prompt per surface** — a second consent request while one is already live for the same + * surface (this full-screen window, or one embedded tab) is refused ([beginConsent]), so a page can't + * swap a card's name under the user's finger; the callers also answer the displaced card before showing + * a successor. + * 3. **Per-origin cooldown** — after a prompt is answered, the next request from the same origin on the + * same surface can't flash another card for [DOWNLOAD_COOLDOWN_MS] ([shouldPrompt]). + */ +object BrowserDownloadGate { + private const val TAG = "BrowserDownloadGate" + + /** Minimum spacing between consent prompts per origin on one surface, mirroring BrowserWebTools.share. */ + const val DOWNLOAD_COOLDOWN_MS = 1_000L + + /** Extensions an install-or-run prompt exists for. Reported (never rewritten) so the user can judge the real name. */ + val RISKY_EXTENSIONS = + setOf( + "apk", + "apks", + "apkm", + "xapk", + "aab", + "jar", + "dex", + "so", + "exe", + "msi", + "bat", + "cmd", + "com", + "scr", + "hta", + "ps1", + "vbs", + "wsf", + "dmg", + "pkg", + "app", + "deb", + "rpm", + "appimage", + "run", + "html", + "htm", + "xhtml", + "svg", + "sh", + "zsh", + "bash", + "command", + "lnk", + "url", + "desktop", + ) + + /** Everything the consent card needs to show before a single byte is saved. */ + class Spec( + val fileName: String, + val sizeBytes: Long, + val risky: Boolean, + ) + + /** + * One surface's live consent, handed out by [beginConsent] and ended by [endConsent]. Holding it + * is what stops a second prompt for the same surface from displacing the card under the user's + * finger; the page has no way to observe it. Callers must [endConsent] on every answer path. + */ + class Consent internal constructor( + internal val surfaceKey: String, + internal val origin: String, + ) + + /** + * The post-consent save for already-allowed inline bytes. Kept here (and exposed as the save a + * [com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard] allow runs) because exactly + * the same save is shared by the card's allow and by any caller that already holds consented bytes. + */ + class InlineSave internal constructor( + val fileName: String, + val mimeType: String?, + val bytes: ByteArray, + ) { + /** Writes the consented bytes into the shared Downloads collection, exactly as the card named them. */ + fun save(context: android.content.Context) = BrowserDownloads.saveInlineBytes(context, fileName, mimeType, bytes) + } + + /** + * Builds the post-consent inline save for a `browser.download` envelope's data URL: the sanitized + * name and the decoded bytes, so the eventual save is exactly what the card showed. Null when the + * URL is malformed or over [BrowserDownloads.MAX_INLINE_BYTES] — the caller treats null as a refused + * download and shows no prompt. + */ + fun preludeInlineSave( + dataUrl: String, + suggestedName: String?, + ): InlineSave? { + val header = dataUrl.substringBefore(',', "") + if (!dataUrl.contains(',') || !header.startsWith("data:", ignoreCase = true) || !header.endsWith(";base64", ignoreCase = true)) return null + val payload = dataUrl.substringAfter(',', "") + if (payload.length > BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 8) return null + val bytes = runCatching { android.util.Base64.decode(payload, android.util.Base64.DEFAULT) }.getOrNull() ?: return null + if (bytes.size > BrowserDownloads.MAX_INLINE_BYTES) return null + // The MIME inside the data URL drives safeName's fallback extension when the page sent no name. + val mime = + header + .removePrefix("data:") + .removeSuffix(";base64") + .substringBefore(';') + .ifBlank { null } + return InlineSave(BrowserDownloads.sanitize(suggestedName, mime), mime, bytes) + } + + /** What the consent card shows for a network download, before any bytes are fetched. */ + fun networkSpec( + fileName: String, + sizeBytes: Long, + ): Spec = Spec(fileName, sizeBytes.coerceAtLeast(0L), isRisky(fileName)) + + /** Guesses a network download's file name the same way the eventual save does. */ + fun guessNetworkName( + url: String, + contentDisposition: String?, + mimeType: String?, + ): String = BrowserDownloads.sanitize(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType) + + /** + * Whether a consent prompt may be shown for [origin] on [surfaceKey] right now. A `false` return + * means a prompt for this origin on this surface was just answered and this request is silently + * dropped — the throttle that keeps a spamming page from flashing dialogs forever. + */ + fun shouldPrompt( + surfaceKey: String, + origin: String, + ): Boolean { + val now = SystemClock.elapsedRealtime() + val last = lastAnsweredAt[surfaceKey]?.get(origin) ?: 0L + if (now - last < DOWNLOAD_COOLDOWN_MS) { + Log.d(TAG) { "Download consent for $origin throttled" } + return false + } + return true + } + + /** + * Claims the one live consent slot for [surfaceKey], or returns null when one is already live for + * that surface — the anti-swap rule, released by [endConsent]. A `false` [shouldPrompt] caller + * never even gets here (dropped before the slot is taken). + */ + fun beginConsent( + surfaceKey: String, + origin: String, + ): Consent? { + if (liveSurfaces.contains(surfaceKey)) return null + liveSurfaces.add(surfaceKey) + return Consent(surfaceKey, origin) + } + + /** + * Releases [consent]'s slot and starts its per-origin cooldown. Called on every answer path — + * Save, Don't save, dismissal, and the surface being destroyed — so a torn-down window can never + * wedge the gate. Idempotent. + */ + fun endConsent(consent: Consent) { + liveSurfaces.remove(consent.surfaceKey) + lastAnsweredAt.getOrPut(consent.surfaceKey) { HashMap() }[consent.origin] = SystemClock.elapsedRealtime() + } + + /** Drops this surface's throttle and live-slot state — when the window/tab hosting its WebView goes away. */ + fun clearSurface(surfaceKey: String) { + liveSurfaces.remove(surfaceKey) + lastAnsweredAt.remove(surfaceKey) + } + + /** Whether [fileName]'s extension is one a user should double-check before saving. */ + fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").lowercase() in RISKY_EXTENSIONS + + // Main-thread only: one live surface's consent, and per-surface then per-origin last-answered stamps. + private val liveSurfaces = HashSet() + private val lastAnsweredAt = HashMap>() +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt index 6e97344b45..fbfcef6a96 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -34,6 +34,7 @@ import android.widget.Toast import com.vitorpamplona.quartz.utils.Log import okhttp3.Request import java.io.File +import java.io.IOException import java.io.OutputStream import java.net.URLDecoder import java.util.concurrent.Executors @@ -45,6 +46,10 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR * `blob:` URLs (which only the page can read, so the browser-extras script hands their bytes over) — into * the system Downloads collection, the way Chrome does. * + * Page-initiated saves (both the `browser.download` bridge envelope and the WebView `DownloadListener`) + * reach the write sinks here only through [BrowserDownloadGate]'s one-shot consent; the + * immediately-user-initiated "Download image" context-menu action is the one exception. + * * Network downloads follow the page's own route: through the Tor SOCKS proxy when the site is on Tor (OkHttp * leaves SOCKS hosts unresolved, so even DNS goes through Tor), directly otherwise. They carry the page's * cookies from its own per-account storage profile and its user agent, so a logged-in download works. @@ -59,8 +64,9 @@ object BrowserDownloads { private val main = Handler(Looper.getMainLooper()) /** - * A WebView `DownloadListener` hit. [cookie] must be read on the main thread (from the tab's own - * profile) before calling; the transfer itself runs on a background thread. + * The immediately-user-initiated download (the long-press "Download image" context-menu item): that + * tap IS the gesture, so this brushes past the consent prompt. The transfer itself runs on a + * background thread. */ fun download( context: Context, @@ -76,40 +82,136 @@ object BrowserDownloads { saveDataUrl(app, url, null) return } - if (!url.startsWith("https://", ignoreCase = true) && !url.startsWith("http://", ignoreCase = true)) return + if (!isHttp(url)) return val name = URLUtil.guessFileName(url, contentDisposition, mimeType) toast(app, app.getString(CommonsR.string.browser_download_started, name)) io.execute { - val ok = - runCatching { - val request = - Request - .Builder() - .url(url) - .apply { - userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } - cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } - }.get() - .build() - // No end-to-end call timeout: a large file over Tor legitimately takes minutes. The - // client's read timeout still ends a transfer that stalls completely. - val client = - NappletBlobHttp - .client(proxyPort) - .newBuilder() - .callTimeout(0, TimeUnit.SECONDS) - .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("HTTP ${response.code}") - val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } - write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } - } - }.onFailure { Log.w(TAG, "Download failed for $url", it) } - .getOrDefault(false) - toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) + runNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) } } + /** + * A WebView `DownloadListener` hit — a PAGE-initiated save (a `` navigation or + * `Content-Disposition: attachment`), so it routes through the one consent gate exactly like the + * `browser.download` bridge envelope. [cookieHolder] is invoked (main-thread, on the WebView's own + * profile) when cookies are needed; the transfer itself only runs when the user allows — nothing is + * fetched until then. + */ + fun downloadWithConsent( + context: Context, + url: String, + contentDisposition: String?, + mimeType: String?, + cookieHolder: () -> String?, + userAgent: String?, + proxyPort: Int, + showPrompt: (fileName: String, sizeBytes: Long, risky: Boolean, consent: Consent) -> Unit, + ) { + val app = context.applicationContext + if (url.startsWith("data:", ignoreCase = true)) { + // A data: URL from the listener is the same forgeable surface as a bridge envelope: gate it. + val save = BrowserDownloadGate.preludeInlineSave(url, null) ?: return + showPrompt(save.fileName, save.bytes.size.toLong(), BrowserDownloadGate.isRisky(save.fileName), Consent { save.save(app) }) + return + } + if (!isHttp(url)) return + val guessedName = URLUtil.guessFileName(url, contentDisposition, mimeType) + io.execute { + // Ask the server for the exact size up front (fast-timed-out HEAD): a host that won't say + // leaves it -1 and the card shows the name alone. The GET itself stays unbounded — a large + // file over Tor takes minutes; only this probe is bounded. + val size = runCatching { probeContentLength(url, userAgent, cookieHolder(), proxyPort) }.getOrDefault(-1L) + main.post { + showPrompt( + sanitize(guessedName, mimeType), + size, + BrowserDownloadGate.isRisky(guessedName), + Consent { + io.execute { runNetworkDownload(app, url, guessedName, userAgent, mimeType, cookieHolder(), proxyPort) } + }, + ) + } + } + } + + /** + * The action the consent card's Save button runs: the transfer fires only on an explicit allow, so + * bytes are never pulled into a prompt closure before the user has said yes. + */ + fun interface Consent { + fun run() + } + + private fun isHttp(url: String) = url.startsWith("https://", ignoreCase = true) || url.startsWith("http://", ignoreCase = true) + + /** The actual transfer + toasts; runs on [io]. Shared by the consent prompt's allow and the context menu. */ + private fun runNetworkDownload( + app: Context, + url: String, + name: String, + userAgent: String?, + mimeType: String?, + cookie: String?, + proxyPort: Int, + ) { + val ok = + runCatching { + val request = request(url, userAgent, cookie).get().build() + val client = client(proxyPort) + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } + write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } + } + }.onFailure { Log.w(TAG, "Download failed for $url", it) } + .getOrDefault(false) + toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) + } + + /** The Content-Length a HEAD to [url] reports, or -1 when the server won't say / the probe fails. */ + private fun probeContentLength( + url: String, + userAgent: String?, + cookie: String?, + proxyPort: Int, + ): Long { + val request = request(url, userAgent, cookie).head().build() + // A small-BODY-time probe (not the unbounded transfer the eventual GET gets). A server that + // answers slowly leaves the size unknown rather than holding the prompt; a server that breaks + // on HEAD simply never fills it in. + val client = + NappletBlobHttp + .client(proxyPort) + .newBuilder() + .callTimeout(10, TimeUnit.SECONDS) + .build() + return client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code}") + response.body.contentLength() + } + } + + /** The OkHttp client for transfers through [proxyPort]: no end-to-end call timeout, as documented above. */ + private fun client(proxyPort: Int) = + NappletBlobHttp + .client(proxyPort) + .newBuilder() + .callTimeout(0, TimeUnit.SECONDS) + .build() + + private fun request( + url: String, + userAgent: String?, + cookie: String?, + ): Request.Builder = + Request + .Builder() + .url(url) + .apply { + userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } + cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } + } + /** Saves a `data:` URL (`data:[mime][;base64],payload`). */ fun saveDataUrl( context: Context, @@ -147,6 +249,36 @@ object BrowserDownloads { } } + /** + * Saves bytes a page handed over AFTER native consent: [name] is the already-sanitized, + * already-approved file name and [bytes] were decoded (and bounded) before the prompt, so this is + * exactly what the user saw on the consent card. Runs on the downloads executor and toasts the + * outcome, like every other path into [write]. + */ + fun saveInlineBytes( + context: Context, + name: String, + mimeType: String?, + bytes: ByteArray, + ) { + if (bytes.size > MAX_INLINE_BYTES) return + val app = context.applicationContext + io.execute { + val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) + toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) + } + } + + /** + * The plain file name the sink would use for a page's suggestion: without path parts or control + * characters, else "download" + the MIME's extension. Exposed so a consent prompt can show — and + * approve — the exact name [write] will store, before any bytes move. + */ + fun sanitize( + suggested: String?, + mimeType: String?, + ): String = safeName(suggested, mimeType) + /** * Writes into the public Downloads collection (Android 10+, no permission needed), or into the app's * own Downloads folder on older versions, where writing the shared one would need a storage permission diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt index acb3b06ec1..b53275020b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt @@ -29,10 +29,14 @@ package com.vitorpamplona.amethyst.napplethost * - `` — reported (`browser.themeColor`, normalized to `rgb(r, g, b)`) whenever it * or the colour scheme changes, so the window can tint its system bars and Recents entry. * - `blob:` / `data:` downloads — only the page can read a `blob:` URL, so a click on (or a programmatic - * `.click()` of) an `` pointing at one is turned into its bytes (`browser.download`). + * `.click()` of) an `` pointing at one is turned into its bytes (`browser.download`). The + * native side treats that type as a request, not an authority: the sink stays gated behind this + * origin's consent token plus a per-download confirmation, so a page that forges the envelope gains + * nothing over using the script. * * Messages travel over the same origin-scoped native bridge as NIP-07; the host handles `browser.*` types - * itself and never forwards them to the broker. + * itself and never forwards them to the broker's capability router (which is not a consent exemption — + * the download type is consent-gated by the host itself). */ object BrowserExtrasScript { val JS: String = diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 4525300d1b..4ebed03439 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -362,7 +362,17 @@ class NappletBrowserActivity : ComponentActivity() { wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1) + BrowserDownloads.downloadWithConsent( + context = this, + url = url, + contentDisposition = contentDisposition, + mimeType = mimeType, + cookieHolder = { BrowserWebTools.cookieManager(wv).getCookie(url) }, + userAgent = userAgent, + proxyPort = if (useTor) proxyPort else -1, + ) { fileName, sizeBytes, risky, consent -> + offerListenerDownloadConsent(fileName, sizeBytes, risky, consent) + } } wv.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) wv.dropSystemBarInsets() @@ -423,6 +433,7 @@ class NappletBrowserActivity : ComponentActivity() { // A dialog still up would leak its window and leave the page's JS blocked on an unanswered result. chrome?.dialog?.answer?.invoke(false, null, false) chrome?.permissionPrompt?.answer?.invoke(false, false) + chrome?.downloadPrompt?.answer?.invoke(false) customViewCallback?.onCustomViewHidden() destroyWebView() super.onDestroy() @@ -892,15 +903,30 @@ class NappletBrowserActivity : ComponentActivity() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. - if (envelope.stringOrNull("type").orEmpty().startsWith("browser.")) { - onBrowserMessage(envelope) - return - } + // The origin the WebView REPORTS — the page cannot forge this — keys every consent decision, + // including browser.* ones. Page-supplied fields are never trusted for that. + val origin = trustedOrigin(sourceOrigin) ?: return - val scheme = sourceOrigin.scheme ?: return - val host = sourceOrigin.host ?: return - val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + // Browser conveniences (share, theme colour, blob downloads) are handled here, never forwarded + // to the broker's capability router. That is NOT a consent exemption: browser.download writes + // into the shared Downloads collection, so it is gated below by [offerDownloadConsent] on + // this WebView-reported origin. + when (envelope.stringOrNull("type")) { + "browser.share" -> { + if (resumed) BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) + return + } + "browser.themeColor" -> { + themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) + applyThemeColor(themeColor) + updateTaskDescription() + return + } + "browser.download" -> { + offerDownloadConsent(origin, envelope) + return + } + } val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" } val msg = @@ -923,25 +949,75 @@ class NappletBrowserActivity : ComponentActivity() { } } - /** A `browser.*` message from [BrowserExtrasScript]. */ - private fun onBrowserMessage(envelope: JsonObject) { - when (envelope.stringOrNull("type")) { - "browser.share" -> - if (resumed) { - BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) - } - "browser.themeColor" -> { - themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) - applyThemeColor(themeColor) - updateTaskDescription() + /** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */ + private fun trustedOrigin(sourceOrigin: Uri): String? { + val scheme = sourceOrigin.scheme ?: return null + val host = sourceOrigin.host ?: return null + return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + } + + /** + * The one-shot native consent card for a `browser.download` envelope, then the save. Shown keyed on + * the WebView-reported [origin] only; nothing about the envelope can trigger the save alone. The + * byte payload is fully decoded here — before the prompt — so the dialog names the true size and + * malformed or oversized payloads are refused without ever reaching MediaStore. Exactly one card is + * live per origin on this window at a time (a second request drops), so a page can't swap the name + * under the user's finger. + */ + private fun offerDownloadConsent( + origin: String, + envelope: JsonObject, + ) { + val data = envelope.stringOrNull("data")?.takeIf { it.startsWith("data:", ignoreCase = true) } ?: return + val save = BrowserDownloadGate.preludeInlineSave(data, envelope.stringOrNull("name")) ?: return + val host = chrome ?: return + if (!BrowserDownloadGate.shouldPrompt(SURFACE_KEY, origin)) return + // One live prompt per window: a second request while a card is up is dropped, and a live card is + // never replaced — the anti-swap rule the fingerprint under the user's finger relies on. + if (host.downloadPrompt != null) return + host.downloadPrompt = + BrowserChromeHost.PendingDownload( + host = BrowserChrome.displayHost(origin), + security = BrowserChrome.security(host.ui.chrome), + fileName = save.fileName, + sizeBytes = save.bytes.size.toLong(), + risky = BrowserDownloadGate.isRisky(save.fileName), + ) { allowed -> + if (allowed) BrowserDownloads.saveInlineBytes(this, save.fileName, save.mimeType, save.bytes) } - "browser.download" -> { - val data = envelope.stringOrNull("data") ?: return - if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { - BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) - } + } + + /** + * The consent card for a WebView `DownloadListener` hit (a page-initiated `` navigation + * or `Content-Disposition: attachment`), offered by [BrowserDownloads.downloadWithConsent] after it + * probed the size. Shares the exact same gate and anti-swap rule as the `browser.download` bridge + * envelope: one live card per window, a live card is never replaced. + */ + private fun offerListenerDownloadConsent( + fileName: String, + sizeBytes: Long, + risky: Boolean, + consent: BrowserDownloads.Consent, + ) { + val origin = + chrome + ?.ui + ?.chrome + ?.url + ?.let(BrowserChrome::originOf) ?: return + if (!BrowserDownloadGate.shouldPrompt(SURFACE_KEY, origin)) return + val host = chrome ?: return + if (host.downloadPrompt != null) return + host.downloadPrompt = + BrowserChromeHost.PendingDownload( + host = BrowserChrome.displayHost(origin), + security = BrowserChrome.security(host.ui.chrome), + fileName = fileName, + sizeBytes = sizeBytes, + risky = risky, + ) { allowed -> + if (allowed) consent.run() } - } } private fun requestBrowserToken(origin: String) { @@ -1652,6 +1728,12 @@ class NappletBrowserActivity : ComponentActivity() { companion object { private const val TAG = "NappletBrowserActivity" + + /** + * The one consent-gate surface key for this single-window Activity: unlike the embedded Service, + * one Activity hosts exactly one WebView, so there is only ever one surface to book. + */ + private const val SURFACE_KEY = "full-screen" private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity" /** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */ diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index f814830628..f140673e0a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -184,6 +184,20 @@ object NappletBrowserContract { /** Leave HTML fullscreen (the user pressed back). */ const val MSG_EXIT_FULLSCREEN = 33 + /** + * Provider → client: a page's inline download needs the user's consent before its bytes are + * written into the shared Downloads collection ([browser.download] reaches a native file-write + * sink, so the gate lives at the sink, keyed on the WebView-reported [KEY_BROWSER_ORIGIN] — never + * a page-supplied field). Carries [KEY_DOWNLOAD_ID], the sanitized [KEY_DOWNLOAD_NAME], the exact + * byte size in [KEY_DOWNLOAD_SIZE], and [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). + * Answered with [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave + * the `:napplet` process. + */ + const val MSG_DOWNLOAD_CONSENT = 34 + + /** Client → provider: the user's answer to [MSG_DOWNLOAD_CONSENT]: [KEY_DOWNLOAD_ID] + [KEY_DOWNLOAD_ALLOWED]. */ + const val MSG_DOWNLOAD_CONSENT_RESULT = 35 + const val KEY_CAN_GO_FORWARD = "canGoForward" const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" @@ -206,6 +220,21 @@ object NappletBrowserContract { const val KEY_PERMISSIONS = "permissions" const val KEY_BROWSER_ORIGIN = "browserOrigin" + /** Correlates a [MSG_DOWNLOAD_CONSENT] prompt with its [MSG_DOWNLOAD_CONSENT_RESULT] answer. */ + const val KEY_DOWNLOAD_ID = "downloadId" + + /** The sanitized file name the consented download will be stored under (already path/control-char stripped). */ + const val KEY_DOWNLOAD_NAME = "downloadName" + + /** The exact decoded byte count the consented download will write. */ + const val KEY_DOWNLOAD_SIZE = "downloadSize" + + /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ + const val KEY_DOWNLOAD_RISKY = "downloadRisky" + + /** The user's answer in [MSG_DOWNLOAD_CONSENT_RESULT]: true = save, false = discard. */ + const val KEY_DOWNLOAD_ALLOWED = "downloadAllowed" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index e58e8a861e..00b5c5d95d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -154,6 +154,13 @@ class NappletBrowserService : Service() { // WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt. private val pendingWebPermissions = mutableMapOf() + // Inline-download consent cards the main process is showing: relay id → the save to run if the + // user allows. [consentTabs] scopes each id to its tab's session so a closing tab clears only its + // own; entries are removed when the client answers. + private val pendingDownloadConsents = mutableMapOf Unit>() + private val consentTabs = mutableMapOf() + private var downloadSeq = 0L + // The shim never changes; read+decode it once instead of per tab on the main thread. private val shimJs: String by lazy { readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() } @@ -295,6 +302,13 @@ class NappletBrowserService : Service() { .toSet(), ) } + NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT -> { + val data = msg.data ?: return true + val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + val answer = pendingDownloadConsents.remove(id) ?: return true + consentTabs.remove(id) + answer(data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)) + } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } @@ -446,6 +460,12 @@ class NappletBrowserService : Service() { // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() cancelPending(tab) + // A consent card parked behind this tab dies with it: an unanswered prompt saves nothing, and + // its decoded bytes are freed. Only this tab's entries are dropped (a sibling tab's is not). + pendingDownloadConsents.keys.filter { consentTabs[it] == sessionId }.forEach { id -> + pendingDownloadConsents.remove(id) + consentTabs.remove(id) + } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -462,8 +482,18 @@ class NappletBrowserService : Service() { wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - val route = if (tab != null && tab.useTor) tab.proxyPort else -1 - BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), route) + if (tab == null) return@setDownloadListener + BrowserDownloads.downloadWithConsent( + context = this, + url = url, + contentDisposition = contentDisposition, + mimeType = mimeType, + cookieHolder = { BrowserWebTools.cookieManager(wv).getCookie(url) }, + userAgent = userAgent, + proxyPort = if (tab.useTor) tab.proxyPort else -1, + ) { fileName, sizeBytes, risky, consent -> + offerListenerDownloadConsent(tab, fileName, sizeBytes, risky, consent) + } } } @@ -953,21 +983,25 @@ class NappletBrowserService : Service() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // Browser conveniences (share, blob downloads) are handled here, never brokered. The theme colour - // only matters to a window with system bars, which an embedded tab doesn't own. + // The origin the WebView REPORTS — the page cannot forge this — keys every consent decision, + // including browser.* ones. Page-supplied fields are never trusted for that. + val origin = trustedOrigin(sourceOrigin) ?: return + + // Browser conveniences (share, blob downloads) are handled here, never forwarded to the broker's + // capability router; the theme colour only matters to a window with system bars, which an embedded + // tab doesn't own. That is NOT a consent exemption: browser.download writes into the shared + // Downloads collection, so it is gated below by [offerDownloadConsent] on this WebView-reported + // origin. when (envelope.stringOrNull("type")) { "browser.share" -> { BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) return } + "browser.themeColor" -> return "browser.download" -> { - val data = envelope.stringOrNull("data") ?: return - if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { - BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) - } + offerDownloadConsent(tab, origin, envelope) return } - "browser.themeColor" -> return } // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. @@ -980,10 +1014,6 @@ class NappletBrowserService : Service() { return } - val scheme = sourceOrigin.scheme ?: return - val host = sourceOrigin.host ?: return - val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { @@ -1005,6 +1035,82 @@ class NappletBrowserService : Service() { } } + /** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */ + private fun trustedOrigin(sourceOrigin: Uri): String? { + val scheme = sourceOrigin.scheme ?: return null + val host = sourceOrigin.host ?: return null + return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + } + + /** + * The one-shot native consent card for a `browser.download` envelope, relayed to the main process + * (this provider has no window to show one on), then the save. Keyed on the WebView-reported + * [origin] only; nothing about the envelope can trigger the save alone. The byte payload is fully + * decoded here — before the prompt — so the dialog names the true size and malformed or oversized + * payloads are refused without ever reaching MediaStore. Exactly one card is live per tab at a time + * (a second request drops), so a page can't swap the name under the user's finger. + */ + private fun offerDownloadConsent( + tab: BrowserTab, + origin: String, + envelope: JsonObject, + ) { + val data = envelope.stringOrNull("data")?.takeIf { it.startsWith("data:", ignoreCase = true) } ?: return + val save = BrowserDownloadGate.preludeInlineSave(data, envelope.stringOrNull("name")) ?: return + if (!BrowserDownloadGate.shouldPrompt(tab.sessionId, origin)) return + if (hasLiveDownloadConsent(tab)) return + val id = ++downloadSeq + val sent = + sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { + putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) + putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) + putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, save.fileName) + putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, save.bytes.size.toLong()) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, BrowserDownloadGate.isRisky(save.fileName)) + } + if (sent) { + consentTabs[id] = tab.sessionId + pendingDownloadConsents[id] = { allowed -> + if (allowed) BrowserDownloads.saveInlineBytes(this, save.fileName, save.mimeType, save.bytes) + } + } + } + + /** Whether this tab's consent card is still on screen (a second request for it is dropped — the anti-swap rule). */ + private fun hasLiveDownloadConsent(tab: BrowserTab): Boolean = pendingDownloadConsents.keys.any { consentTabs[it] == tab.sessionId } + + /** + * The consent card for a WebView `DownloadListener` hit in this tab (a page-initiated `` + * or `Content-Disposition: attachment`), offered by [BrowserDownloads.downloadWithConsent] after it + * probed the size. Shares the exact same one-live-card-per-tab anti-swap rule as the bridge envelope. + */ + private fun offerListenerDownloadConsent( + tab: BrowserTab, + fileName: String, + sizeBytes: Long, + risky: Boolean, + consent: BrowserDownloads.Consent, + ) { + val origin = tab.webView?.url?.let(BrowserChrome::originOf) ?: return + if (!BrowserDownloadGate.shouldPrompt(tab.sessionId, origin)) return + if (hasLiveDownloadConsent(tab)) return + val id = ++downloadSeq + val sent = + sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { + putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) + putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) + putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, fileName) + putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, sizeBytes) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, risky) + } + if (sent) { + consentTabs[id] = tab.sessionId + pendingDownloadConsents[id] = { allowed -> + if (allowed) consent.run() + } + } + } + private fun requestBrowserToken( tab: BrowserTab, origin: String, From a6b6b08f2980933bbf73ac4bd2ce95f8905b205e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:28:24 +0000 Subject: [PATCH 4/6] fix(browser): one consent gate for every page-initiated download Builds on the reported fix: every download a page starts (WebView DownloadListener or the browser.download bridge message) becomes a DownloadOffer shown on the consent card before anything is fetched or written. - Per-surface DownloadCooldown that actually runs (the old gate's cooldown stamp was never written), plus one offer prepared at a time so a page can't queue 25 MiB decodes. - Cookies read on the main thread from the tab's own WebView profile again (the lambda was invoked on the download thread). - Use the listener's contentLength instead of a pre-consent HEAD probe: no request leaves before the user says yes, no 10s card delay. - The card shows and the save writes the same sanitized name; unknown sizes are hidden instead of "-1 B"; long names keep their extension. - Inline data: URLs decode off the main thread; the rules (risky extensions, data: URL decoding with a hard cap, cooldown) move to commons with unit tests. - Drop the hand-written values-es strings (Crowdin-managed) and the unused gate code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3cqjbY7FX2zrkGXCVXpFD --- .../loggedIn/browser/EmbeddedPageRequests.kt | 6 +- .../browser/EmbeddedWebAppController.kt | 2 +- .../screen/loggedIn/browser/WebAppScreen.kt | 2 +- .../commons/browser/BrowserDownloadRules.kt | 186 ++++++++++++ .../browser/BrowserDownloadRulesTest.kt | 99 +++++++ .../composeResources/values-es/strings.xml | 3 - .../browser/ui/pill/DownloadPromptCard.kt | 26 +- .../amethyst/napplethost/BrowserChromeHost.kt | 6 +- .../napplethost/BrowserDownloadGate.kt | 226 --------------- .../amethyst/napplethost/BrowserDownloads.kt | 268 ++++++++---------- .../napplethost/BrowserExtrasScript.kt | 8 +- .../napplethost/NappletBrowserActivity.kt | 120 ++++---- .../napplethost/NappletBrowserContract.kt | 15 +- .../napplethost/NappletBrowserService.kt | 153 +++++----- 14 files changed, 547 insertions(+), 573 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt index dd1e18c379..67119d18c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -42,9 +42,9 @@ data class EmbeddedPermissionRequest( ) /** - * An inline download (`browser.download`) from an embedded page, waiting for consent before its bytes - * are written into the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and - * exact decoded size the sandbox reports; [origin] is the WebView-reported origin, not a page field. + * A download an embedded page started, waiting for consent before anything is fetched or written into + * the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and size (-1 when + * unknown) the sandbox reports; [origin] is the WebView-reported origin, not a page field. */ data class EmbeddedDownloadRequest( val id: Long, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 44707deeaf..df043eb826 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -386,7 +386,7 @@ class EmbeddedWebAppController( id = id, origin = origin, fileName = name, - sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, 0L), + sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, -1L), risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 16b391755d..36b516a1b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -410,7 +410,7 @@ private fun EmbeddedPageUi( } } - // A page's inline download: nothing reaches the shared Downloads collection until this is answered. + // A download the page started: nothing is fetched or saved until this is answered. // The card shows the sanitized file name and exact byte count the sandbox will write, headed by the // WebView-reported origin (never a page-supplied field), with a warning for installer/script-like // extensions — the social-engineering payload names the disclosure calls out ("invoice.apk"). diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt new file mode 100644 index 0000000000..6b85a01384 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt @@ -0,0 +1,186 @@ +/* + * 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.browser + +import kotlin.io.encoding.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * The platform-free rules behind the in-app browser's download consent: which file names deserve a + * warning, how a page-supplied `data:` URL turns into the bytes a consent card describes, and how often + * one site may ask. + * + * A page can start a download without any gesture (a navigation to an attachment, a script `.click()` + * on an ``, or a hand-forged bridge envelope), so every page-initiated save asks the user + * first. The card shows exactly the name and size these rules produce, and the save writes exactly that. + */ +object BrowserDownloadRules { + /** Cap for bytes a page hands over inline (a `blob:`/`data:` download travels as base64 over the bridge). */ + const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + + /** + * Extensions something can install or run from. The consent card flags them; the name itself is + * never rewritten, so the user judges the real one. + */ + val RISKY_EXTENSIONS = + setOf( + "apk", + "apks", + "apkm", + "xapk", + "aab", + "jar", + "dex", + "so", + "exe", + "msi", + "bat", + "cmd", + "com", + "scr", + "hta", + "ps1", + "vbs", + "wsf", + "lnk", + "url", + "dmg", + "pkg", + "app", + "command", + "deb", + "rpm", + "appimage", + "run", + "sh", + "zsh", + "bash", + "desktop", + "html", + "htm", + "xhtml", + "svg", + ) + + /** Whether [fileName]'s last extension is one a user should double-check before saving. */ + fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").trim().lowercase() in RISKY_EXTENSIONS + + /** A decoded `data:` URL: the declared MIME type (null when absent) and the payload bytes. */ + class DataUrl( + val mimeType: String?, + val bytes: ByteArray, + ) + + private val lenientBase64 = Base64.Mime.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + + /** + * Decodes `data:[][;param=v…][;base64],`, base64 or percent-encoded. Null when it is + * not a data URL, is malformed, or decodes to more than [maxBytes] — a refused download, never a + * truncated one. The encoded length is checked first, so an oversized payload is never decoded. + */ + fun decodeDataUrl( + dataUrl: String, + maxBytes: Int = MAX_INLINE_BYTES, + ): DataUrl? { + if (!dataUrl.startsWith("data:", ignoreCase = true)) return null + val comma = dataUrl.indexOf(',') + if (comma < 0) return null + val header = dataUrl.substring(5, comma) + val params = header.split(';') + val mime = + params + .first() + .trim() + .lowercase() + .takeIf { it.isNotEmpty() } + val isBase64 = params.drop(1).any { it.trim().equals("base64", ignoreCase = true) } + val payloadLength = dataUrl.length - comma - 1 + val bytes = + if (isBase64) { + // 4 chars per 3 bytes, plus slack for padding and the line breaks a lenient decoder skips. + if (payloadLength > maxBytes / 3 * 4 + 1024) return null + runCatching { lenientBase64.decode(dataUrl, comma + 1, dataUrl.length) }.getOrNull() ?: return null + } else { + // A percent escape is 3 chars per byte, a literal char up to 4 UTF-8 bytes. + if (payloadLength > maxBytes) return null + percentDecode(dataUrl, comma + 1) ?: return null + } + if (bytes.size > maxBytes) return null + return DataUrl(mime, bytes) + } + + /** RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`); null on a broken escape. */ + private fun percentDecode( + text: String, + start: Int, + ): ByteArray? { + val source = text.substring(start).encodeToByteArray() + val out = ByteArray(source.size) + var read = 0 + var written = 0 + while (read < source.size) { + val b = source[read] + if (b == '%'.code.toByte()) { + if (read + 2 >= source.size) return null + val hi = hexValue(source[read + 1]) + val lo = hexValue(source[read + 2]) + if (hi < 0 || lo < 0) return null + out[written++] = ((hi shl 4) or lo).toByte() + read += 3 + } else { + out[written++] = b + read++ + } + } + return out.copyOf(written) + } + + private fun hexValue(b: Byte): Int = + when (val c = b.toInt().toChar()) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> -1 + } +} + +/** + * How often one browser surface (a window, or one embedded tab) lets a site show a download card: after + * the user answers a site's card, that site can't raise another for [window]. Without it a page that is + * refused can re-ask in a loop, so Cancel would never make the card go away. Main-thread only. + */ +class DownloadCooldown( + private val window: Duration = 1.seconds, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private val answeredAt = HashMap() + + /** Whether [origin] may show a card now. */ + fun allows(origin: String): Boolean = answeredAt[origin]?.let { it.elapsedNow() >= window } ?: true + + /** Starts [origin]'s cooldown: the user just answered (or the surface dismissed) its card. */ + fun answered(origin: String) { + answeredAt[origin] = timeSource.markNow() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt new file mode 100644 index 0000000000..e186292c62 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt @@ -0,0 +1,99 @@ +/* + * 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.browser + +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class BrowserDownloadRulesTest { + @Test + fun flagsInstallableAndScriptExtensions() { + assertTrue(BrowserDownloadRules.isRisky("invoice.apk")) + assertTrue(BrowserDownloadRules.isRisky("invoice.pdf.APK")) + assertTrue(BrowserDownloadRules.isRisky("page.html")) + assertFalse(BrowserDownloadRules.isRisky("photo.jpg")) + assertFalse(BrowserDownloadRules.isRisky("apk")) + assertFalse(BrowserDownloadRules.isRisky("download")) + } + + @Test + fun decodesBase64DataUrl() { + val payload = "hello world".encodeToByteArray() + val url = "data:text/plain;charset=utf-8;base64," + Base64.encode(payload) + val decoded = BrowserDownloadRules.decodeDataUrl(url)!! + assertEquals("text/plain", decoded.mimeType) + assertContentEquals(payload, decoded.bytes) + } + + @Test + fun decodesUnpaddedAndWrappedBase64() { + assertContentEquals("hi".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGk")!!.bytes) + assertContentEquals("hello world".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGVsbG8g\r\nd29ybGQ=")!!.bytes) + } + + @Test + fun decodesPercentEncodedDataUrl() { + val decoded = BrowserDownloadRules.decodeDataUrl("DATA:,a%20b+c%C3%A9")!! + assertNull(decoded.mimeType) + assertEquals("a b+cé", decoded.bytes.decodeToString()) + } + + @Test + fun refusesMalformedDataUrls() { + assertNull(BrowserDownloadRules.decodeDataUrl("https://example.com/a.apk")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:text/plain;base64")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%2")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%zz")) + } + + @Test + fun refusesOversizedPayloadsInsteadOfTruncating() { + val bytes = ByteArray(100) { it.toByte() } + val url = "data:application/octet-stream;base64," + Base64.encode(bytes) + assertEquals(100, BrowserDownloadRules.decodeDataUrl(url, maxBytes = 100)!!.bytes.size) + assertNull(BrowserDownloadRules.decodeDataUrl(url, maxBytes = 99)) + assertNull(BrowserDownloadRules.decodeDataUrl("data:," + "a".repeat(11), maxBytes = 10)) + } + + @Test + fun cooldownHoldsOffOnlyTheAnsweredOrigin() { + val clock = TestTimeSource() + val cooldown = DownloadCooldown(1.seconds, clock) + assertTrue(cooldown.allows("https://a.example")) + + cooldown.answered("https://a.example") + assertFalse(cooldown.allows("https://a.example")) + assertTrue(cooldown.allows("https://b.example")) + + clock += 999.milliseconds + assertFalse(cooldown.allows("https://a.example")) + clock += 1.milliseconds + assertTrue(cooldown.allows("https://a.example")) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index 52d054cc83..c278f19263 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -2997,7 +2997,4 @@ No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s. Buscar o introducir dirección NApplet sin título - ¿Descargar este archivo? - Este tipo de archivo puede ejecutar código. Comprueba que el nombre coincide con lo que querías obtener. - Guardar diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt index 28b40df411..09d52f47a1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt @@ -52,14 +52,14 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_risky import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_save import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_title +import com.vitorpamplona.amethyst.commons.ui.note.types.formatBytes import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.util.DecimalPatternFormatter /** - * A page's inline download waiting for consent before its bytes are written to the shared Downloads - * collection. The bridge envelope is forgeable (any top-frame script can post it, no gesture needed), - * so the gate lives here: the card names the exact file the sink would write — the WebView-reported - * origin, never a page-supplied field — and nothing is saved until the user taps Save. + * A download the page started, waiting for consent before anything is fetched or written to the shared + * Downloads collection. A page can start one without any gesture, so the card names the site (the + * WebView-reported origin, never a page-supplied field), the exact file name that would be saved and its + * size ([sizeBytes] is -1 when the server didn't say), and nothing is saved until the user taps Save. */ @Composable fun DownloadPromptCard( @@ -90,8 +90,11 @@ fun DownloadPromptCard( } Spacer(Modifier.width(14.dp)) Column { - Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text(formatBytes(sizeBytes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + // Never cut the end off: the extension is what tells "invoice.pdf" from "invoice.pdf.apk". + Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 3, overflow = TextOverflow.MiddleEllipsis) + if (sizeBytes >= 0) { + Text(formatBytes(sizeBytes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } if (risky) { @@ -117,12 +120,3 @@ fun DownloadPromptCard( } } } - -/** Rounded byte count for the consent card ("412 B", "1.2 MB", "25.0 MB"). */ -private fun formatBytes(bytes: Long): String { - if (bytes < 1024L) return "$bytes B" - val kb = bytes / 1024.0 - if (kb < 1024) return "${DecimalPatternFormatter("0.0").format(kb)} KB" - val mb = kb / 1024 - return "${DecimalPatternFormatter("0.0").format(mb)} MB" -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index 4a13007331..ad595fd2fb 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -132,9 +132,9 @@ class BrowserChromeHost( ) /** - * An inline download (`browser.download` bridge message) waiting for consent before its bytes are - * written into the shared Downloads collection. [fileName]/[sizeBytes] describe exactly what the - * native sink would write; nothing is saved until [answer] runs with true. + * A download the page started, waiting for consent before anything is fetched or written into the + * shared Downloads collection. [fileName]/[sizeBytes] (-1 when unknown) describe exactly what would + * be saved; nothing is saved until [answer] runs with true. */ class PendingDownload( val host: String?, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt deleted file mode 100644 index 3ef91c013d..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * 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.napplethost - -import android.os.SystemClock -import android.webkit.URLUtil -import com.vitorpamplona.quartz.utils.Log - -/** - * The ONE consent and gate for every page-initiated native file write into the shared public Downloads - * collection, so both entry paths into [BrowserDownloads] — the `browser.download` bridge envelope and - * the WebView `DownloadListener` — pass through exactly one gate. - * - * Why it lives in the `:napplet` sandbox and not in the injected script: the WebView bridge accepts - * envelopes from any origin the user browses (`addWebMessageListener(..., setOf("*"))`), and - * BrowserWebTools.share's own comment documents pages posting bridge envelopes directly, bypassing the - * injected script's checks. So a hostile top frame can ask for a native file write with no gesture and - * no consent. The gate is keyed on data the page cannot forge — the WebView-reported origin — and it is - * deliberately NOT the broker's per-origin launch token: that token is minted automatically for any - * logged-in origin that asks (NappletBrokerService's MSG_MINT_BROWSER_TOKEN handler shows no prompt), - * so it is a session handle, not a consent grant. - * - * Three layers: - * - * 1. **One-shot native consent** — every page-initiated save shows [com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard] - * first: the exact sanitized name, the true size (decoded base64 length, or the server's Content-Length - * when the page gave a network URL), and the WebView-reported origin. Nothing reaches [BrowserDownloads] - * until the user taps Save. The immediately-user-initiated context-menu "Download image" brushes past - * the prompt (that tap IS the gesture). - * 2. **One live prompt per surface** — a second consent request while one is already live for the same - * surface (this full-screen window, or one embedded tab) is refused ([beginConsent]), so a page can't - * swap a card's name under the user's finger; the callers also answer the displaced card before showing - * a successor. - * 3. **Per-origin cooldown** — after a prompt is answered, the next request from the same origin on the - * same surface can't flash another card for [DOWNLOAD_COOLDOWN_MS] ([shouldPrompt]). - */ -object BrowserDownloadGate { - private const val TAG = "BrowserDownloadGate" - - /** Minimum spacing between consent prompts per origin on one surface, mirroring BrowserWebTools.share. */ - const val DOWNLOAD_COOLDOWN_MS = 1_000L - - /** Extensions an install-or-run prompt exists for. Reported (never rewritten) so the user can judge the real name. */ - val RISKY_EXTENSIONS = - setOf( - "apk", - "apks", - "apkm", - "xapk", - "aab", - "jar", - "dex", - "so", - "exe", - "msi", - "bat", - "cmd", - "com", - "scr", - "hta", - "ps1", - "vbs", - "wsf", - "dmg", - "pkg", - "app", - "deb", - "rpm", - "appimage", - "run", - "html", - "htm", - "xhtml", - "svg", - "sh", - "zsh", - "bash", - "command", - "lnk", - "url", - "desktop", - ) - - /** Everything the consent card needs to show before a single byte is saved. */ - class Spec( - val fileName: String, - val sizeBytes: Long, - val risky: Boolean, - ) - - /** - * One surface's live consent, handed out by [beginConsent] and ended by [endConsent]. Holding it - * is what stops a second prompt for the same surface from displacing the card under the user's - * finger; the page has no way to observe it. Callers must [endConsent] on every answer path. - */ - class Consent internal constructor( - internal val surfaceKey: String, - internal val origin: String, - ) - - /** - * The post-consent save for already-allowed inline bytes. Kept here (and exposed as the save a - * [com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard] allow runs) because exactly - * the same save is shared by the card's allow and by any caller that already holds consented bytes. - */ - class InlineSave internal constructor( - val fileName: String, - val mimeType: String?, - val bytes: ByteArray, - ) { - /** Writes the consented bytes into the shared Downloads collection, exactly as the card named them. */ - fun save(context: android.content.Context) = BrowserDownloads.saveInlineBytes(context, fileName, mimeType, bytes) - } - - /** - * Builds the post-consent inline save for a `browser.download` envelope's data URL: the sanitized - * name and the decoded bytes, so the eventual save is exactly what the card showed. Null when the - * URL is malformed or over [BrowserDownloads.MAX_INLINE_BYTES] — the caller treats null as a refused - * download and shows no prompt. - */ - fun preludeInlineSave( - dataUrl: String, - suggestedName: String?, - ): InlineSave? { - val header = dataUrl.substringBefore(',', "") - if (!dataUrl.contains(',') || !header.startsWith("data:", ignoreCase = true) || !header.endsWith(";base64", ignoreCase = true)) return null - val payload = dataUrl.substringAfter(',', "") - if (payload.length > BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 8) return null - val bytes = runCatching { android.util.Base64.decode(payload, android.util.Base64.DEFAULT) }.getOrNull() ?: return null - if (bytes.size > BrowserDownloads.MAX_INLINE_BYTES) return null - // The MIME inside the data URL drives safeName's fallback extension when the page sent no name. - val mime = - header - .removePrefix("data:") - .removeSuffix(";base64") - .substringBefore(';') - .ifBlank { null } - return InlineSave(BrowserDownloads.sanitize(suggestedName, mime), mime, bytes) - } - - /** What the consent card shows for a network download, before any bytes are fetched. */ - fun networkSpec( - fileName: String, - sizeBytes: Long, - ): Spec = Spec(fileName, sizeBytes.coerceAtLeast(0L), isRisky(fileName)) - - /** Guesses a network download's file name the same way the eventual save does. */ - fun guessNetworkName( - url: String, - contentDisposition: String?, - mimeType: String?, - ): String = BrowserDownloads.sanitize(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType) - - /** - * Whether a consent prompt may be shown for [origin] on [surfaceKey] right now. A `false` return - * means a prompt for this origin on this surface was just answered and this request is silently - * dropped — the throttle that keeps a spamming page from flashing dialogs forever. - */ - fun shouldPrompt( - surfaceKey: String, - origin: String, - ): Boolean { - val now = SystemClock.elapsedRealtime() - val last = lastAnsweredAt[surfaceKey]?.get(origin) ?: 0L - if (now - last < DOWNLOAD_COOLDOWN_MS) { - Log.d(TAG) { "Download consent for $origin throttled" } - return false - } - return true - } - - /** - * Claims the one live consent slot for [surfaceKey], or returns null when one is already live for - * that surface — the anti-swap rule, released by [endConsent]. A `false` [shouldPrompt] caller - * never even gets here (dropped before the slot is taken). - */ - fun beginConsent( - surfaceKey: String, - origin: String, - ): Consent? { - if (liveSurfaces.contains(surfaceKey)) return null - liveSurfaces.add(surfaceKey) - return Consent(surfaceKey, origin) - } - - /** - * Releases [consent]'s slot and starts its per-origin cooldown. Called on every answer path — - * Save, Don't save, dismissal, and the surface being destroyed — so a torn-down window can never - * wedge the gate. Idempotent. - */ - fun endConsent(consent: Consent) { - liveSurfaces.remove(consent.surfaceKey) - lastAnsweredAt.getOrPut(consent.surfaceKey) { HashMap() }[consent.origin] = SystemClock.elapsedRealtime() - } - - /** Drops this surface's throttle and live-slot state — when the window/tab hosting its WebView goes away. */ - fun clearSurface(surfaceKey: String) { - liveSurfaces.remove(surfaceKey) - lastAnsweredAt.remove(surfaceKey) - } - - /** Whether [fileName]'s extension is one a user should double-check before saving. */ - fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").lowercase() in RISKY_EXTENSIONS - - // Main-thread only: one live surface's consent, and per-surface then per-origin last-answered stamps. - private val liveSurfaces = HashSet() - private val lastAnsweredAt = HashMap>() -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt index fbfcef6a96..31a7d57ae1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -27,16 +27,14 @@ import android.os.Environment import android.os.Handler import android.os.Looper import android.provider.MediaStore -import android.util.Base64 import android.webkit.MimeTypeMap import android.webkit.URLUtil import android.widget.Toast +import com.vitorpamplona.amethyst.commons.browser.BrowserDownloadRules import com.vitorpamplona.quartz.utils.Log import okhttp3.Request import java.io.File -import java.io.IOException import java.io.OutputStream -import java.net.URLDecoder import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import com.vitorpamplona.amethyst.commons.R as CommonsR @@ -46,9 +44,11 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR * `blob:` URLs (which only the page can read, so the browser-extras script hands their bytes over) — into * the system Downloads collection, the way Chrome does. * - * Page-initiated saves (both the `browser.download` bridge envelope and the WebView `DownloadListener`) - * reach the write sinks here only through [BrowserDownloadGate]'s one-shot consent; the - * immediately-user-initiated "Download image" context-menu action is the one exception. + * A page can start a download without any gesture, so everything it starts arrives as a [DownloadOffer] + * that the surface shows on a consent card; nothing is fetched or written until the user taps Save. That + * covers both entry points: the WebView `DownloadListener` ([offerListenerDownload]) and the + * `browser.download` bridge envelope ([offerInline]), which any top-frame script can post directly. The + * long-press "Download image" item ([download]) is the one ungated path: the user's own tap starts it. * * Network downloads follow the page's own route: through the Tor SOCKS proxy when the site is on Tor (OkHttp * leaves SOCKS hosts unresolved, so even DNS goes through Tor), directly otherwise. They carry the page's @@ -58,15 +58,37 @@ object BrowserDownloads { private const val TAG = "BrowserDownloads" /** Cap for bytes a page hands over for a blob:/data: download (they travel as base64 over the bridge). */ - const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + const val MAX_INLINE_BYTES = BrowserDownloadRules.MAX_INLINE_BYTES private val io = Executors.newSingleThreadExecutor { Thread(it, "napplet-downloads").apply { isDaemon = true } } + + // Decoding an inline payload (up to 25 MiB) is kept off the main thread, and off [io] so a long + // transfer already running there doesn't hold the next consent card back. + private val decoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-download-decode").apply { isDaemon = true } } private val main = Handler(Looper.getMainLooper()) + private val unsafeNameChars = Regex("[\\u0000-\\u001f:*?\"<>|]") + /** - * The immediately-user-initiated download (the long-press "Download image" context-menu item): that - * tap IS the gesture, so this brushes past the consent prompt. The transfer itself runs on a - * background thread. + * A page-initiated download, resolved to exactly what its consent card shows: [save] writes a file + * named [fileName] and nothing else, and until it runs no byte has been fetched or written. + */ + class DownloadOffer internal constructor( + val fileName: String, + /** The exact size in bytes, or -1 when the server didn't say. */ + val sizeBytes: Long, + private val start: (Context) -> Unit, + ) { + /** Whether [fileName] is something that can be installed or run, which the card warns about. */ + val risky: Boolean get() = BrowserDownloadRules.isRisky(fileName) + + fun save(context: Context) = start(context.applicationContext) + } + + /** + * The long-press "Download image" item: the user's tap is the gesture, so it saves without a card. + * [cookie] must be read on the main thread (from the tab's own profile) before calling; the transfer + * itself runs on a background thread. */ fun download( context: Context, @@ -83,69 +105,70 @@ object BrowserDownloads { return } if (!isHttp(url)) return - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) - toast(app, app.getString(CommonsR.string.browser_download_started, name)) - io.execute { - runNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) - } + startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, mimeType, cookie, proxyPort) } /** - * A WebView `DownloadListener` hit — a PAGE-initiated save (a `` navigation or - * `Content-Disposition: attachment`), so it routes through the one consent gate exactly like the - * `browser.download` bridge envelope. [cookieHolder] is invoked (main-thread, on the WebView's own - * profile) when cookies are needed; the transfer itself only runs when the user allows — nothing is - * fetched until then. + * A WebView `DownloadListener` hit: a download the page started. Calls [onReady] exactly once, on the + * main thread, with the offer for the consent card (null when the URL can't be saved). [contentLength] is + * the size the listener reported (<= 0 when unknown); [cookie] must be read on the main thread from + * the tab's own profile. Nothing is fetched until the offer's save runs. */ - fun downloadWithConsent( - context: Context, + fun offerListenerDownload( url: String, + userAgent: String?, contentDisposition: String?, mimeType: String?, - cookieHolder: () -> String?, - userAgent: String?, + contentLength: Long, + cookie: String?, proxyPort: Int, - showPrompt: (fileName: String, sizeBytes: Long, risky: Boolean, consent: Consent) -> Unit, + onReady: (DownloadOffer?) -> Unit, ) { - val app = context.applicationContext if (url.startsWith("data:", ignoreCase = true)) { - // A data: URL from the listener is the same forgeable surface as a bridge envelope: gate it. - val save = BrowserDownloadGate.preludeInlineSave(url, null) ?: return - showPrompt(save.fileName, save.bytes.size.toLong(), BrowserDownloadGate.isRisky(save.fileName), Consent { save.save(app) }) + offerInline(url, null, onReady) return } - if (!isHttp(url)) return - val guessedName = URLUtil.guessFileName(url, contentDisposition, mimeType) - io.execute { - // Ask the server for the exact size up front (fast-timed-out HEAD): a host that won't say - // leaves it -1 and the card shows the name alone. The GET itself stays unbounded — a large - // file over Tor takes minutes; only this probe is bounded. - val size = runCatching { probeContentLength(url, userAgent, cookieHolder(), proxyPort) }.getOrDefault(-1L) - main.post { - showPrompt( - sanitize(guessedName, mimeType), - size, - BrowserDownloadGate.isRisky(guessedName), - Consent { - io.execute { runNetworkDownload(app, url, guessedName, userAgent, mimeType, cookieHolder(), proxyPort) } - }, - ) - } + if (!isHttp(url)) { + onReady(null) + return } + val name = networkName(url, contentDisposition, mimeType) + onReady( + DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L) { app -> + startNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) + }, + ) } /** - * The action the consent card's Save button runs: the transfer fires only on an explicit allow, so - * bytes are never pulled into a prompt closure before the user has said yes. + * A page's inline download (a `browser.download` envelope's `data:` URL). Decodes it off the main + * thread, then calls [onReady] exactly once, on the main thread, with the offer (its size is the true + * decoded length), or null when the payload is malformed or oversized and is refused without a card. */ - fun interface Consent { - fun run() + fun offerInline( + dataUrl: String, + suggestedName: String?, + onReady: (DownloadOffer?) -> Unit, + ) { + decoder.execute { + val offer = + BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data -> + val name = safeName(suggestedName, data.mimeType) + DownloadOffer(name, data.bytes.size.toLong()) { app -> writeInBackground(app, name, data.mimeType, data.bytes) } + } + main.post { onReady(offer) } + } } private fun isHttp(url: String) = url.startsWith("https://", ignoreCase = true) || url.startsWith("http://", ignoreCase = true) - /** The actual transfer + toasts; runs on [io]. Shared by the consent prompt's allow and the context menu. */ - private fun runNetworkDownload( + private fun networkName( + url: String, + contentDisposition: String?, + mimeType: String?, + ) = safeName(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType) + + private fun startNetworkDownload( app: Context, url: String, name: String, @@ -154,131 +177,62 @@ object BrowserDownloads { cookie: String?, proxyPort: Int, ) { - val ok = - runCatching { - val request = request(url, userAgent, cookie).get().build() - val client = client(proxyPort) - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("HTTP ${response.code}") - val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } - write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } - } - }.onFailure { Log.w(TAG, "Download failed for $url", it) } - .getOrDefault(false) - toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) - } - - /** The Content-Length a HEAD to [url] reports, or -1 when the server won't say / the probe fails. */ - private fun probeContentLength( - url: String, - userAgent: String?, - cookie: String?, - proxyPort: Int, - ): Long { - val request = request(url, userAgent, cookie).head().build() - // A small-BODY-time probe (not the unbounded transfer the eventual GET gets). A server that - // answers slowly leaves the size unknown rather than holding the prompt; a server that breaks - // on HEAD simply never fills it in. - val client = - NappletBlobHttp - .client(proxyPort) - .newBuilder() - .callTimeout(10, TimeUnit.SECONDS) - .build() - return client.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw IOException("HTTP ${response.code}") - response.body.contentLength() + toast(app, app.getString(CommonsR.string.browser_download_started, name)) + io.execute { + val ok = + runCatching { + val request = + Request + .Builder() + .url(url) + .apply { + userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } + cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } + }.get() + .build() + // No end-to-end call timeout: a large file over Tor legitimately takes minutes. The + // client's read timeout still ends a transfer that stalls completely. + val client = + NappletBlobHttp + .client(proxyPort) + .newBuilder() + .callTimeout(0, TimeUnit.SECONDS) + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } + write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } + } + }.onFailure { Log.w(TAG, "Download failed for $url", it) } + .getOrDefault(false) + toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } - /** The OkHttp client for transfers through [proxyPort]: no end-to-end call timeout, as documented above. */ - private fun client(proxyPort: Int) = - NappletBlobHttp - .client(proxyPort) - .newBuilder() - .callTimeout(0, TimeUnit.SECONDS) - .build() - - private fun request( - url: String, - userAgent: String?, - cookie: String?, - ): Request.Builder = - Request - .Builder() - .url(url) - .apply { - userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } - cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } - } - - /** Saves a `data:` URL (`data:[mime][;base64],payload`). */ + /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly. */ fun saveDataUrl( context: Context, dataUrl: String, suggestedName: String?, ) { - val app = context.applicationContext - val header = dataUrl.substringBefore(',', "") - val payload = dataUrl.substringAfter(',', "") - val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" } - val bytes = - runCatching { - if (header.endsWith(";base64", ignoreCase = true)) { - Base64.decode(payload, Base64.DEFAULT) - } else { - URLDecoder.decode(payload, "UTF-8").toByteArray() - } - }.getOrNull() ?: return - saveBytes(app, suggestedName, mime, bytes) + val data = BrowserDownloadRules.decodeDataUrl(dataUrl) ?: return + val mime = data.mimeType ?: "application/octet-stream" + writeInBackground(context.applicationContext, safeName(suggestedName, mime), mime, data.bytes) } - /** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */ - fun saveBytes( - context: Context, - suggestedName: String?, - mimeType: String?, - bytes: ByteArray, - ) { - if (bytes.size > MAX_INLINE_BYTES) return - val app = context.applicationContext - val name = safeName(suggestedName, mimeType) - io.execute { - val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) - toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) - } - } - - /** - * Saves bytes a page handed over AFTER native consent: [name] is the already-sanitized, - * already-approved file name and [bytes] were decoded (and bounded) before the prompt, so this is - * exactly what the user saw on the consent card. Runs on the downloads executor and toasts the - * outcome, like every other path into [write]. - */ - fun saveInlineBytes( - context: Context, + private fun writeInBackground( + app: Context, name: String, mimeType: String?, bytes: ByteArray, ) { if (bytes.size > MAX_INLINE_BYTES) return - val app = context.applicationContext io.execute { val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } - /** - * The plain file name the sink would use for a page's suggestion: without path parts or control - * characters, else "download" + the MIME's extension. Exposed so a consent prompt can show — and - * approve — the exact name [write] will store, before any bytes move. - */ - fun sanitize( - suggested: String?, - mimeType: String?, - ): String = safeName(suggested, mimeType) - /** * Writes into the public Downloads collection (Android 10+, no permission needed), or into the app's * own Downloads folder on older versions, where writing the shared one would need a storage permission @@ -324,7 +278,7 @@ object BrowserDownloads { suggested ?.substringAfterLast('/') ?.substringAfterLast('\\') - ?.replace(Regex("[\\u0000-\\u001f:*?\"<>|]"), "_") + ?.replace(unsafeNameChars, "_") ?.trim() ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } if (base != null) return base.take(120) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt index b53275020b..cbb8c7246b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt @@ -30,13 +30,11 @@ package com.vitorpamplona.amethyst.napplethost * or the colour scheme changes, so the window can tint its system bars and Recents entry. * - `blob:` / `data:` downloads — only the page can read a `blob:` URL, so a click on (or a programmatic * `.click()` of) an `` pointing at one is turned into its bytes (`browser.download`). The - * native side treats that type as a request, not an authority: the sink stays gated behind this - * origin's consent token plus a per-download confirmation, so a page that forges the envelope gains - * nothing over using the script. + * native side treats it as a request: the user confirms every save on a consent card, so a page that + * posts the envelope itself gains nothing over using the script. * * Messages travel over the same origin-scoped native bridge as NIP-07; the host handles `browser.*` types - * itself and never forwards them to the broker's capability router (which is not a consent exemption — - * the download type is consent-gated by the host itself). + * itself and never forwards them to the broker. */ object BrowserExtrasScript { val JS: String = diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 4ebed03439..d724037235 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi @@ -225,6 +226,8 @@ class NappletBrowserActivity : ComponentActivity() { private val originTokens = mutableMapOf() private val pendingByOrigin = mutableMapOf>() private val mintInFlight = mutableSetOf() + private val downloadCooldown = DownloadCooldown() + private var preparingDownload = false /** * Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled @@ -361,17 +364,18 @@ class NappletBrowserActivity : ComponentActivity() { wv.webViewClient = BrowserClient() wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } - wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - BrowserDownloads.downloadWithConsent( - context = this, - url = url, - contentDisposition = contentDisposition, - mimeType = mimeType, - cookieHolder = { BrowserWebTools.cookieManager(wv).getCookie(url) }, - userAgent = userAgent, - proxyPort = if (useTor) proxyPort else -1, - ) { fileName, sizeBytes, risky, consent -> - offerListenerDownloadConsent(fileName, sizeBytes, risky, consent) + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> + val origin = + chrome + ?.ui + ?.chrome + ?.url + ?.let(BrowserChrome::originOf) ?: return@setDownloadListener + if (!canOfferDownload(origin)) return@setDownloadListener + // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. + val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) + prepareDownloadOffer(origin) { ready -> + BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (useTor) proxyPort else -1, ready) } } wv.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) @@ -903,14 +907,11 @@ class NappletBrowserActivity : ComponentActivity() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // The origin the WebView REPORTS — the page cannot forge this — keys every consent decision, - // including browser.* ones. Page-supplied fields are never trusted for that. + // The origin the WebView reports, which the page can't forge, keys every decision below. val origin = trustedOrigin(sourceOrigin) ?: return - // Browser conveniences (share, theme colour, blob downloads) are handled here, never forwarded - // to the broker's capability router. That is NOT a consent exemption: browser.download writes - // into the shared Downloads collection, so it is gated below by [offerDownloadConsent] on - // this WebView-reported origin. + // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. + // A download still asks the user first ([offerInlineDownload]): any top-frame script can post one. when (envelope.stringOrNull("type")) { "browser.share" -> { if (resumed) BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) @@ -923,7 +924,7 @@ class NappletBrowserActivity : ComponentActivity() { return } "browser.download" -> { - offerDownloadConsent(origin, envelope) + offerInlineDownload(origin, envelope) return } } @@ -957,66 +958,54 @@ class NappletBrowserActivity : ComponentActivity() { } /** - * The one-shot native consent card for a `browser.download` envelope, then the save. Shown keyed on - * the WebView-reported [origin] only; nothing about the envelope can trigger the save alone. The - * byte payload is fully decoded here — before the prompt — so the dialog names the true size and - * malformed or oversized payloads are refused without ever reaching MediaStore. Exactly one card is - * live per origin on this window at a time (a second request drops), so a page can't swap the name - * under the user's finger. + * A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script + * read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field. */ - private fun offerDownloadConsent( + private fun offerInlineDownload( origin: String, envelope: JsonObject, ) { - val data = envelope.stringOrNull("data")?.takeIf { it.startsWith("data:", ignoreCase = true) } ?: return - val save = BrowserDownloadGate.preludeInlineSave(data, envelope.stringOrNull("name")) ?: return - val host = chrome ?: return - if (!BrowserDownloadGate.shouldPrompt(SURFACE_KEY, origin)) return - // One live prompt per window: a second request while a card is up is dropped, and a live card is - // never replaced — the anti-swap rule the fingerprint under the user's finger relies on. - if (host.downloadPrompt != null) return - host.downloadPrompt = - BrowserChromeHost.PendingDownload( - host = BrowserChrome.displayHost(origin), - security = BrowserChrome.security(host.ui.chrome), - fileName = save.fileName, - sizeBytes = save.bytes.size.toLong(), - risky = BrowserDownloadGate.isRisky(save.fileName), - ) { allowed -> - if (allowed) BrowserDownloads.saveInlineBytes(this, save.fileName, save.mimeType, save.bytes) - } + if (!canOfferDownload(origin)) return + val data = envelope.stringOrNull("data") ?: return + prepareDownloadOffer(origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) } } /** - * The consent card for a WebView `DownloadListener` hit (a page-initiated `` navigation - * or `Content-Disposition: attachment`), offered by [BrowserDownloads.downloadWithConsent] after it - * probed the size. Shares the exact same gate and anti-swap rule as the `browser.download` bridge - * envelope: one live card per window, a live card is never replaced. + * Whether [origin] may put a download card up now: never over a card already showing (so a page can't + * swap the name under the user's finger) or one still being prepared (so a page can't queue decodes), + * and not within the cooldown after its last card was answered. */ - private fun offerListenerDownloadConsent( - fileName: String, - sizeBytes: Long, - risky: Boolean, - consent: BrowserDownloads.Consent, + private fun canOfferDownload(origin: String) = chrome?.downloadPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + + /** Runs [prepare] (which answers exactly once, on the main thread) and shows the offer it produces. */ + private fun prepareDownloadOffer( + origin: String, + prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit, + ) { + preparingDownload = true + prepare { offer -> + preparingDownload = false + if (offer != null) showDownloadOffer(origin, offer) + } + } + + /** Shows [offer]'s consent card; the file is fetched or written only if the user taps Save. */ + private fun showDownloadOffer( + origin: String, + offer: BrowserDownloads.DownloadOffer, ) { - val origin = - chrome - ?.ui - ?.chrome - ?.url - ?.let(BrowserChrome::originOf) ?: return - if (!BrowserDownloadGate.shouldPrompt(SURFACE_KEY, origin)) return val host = chrome ?: return - if (host.downloadPrompt != null) return + if (isDestroyed || !canOfferDownload(origin)) return host.downloadPrompt = BrowserChromeHost.PendingDownload( host = BrowserChrome.displayHost(origin), security = BrowserChrome.security(host.ui.chrome), - fileName = fileName, - sizeBytes = sizeBytes, - risky = risky, + fileName = offer.fileName, + sizeBytes = offer.sizeBytes, + risky = offer.risky, ) { allowed -> - if (allowed) consent.run() + downloadCooldown.answered(origin) + if (allowed) offer.save(this) } } @@ -1729,11 +1718,6 @@ class NappletBrowserActivity : ComponentActivity() { companion object { private const val TAG = "NappletBrowserActivity" - /** - * The one consent-gate surface key for this single-window Activity: unlike the embedded Service, - * one Activity hosts exactly one WebView, so there is only ever one surface to book. - */ - private const val SURFACE_KEY = "full-screen" private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity" /** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */ diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index f140673e0a..0cb87270e3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -185,13 +185,12 @@ object NappletBrowserContract { const val MSG_EXIT_FULLSCREEN = 33 /** - * Provider → client: a page's inline download needs the user's consent before its bytes are - * written into the shared Downloads collection ([browser.download] reaches a native file-write - * sink, so the gate lives at the sink, keyed on the WebView-reported [KEY_BROWSER_ORIGIN] — never - * a page-supplied field). Carries [KEY_DOWNLOAD_ID], the sanitized [KEY_DOWNLOAD_NAME], the exact - * byte size in [KEY_DOWNLOAD_SIZE], and [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). - * Answered with [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave - * the `:napplet` process. + * Provider → client: a download the page started (an attachment, ``, or an inline + * `browser.download`) needs the user's consent before anything is fetched or written into the shared + * Downloads collection. Carries [KEY_DOWNLOAD_ID], the WebView-reported [KEY_BROWSER_ORIGIN] (never a + * page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], and + * [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). Answered with + * [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave the `:napplet` process. */ const val MSG_DOWNLOAD_CONSENT = 34 @@ -226,7 +225,7 @@ object NappletBrowserContract { /** The sanitized file name the consented download will be stored under (already path/control-char stripped). */ const val KEY_DOWNLOAD_NAME = "downloadName" - /** The exact decoded byte count the consented download will write. */ + /** The size in bytes the consented download will write, or -1 when the server didn't say. */ const val KEY_DOWNLOAD_SIZE = "downloadSize" /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 00b5c5d95d..c8e0697818 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -65,6 +65,7 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -146,6 +147,11 @@ class NappletBrowserService : Service() { val pendingByOrigin = mutableMapOf>() val mintInFlight = mutableSetOf() + // Page-initiated downloads: how often each site may ask, and whether an offer is being prepared + // (decoded) — one at a time, so a page can't queue up decodes. + val downloadCooldown = DownloadCooldown() + var preparingDownload = false + val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) } @@ -154,11 +160,15 @@ class NappletBrowserService : Service() { // WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt. private val pendingWebPermissions = mutableMapOf() - // Inline-download consent cards the main process is showing: relay id → the save to run if the - // user allows. [consentTabs] scopes each id to its tab's session so a closing tab clears only its - // own; entries are removed when the client answers. - private val pendingDownloadConsents = mutableMapOf Unit>() - private val consentTabs = mutableMapOf() + // Download consent cards the main process is showing, by relay id, until the client answers or the + // tab closes. The offer holds the decoded bytes (or the URL to fetch) the card describes. + private class PendingDownload( + val sessionId: String, + val origin: String, + val offer: BrowserDownloads.DownloadOffer, + ) + + private val pendingDownloads = mutableMapOf() private var downloadSeq = 0L // The shim never changes; read+decode it once instead of per tab on the main thread. @@ -304,10 +314,10 @@ class NappletBrowserService : Service() { } NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT -> { val data = msg.data ?: return true - val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) - val answer = pendingDownloadConsents.remove(id) ?: return true - consentTabs.remove(id) - answer(data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)) + val pending = pendingDownloads.remove(data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)) ?: return true + val tab = tabs[pending.sessionId] ?: return true + tab.downloadCooldown.answered(pending.origin) + if (data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)) pending.offer.save(this) } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() @@ -460,12 +470,8 @@ class NappletBrowserService : Service() { // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() cancelPending(tab) - // A consent card parked behind this tab dies with it: an unanswered prompt saves nothing, and - // its decoded bytes are freed. Only this tab's entries are dropped (a sibling tab's is not). - pendingDownloadConsents.keys.filter { consentTabs[it] == sessionId }.forEach { id -> - pendingDownloadConsents.remove(id) - consentTabs.remove(id) - } + // An unanswered download card dies with its tab: nothing is saved, and its bytes are freed. + pendingDownloads.values.removeAll { it.sessionId == sessionId } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -481,18 +487,14 @@ class NappletBrowserService : Service() { BrowserWebTools.applyBrowserSettings(wv) wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) - wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> if (tab == null) return@setDownloadListener - BrowserDownloads.downloadWithConsent( - context = this, - url = url, - contentDisposition = contentDisposition, - mimeType = mimeType, - cookieHolder = { BrowserWebTools.cookieManager(wv).getCookie(url) }, - userAgent = userAgent, - proxyPort = if (tab.useTor) tab.proxyPort else -1, - ) { fileName, sizeBytes, risky, consent -> - offerListenerDownloadConsent(tab, fileName, sizeBytes, risky, consent) + val origin = wv.url?.let(BrowserChrome::originOf) ?: return@setDownloadListener + if (!canOfferDownload(tab, origin)) return@setDownloadListener + // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. + val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) + prepareDownloadOffer(tab, origin) { ready -> + BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (tab.useTor) tab.proxyPort else -1, ready) } } } @@ -983,15 +985,12 @@ class NappletBrowserService : Service() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // The origin the WebView REPORTS — the page cannot forge this — keys every consent decision, - // including browser.* ones. Page-supplied fields are never trusted for that. + // The origin the WebView reports, which the page can't forge, keys every decision below. val origin = trustedOrigin(sourceOrigin) ?: return - // Browser conveniences (share, blob downloads) are handled here, never forwarded to the broker's - // capability router; the theme colour only matters to a window with system bars, which an embedded - // tab doesn't own. That is NOT a consent exemption: browser.download writes into the shared - // Downloads collection, so it is gated below by [offerDownloadConsent] on this WebView-reported - // origin. + // Browser conveniences (share, blob downloads) are handled here, never brokered; a download still + // asks the user first ([offerInlineDownload]), since any top-frame script can post one. The theme + // colour only matters to a window with system bars, which an embedded tab doesn't own. when (envelope.stringOrNull("type")) { "browser.share" -> { BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) @@ -999,7 +998,7 @@ class NappletBrowserService : Service() { } "browser.themeColor" -> return "browser.download" -> { - offerDownloadConsent(tab, origin, envelope) + offerInlineDownload(tab, origin, envelope) return } } @@ -1043,72 +1042,62 @@ class NappletBrowserService : Service() { } /** - * The one-shot native consent card for a `browser.download` envelope, relayed to the main process - * (this provider has no window to show one on), then the save. Keyed on the WebView-reported - * [origin] only; nothing about the envelope can trigger the save alone. The byte payload is fully - * decoded here — before the prompt — so the dialog names the true size and malformed or oversized - * payloads are refused without ever reaching MediaStore. Exactly one card is live per tab at a time - * (a second request drops), so a page can't swap the name under the user's finger. + * A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script + * read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field. */ - private fun offerDownloadConsent( + private fun offerInlineDownload( tab: BrowserTab, origin: String, envelope: JsonObject, ) { - val data = envelope.stringOrNull("data")?.takeIf { it.startsWith("data:", ignoreCase = true) } ?: return - val save = BrowserDownloadGate.preludeInlineSave(data, envelope.stringOrNull("name")) ?: return - if (!BrowserDownloadGate.shouldPrompt(tab.sessionId, origin)) return - if (hasLiveDownloadConsent(tab)) return - val id = ++downloadSeq - val sent = - sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { - putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) - putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) - putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, save.fileName) - putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, save.bytes.size.toLong()) - putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, BrowserDownloadGate.isRisky(save.fileName)) - } - if (sent) { - consentTabs[id] = tab.sessionId - pendingDownloadConsents[id] = { allowed -> - if (allowed) BrowserDownloads.saveInlineBytes(this, save.fileName, save.mimeType, save.bytes) - } + if (!canOfferDownload(tab, origin)) return + val data = envelope.stringOrNull("data") ?: return + prepareDownloadOffer(tab, origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) } + } + + /** + * Whether [origin] may put a download card up in [tab] now: never over the tab's card already showing + * (so a page can't swap the name under the user's finger) or one still being prepared (so a page + * can't queue decodes), and not within the cooldown after its last card was answered. + */ + private fun canOfferDownload( + tab: BrowserTab, + origin: String, + ) = !tab.preparingDownload && pendingDownloads.values.none { it.sessionId == tab.sessionId } && tab.downloadCooldown.allows(origin) + + /** Runs [prepare] (which answers exactly once, on the main thread) and relays the offer it produces. */ + private fun prepareDownloadOffer( + tab: BrowserTab, + origin: String, + prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit, + ) { + tab.preparingDownload = true + prepare { offer -> + tab.preparingDownload = false + if (offer != null) showDownloadOffer(tab, origin, offer) } } - /** Whether this tab's consent card is still on screen (a second request for it is dropped — the anti-swap rule). */ - private fun hasLiveDownloadConsent(tab: BrowserTab): Boolean = pendingDownloadConsents.keys.any { consentTabs[it] == tab.sessionId } - /** - * The consent card for a WebView `DownloadListener` hit in this tab (a page-initiated `` - * or `Content-Disposition: attachment`), offered by [BrowserDownloads.downloadWithConsent] after it - * probed the size. Shares the exact same one-live-card-per-tab anti-swap rule as the bridge envelope. + * Asks the main process to show [offer]'s consent card (this provider has no window of its own); the + * file is fetched or written only if the user taps Save there. */ - private fun offerListenerDownloadConsent( + private fun showDownloadOffer( tab: BrowserTab, - fileName: String, - sizeBytes: Long, - risky: Boolean, - consent: BrowserDownloads.Consent, + origin: String, + offer: BrowserDownloads.DownloadOffer, ) { - val origin = tab.webView?.url?.let(BrowserChrome::originOf) ?: return - if (!BrowserDownloadGate.shouldPrompt(tab.sessionId, origin)) return - if (hasLiveDownloadConsent(tab)) return + if (tabs[tab.sessionId] !== tab || !canOfferDownload(tab, origin)) return val id = ++downloadSeq val sent = sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) - putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, fileName) - putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, sizeBytes) - putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, risky) + putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, offer.fileName) + putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, offer.sizeBytes) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, offer.risky) } - if (sent) { - consentTabs[id] = tab.sessionId - pendingDownloadConsents[id] = { allowed -> - if (allowed) consent.run() - } - } + if (sent) pendingDownloads[id] = PendingDownload(tab.sessionId, origin, offer) } private fun requestBrowserToken( From a9c755e67b0c75d53d3887597398e578cd9a5207 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:37:28 +0000 Subject: [PATCH 5/6] refactor: rename RenderZappedPost to RenderTargetNote The helper quotes whatever note an activity event acts on (its last replyTo) and is shared by zaps, nutzaps, onchain/bolt12 zaps, reactions and now git status events, so the zap-specific name was misleading. Moved it out of ZapEvent.kt into its own file; no behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PicvdWRJ33zh4MCfjZj2kQ --- .../commons/ui/note/types/Bolt12ZapEvent.kt | 2 +- .../commons/ui/note/types/GitStatus.kt | 2 +- .../amethyst/commons/ui/note/types/Nutzap.kt | 2 +- .../commons/ui/note/types/OnchainZapEvent.kt | 2 +- .../commons/ui/note/types/Reaction.kt | 2 +- .../commons/ui/note/types/RenderTargetNote.kt | 58 +++++++++++++++++++ .../commons/ui/note/types/ZapEvent.kt | 30 +--------- 7 files changed, 64 insertions(+), 34 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RenderTargetNote.kt diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Bolt12ZapEvent.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Bolt12ZapEvent.kt index 13bf7db86b..ea4c1bf0e4 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Bolt12ZapEvent.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Bolt12ZapEvent.kt @@ -89,7 +89,7 @@ fun RenderBolt12Zap( }, ) - RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav) amountSats?.let { ActivityAmountRow(PlatformNumberFormatter().format(it), orange) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt index d4245938c4..1ad0f90f4d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/GitStatus.kt @@ -64,6 +64,6 @@ fun RenderGitStatusEvent( if (note.replyTo?.lastOrNull() != null) { Spacer(modifier = StdVertSpacer) - RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav) + RenderTargetNote(note, quotesLeft, backgroundColor, accountViewModel, nav) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Nutzap.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Nutzap.kt index 53e1b2bcb0..cea03f5759 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Nutzap.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Nutzap.kt @@ -93,7 +93,7 @@ fun RenderNutzap( }, ) - RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav) ActivityAmountRow(showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), orange) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/OnchainZapEvent.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/OnchainZapEvent.kt index 39c14e65c7..515b4bb5db 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/OnchainZapEvent.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/OnchainZapEvent.kt @@ -150,7 +150,7 @@ fun RenderOnchainZap( nav = nav, ) - RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav) AmountRow(sats = sats, orange = orange) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Reaction.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Reaction.kt index ec85bfb015..7c004995d0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Reaction.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Reaction.kt @@ -81,6 +81,6 @@ fun RenderReaction( }, ) - RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RenderTargetNote.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RenderTargetNote.kt new file mode 100644 index 0000000000..6256da07b2 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RenderTargetNote.kt @@ -0,0 +1,58 @@ +/* + * 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.ui.note.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel + +/** + * Quotes the note that [note] acts on (the last entry of its `replyTo`) as a + * short embedded card: the post a zap or reaction targets, the issue/patch/PR + * a git status changes, and so on. + */ +@Composable +fun RenderTargetNote( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + note.replyTo?.lastOrNull()?.let { + NoteCompose( + it, + modifier = Modifier, + isBoostedNote = true, + makeItShort = true, + unPackReply = ReplyRenderType.NONE, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/ZapEvent.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/ZapEvent.kt index 3d46b37ce2..91fbfaf5a7 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/ZapEvent.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/ZapEvent.kt @@ -52,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.note.ActivityCardFrame import com.vitorpamplona.amethyst.commons.ui.note.ActivityHeaderRow import com.vitorpamplona.amethyst.commons.ui.note.CrossfadeToDisplayComment import com.vitorpamplona.amethyst.commons.ui.note.DisplayBlankAuthor -import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.note.UserPicture import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier @@ -70,33 +69,6 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -/** - * Shows the post a zap targets above the transfer card, mirroring how - * reactions and reposts embed their target. - */ -@Composable -fun RenderZappedPost( - zapNote: Note, - quotesLeft: Int, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: INav, -) { - zapNote.replyTo?.lastOrNull()?.let { - NoteCompose( - it, - modifier = Modifier, - isBoostedNote = true, - makeItShort = true, - unPackReply = ReplyRenderType.NONE, - quotesLeft = quotesLeft - 1, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - @Composable fun RenderZapReceipt( note: Note, @@ -155,7 +127,7 @@ fun RenderZapReceiptCard( }, ) - RenderZappedPost(note, quotesLeft, cardBackground, accountViewModel, nav) + RenderTargetNote(note, quotesLeft, cardBackground, accountViewModel, nav) card.amount?.let { ActivityAmountRow(it, orange) } From d51484d538c9927b50fecc43a4760d6bcc2bec83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:54:22 +0000 Subject: [PATCH 6/6] fix(browser): close the gaps an audit found in download consent - The saved file's MIME type now comes from the approved name, so MediaStore can't append a different extension than the card showed ("invoice.pdf" typed as an APK would have become "invoice.pdf.apk"). - File names drop bidi/zero-width/C1 characters, keep their extension when shortened, and render on one middle-ellipsized line. - Refusals back off (1s, doubling to 1 min; Save resets), no card over another page dialog or permission prompt, and Save ignores taps for 500 ms after the card appears. - The card names the host a network file comes from when it isn't the page's (an ad frame, a CDN); a fresh about:blank popup falls back to it instead of silently dropping the download. - Offers save at most once (no double file on a double tap); a decode failure (even OOM) refuses the download instead of killing :napplet; percent-decoding counts bytes before allocating; ;base64 must be the last parameter; more risky extensions. - A closing embedded tab withdraws its card in the main process. - The long-press data: save decodes off the main thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3cqjbY7FX2zrkGXCVXpFD --- .../loggedIn/browser/EmbeddedPageRequests.kt | 1 + .../browser/EmbeddedWebAppController.kt | 11 +- .../screen/loggedIn/browser/WebAppScreen.kt | 8 +- .../commons/browser/BrowserDownloadRules.kt | 171 +++++++++++++----- .../browser/BrowserDownloadRulesTest.kt | 66 ++++++- .../composeResources/values/strings.xml | 1 + .../browser/ui/pill/DownloadPromptCard.kt | 39 +++- .../amethyst/napplethost/BrowserChromeHost.kt | 2 + .../amethyst/napplethost/BrowserDownloads.kt | 86 +++++---- .../napplethost/NappletBrowserActivity.kt | 17 +- .../napplethost/NappletBrowserContract.kt | 8 +- .../napplethost/NappletBrowserService.kt | 30 ++- 12 files changed, 333 insertions(+), 107 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt index 67119d18c3..b7ed7888e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -51,5 +51,6 @@ data class EmbeddedDownloadRequest( val origin: String, val fileName: String, val sizeBytes: Long, + val sourceHost: String?, val risky: Boolean, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index df043eb826..2e56d97b8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -129,7 +129,7 @@ class EmbeddedWebAppController( /** The camera / microphone / location request the page is waiting on, if any. */ val pendingPermission = mutableStateOf(null) - /** The inline download awaiting the user's consent, if any. */ + /** The download the page started that awaits the user's consent, if any. */ val pendingDownload = mutableStateOf(null) /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ @@ -377,7 +377,7 @@ class EmbeddedWebAppController( val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN) val name = data.getString(NappletBrowserContract.KEY_DOWNLOAD_NAME).orEmpty() // An unnamed/absent origin means the sandbox couldn't even state who is asking: refuse. - if (origin == null || name.isEmpty() || pendingDownload.value != null) { + if (origin == null || name.isEmpty() || pendingDownload.value != null || pendingDialog.value != null || pendingPermission.value != null) { answerDownload(id, allowed = false) return true } @@ -387,9 +387,14 @@ class EmbeddedWebAppController( origin = origin, fileName = name, sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, -1L), + sourceHost = data.getString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE), risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), ) } + NappletBrowserContract.MSG_DOWNLOAD_CANCEL -> { + val id = msg.data?.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + if (pendingDownload.value?.id == id) pendingDownload.value = null + } NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true @@ -509,7 +514,7 @@ class EmbeddedWebAppController( } } - /** Answers the download-consent card for [id]: true saves the bytes the sandbox already holds. */ + /** Answers the download-consent card for [id]: true lets the sandbox fetch or write the file it described. */ fun answerDownload( id: Long, allowed: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 36b516a1b7..b66cedceca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -410,10 +410,9 @@ private fun EmbeddedPageUi( } } - // A download the page started: nothing is fetched or saved until this is answered. - // The card shows the sanitized file name and exact byte count the sandbox will write, headed by the - // WebView-reported origin (never a page-supplied field), with a warning for installer/script-like - // extensions — the social-engineering payload names the disclosure calls out ("invoice.apk"). + // A download the page started: nothing is fetched or saved until this is answered. The card shows the + // file name that would be saved, its size and source host, headed by the WebView-reported origin (never + // a page-supplied field), with a warning for names that can be installed or run ("invoice.apk"). val downloadRequest by controller.pendingDownload downloadRequest?.let { request -> Dialog(onDismissRequest = { controller.answerDownload(request.id, allowed = false) }) { @@ -422,6 +421,7 @@ private fun EmbeddedPageUi( security = ui.security, fileName = request.fileName, sizeBytes = request.sizeBytes, + sourceHost = request.sourceHost, risky = request.risky, onAllow = { controller.answerDownload(request.id, allowed = true) }, onDeny = { controller.answerDownload(request.id, allowed = false) }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt index 6b85a01384..fe22abfa1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt @@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.commons.browser import kotlin.io.encoding.Base64 import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeMark import kotlin.time.TimeSource /** - * The platform-free rules behind the in-app browser's download consent: which file names deserve a - * warning, how a page-supplied `data:` URL turns into the bytes a consent card describes, and how often - * one site may ask. + * The platform-free rules behind the in-app browser's download consent: what a saved file may be named, + * which names deserve a warning, how a page-supplied `data:` URL turns into the bytes a consent card + * describes, and how often one site may ask. * * A page can start a download without any gesture (a navigation to an attachment, a script `.click()` * on an ``, or a hand-forged bridge envelope), so every page-initiated save asks the user @@ -39,12 +40,16 @@ object BrowserDownloadRules { /** Cap for bytes a page hands over inline (a `blob:`/`data:` download travels as base64 over the bridge). */ const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + /** Longest file name kept; longer ones are shortened in the middle so the extension survives. */ + const val MAX_NAME_LENGTH = 120 + /** - * Extensions something can install or run from. The consent card flags them; the name itself is - * never rewritten, so the user judges the real one. + * Extensions something can install, run, or open as a live page from. The consent card flags them; + * the name itself is never rewritten, so the user judges the real one. */ val RISKY_EXTENSIONS = setOf( + // Android "apk", "apks", "apkm", @@ -53,22 +58,34 @@ object BrowserDownloadRules { "jar", "dex", "so", + // Windows "exe", "msi", + "msix", + "appx", "bat", "cmd", "com", + "cpl", + "pif", + "reg", "scr", "hta", "ps1", + "js", + "jse", "vbs", + "vbe", "wsf", "lnk", "url", + // Apple "dmg", "pkg", "app", + "ipa", "command", + // Linux "deb", "rpm", "appimage", @@ -77,15 +94,48 @@ object BrowserDownloadRules { "zsh", "bash", "desktop", + // Pages that run script when opened "html", "htm", "xhtml", + "xht", + "mht", + "mhtml", "svg", + "svgz", ) /** Whether [fileName]'s last extension is one a user should double-check before saving. */ fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").trim().lowercase() in RISKY_EXTENSIONS + // Path-reserved characters become "_"; C0/C1 controls, DEL, bidi controls and zero-width characters + // are dropped outright, since they can make "invoice[RLO]gpj.apk" render as "invoicekpa.jpg". + private val reservedNameChars = Regex("[:*?\"<>|]") + private val invisibleNameChars = Regex("[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2069\\ufeff]") + + /** + * A plain file name from a page's suggestion: no path parts, no reserved or invisible characters, at + * most [MAX_NAME_LENGTH] long with its extension kept. Null when nothing usable is left. + */ + fun safeFileName(suggested: String?): String? { + val base = + suggested + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.replace(invisibleNameChars, "") + ?.replace(reservedNameChars, "_") + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } + ?: return null + if (base.length <= MAX_NAME_LENGTH) return base + val ext = base.substringAfterLast('.', "") + return if (ext.isNotEmpty() && ext.length <= 16) { + base.take(MAX_NAME_LENGTH - ext.length - 1).trimEnd() + "." + ext + } else { + base.take(MAX_NAME_LENGTH) + } + } + /** A decoded `data:` URL: the declared MIME type (null when absent) and the payload bytes. */ class DataUrl( val mimeType: String?, @@ -106,58 +156,85 @@ object BrowserDownloadRules { if (!dataUrl.startsWith("data:", ignoreCase = true)) return null val comma = dataUrl.indexOf(',') if (comma < 0) return null - val header = dataUrl.substring(5, comma) - val params = header.split(';') + val params = dataUrl.substring(5, comma).split(';') val mime = params .first() .trim() .lowercase() .takeIf { it.isNotEmpty() } - val isBase64 = params.drop(1).any { it.trim().equals("base64", ignoreCase = true) } + val isBase64 = params.size > 1 && params.last().trim().equals("base64", ignoreCase = true) val payloadLength = dataUrl.length - comma - 1 val bytes = if (isBase64) { - // 4 chars per 3 bytes, plus slack for padding and the line breaks a lenient decoder skips. - if (payloadLength > maxBytes / 3 * 4 + 1024) return null + // 4 chars per 3 bytes, plus room for the CRLF a MIME encoder puts every 76 chars. + if (payloadLength.toLong() > maxBytes / 3 * 4L + maxBytes / 57 * 2L + 1024) return null runCatching { lenientBase64.decode(dataUrl, comma + 1, dataUrl.length) }.getOrNull() ?: return null } else { - // A percent escape is 3 chars per byte, a literal char up to 4 UTF-8 bytes. - if (payloadLength > maxBytes) return null - percentDecode(dataUrl, comma + 1) ?: return null + // A percent escape is 3 chars per byte; the exact size is counted before allocating. + if (payloadLength.toLong() > maxBytes * 3L) return null + percentDecode(dataUrl, comma + 1, maxBytes) ?: return null } if (bytes.size > maxBytes) return null return DataUrl(mime, bytes) } - /** RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`); null on a broken escape. */ + /** + * RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`, other characters are UTF-8). + * Null on a broken escape or when the result would exceed [maxBytes], checked before allocating it. + */ private fun percentDecode( text: String, start: Int, + maxBytes: Int, ): ByteArray? { - val source = text.substring(start).encodeToByteArray() - val out = ByteArray(source.size) - var read = 0 - var written = 0 - while (read < source.size) { - val b = source[read] - if (b == '%'.code.toByte()) { - if (read + 2 >= source.size) return null - val hi = hexValue(source[read + 1]) - val lo = hexValue(source[read + 2]) - if (hi < 0 || lo < 0) return null - out[written++] = ((hi shl 4) or lo).toByte() - read += 3 + var size = 0L + var i = start + while (i < text.length) { + val c = text[i] + if (c == '%') { + if (i + 2 >= text.length || hexValue(text[i + 1]) < 0 || hexValue(text[i + 2]) < 0) return null + size += 1 + i += 3 + } else if (c.isHighSurrogate() && i + 1 < text.length && text[i + 1].isLowSurrogate()) { + size += 4 + i += 2 } else { - out[written++] = b - read++ + // A lone surrogate encodes as U+FFFD, 3 bytes, like any other char from U+0800 up. + size += + when { + c.code < 0x80 -> 1 + c.code < 0x800 -> 2 + else -> 3 + } + i++ + } + if (size > maxBytes) return null + } + val out = ByteArray(size.toInt()) + var written = 0 + i = start + var runStart = start + while (i <= text.length) { + if (i == text.length || text[i] == '%') { + if (i > runStart) { + val run = text.encodeToByteArray(runStart, i) + run.copyInto(out, written) + written += run.size + } + if (i == text.length) break + out[written++] = ((hexValue(text[i + 1]) shl 4) or hexValue(text[i + 2])).toByte() + i += 3 + runStart = i + } else { + i++ } } - return out.copyOf(written) + return if (written == out.size) out else out.copyOf(written) } - private fun hexValue(b: Byte): Int = - when (val c = b.toInt().toChar()) { + private fun hexValue(c: Char): Int = + when (c) { in '0'..'9' -> c - '0' in 'a'..'f' -> c - 'a' + 10 in 'A'..'F' -> c - 'A' + 10 @@ -166,21 +243,33 @@ object BrowserDownloadRules { } /** - * How often one browser surface (a window, or one embedded tab) lets a site show a download card: after - * the user answers a site's card, that site can't raise another for [window]. Without it a page that is - * refused can re-ask in a loop, so Cancel would never make the card go away. Main-thread only. + * How often one browser surface (a window, or one embedded tab) lets a site show a download card. After + * the user refuses a site's card, it waits [base] before it may ask again, and each further refusal + * doubles that, up to [max]; a Save resets it. Without this a refused page could re-ask in a loop until + * the user gives in. Main-thread only. */ class DownloadCooldown( - private val window: Duration = 1.seconds, + private val base: Duration = 1.seconds, + private val max: Duration = 1.minutes, private val timeSource: TimeSource = TimeSource.Monotonic, ) { - private val answeredAt = HashMap() + private class Hold( + val since: TimeMark, + val window: Duration, + ) + + private val holds = HashMap() /** Whether [origin] may show a card now. */ - fun allows(origin: String): Boolean = answeredAt[origin]?.let { it.elapsedNow() >= window } ?: true + fun allows(origin: String): Boolean = holds[origin]?.let { it.since.elapsedNow() >= it.window } ?: true - /** Starts [origin]'s cooldown: the user just answered (or the surface dismissed) its card. */ - fun answered(origin: String) { - answeredAt[origin] = timeSource.markNow() + /** The user answered [origin]'s card: [allowed] resets its wait, a refusal (or dismissal) doubles it. */ + fun answered( + origin: String, + allowed: Boolean, + ) { + val previous = holds[origin]?.window + val window = if (allowed || previous == null) base else minOf(previous * 2, max) + holds[origin] = Hold(timeSource.markNow(), window) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt index e186292c62..95b929c5b9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt @@ -28,6 +28,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.TestTimeSource @@ -81,13 +82,48 @@ class BrowserDownloadRulesTest { assertNull(BrowserDownloadRules.decodeDataUrl("data:," + "a".repeat(11), maxBytes = 10)) } + @Test + fun base64MarkerMustBeTheLastParameter() { + assertEquals("aGk", BrowserDownloadRules.decodeDataUrl("data:text/plain;base64;x=y,aGk")!!.bytes.decodeToString()) + } + + @Test + fun percentDecodingCountsBytesBeforeAllocating() { + assertEquals(3, BrowserDownloadRules.decodeDataUrl("data:,%E4%B8%AD", maxBytes = 3)!!.bytes.size) + assertEquals("中", BrowserDownloadRules.decodeDataUrl("data:,中", maxBytes = 3)!!.bytes.decodeToString()) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,中中", maxBytes = 5)) + assertEquals(4, BrowserDownloadRules.decodeDataUrl("data:,\uD83D\uDE00", maxBytes = 4)!!.bytes.size) + // A lone surrogate must not overflow the pre-counted buffer. + assertTrue(BrowserDownloadRules.decodeDataUrl("data:,a\uDE00b%41")!!.bytes.isNotEmpty()) + } + + @Test + fun safeFileNameStripsPathsAndInvisibleCharacters() { + assertEquals("evil.apk", BrowserDownloadRules.safeFileName("../../evil.apk")) + assertEquals("evil.apk", BrowserDownloadRules.safeFileName("C:\\x\\evil.apk")) + assertEquals("a_b_.pdf", BrowserDownloadRules.safeFileName("a:b?.pdf")) + assertEquals("invoicegpj.apk", BrowserDownloadRules.safeFileName("invoice\u202Egpj.apk")) + assertEquals("ab.txt", BrowserDownloadRules.safeFileName("a\u200Bb\u0085.txt")) + assertNull(BrowserDownloadRules.safeFileName("..")) + assertNull(BrowserDownloadRules.safeFileName(" \u200F ")) + assertNull(BrowserDownloadRules.safeFileName(null)) + } + + @Test + fun safeFileNameKeepsTheExtensionWhenShortening() { + val name = BrowserDownloadRules.safeFileName("photo.jpg" + "x".repeat(300) + ".apk")!! + assertEquals(BrowserDownloadRules.MAX_NAME_LENGTH, name.length) + assertTrue(name.endsWith(".apk")) + assertTrue(BrowserDownloadRules.isRisky(name)) + } + @Test fun cooldownHoldsOffOnlyTheAnsweredOrigin() { val clock = TestTimeSource() - val cooldown = DownloadCooldown(1.seconds, clock) + val cooldown = DownloadCooldown(1.seconds, 1.minutes, clock) assertTrue(cooldown.allows("https://a.example")) - cooldown.answered("https://a.example") + cooldown.answered("https://a.example", allowed = true) assertFalse(cooldown.allows("https://a.example")) assertTrue(cooldown.allows("https://b.example")) @@ -96,4 +132,30 @@ class BrowserDownloadRulesTest { clock += 1.milliseconds assertTrue(cooldown.allows("https://a.example")) } + + @Test + fun cooldownDoublesOnEachRefusalAndResetsOnSave() { + val clock = TestTimeSource() + val cooldown = DownloadCooldown(1.seconds, 4.seconds, clock) + val site = "https://a.example" + + cooldown.answered(site, allowed = false) // 1s + clock += 1.seconds + assertTrue(cooldown.allows(site)) + cooldown.answered(site, allowed = false) // 2s + clock += 1.seconds + assertFalse(cooldown.allows(site)) + clock += 1.seconds + assertTrue(cooldown.allows(site)) + cooldown.answered(site, allowed = false) // 4s + cooldown.answered(site, allowed = false) // capped at 4s + clock += 3.seconds + assertFalse(cooldown.allows(site)) + clock += 1.seconds + assertTrue(cooldown.allows(site)) + + cooldown.answered(site, allowed = true) // back to 1s + clock += 1.seconds + assertTrue(cooldown.allows(site)) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index eb937376ff..b7db85531f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6360,4 +6360,5 @@ Download this file? This file type can run code. Check that the name matches what you meant to get. Save + From %1$s diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt index 09d52f47a1..22ea19d944 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt @@ -38,6 +38,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,17 +54,21 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_from import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_risky import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_save import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_title import com.vitorpamplona.amethyst.commons.ui.note.types.formatBytes import com.vitorpamplona.amethyst.commons.ui.stringRes +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds /** * A download the page started, waiting for consent before anything is fetched or written to the shared * Downloads collection. A page can start one without any gesture, so the card names the site (the - * WebView-reported origin, never a page-supplied field), the exact file name that would be saved and its - * size ([sizeBytes] is -1 when the server didn't say), and nothing is saved until the user taps Save. + * WebView-reported origin, never a page-supplied field), the exact file name that would be saved, its + * size ([sizeBytes] is -1 when the server didn't say) and, for a network file, the host it comes from + * ([sourceHost]) when that isn't [host]. Nothing is saved until the user taps Save. */ @Composable fun DownloadPromptCard( @@ -67,10 +76,18 @@ fun DownloadPromptCard( security: BrowserChrome.Security, fileName: String, sizeBytes: Long, + sourceHost: String?, risky: Boolean, onAllow: () -> Unit, onDeny: () -> Unit, ) { + // Save ignores taps for a moment after the card appears, so a tap meant for whatever was under the + // finger (say, a page dialog's button the page just replaced with this card) can't land on it. + var armed by remember(fileName) { mutableStateOf(false) } + LaunchedEffect(fileName) { + delay(ARM_DELAY) + armed = true + } PageCard { if (host != null) { OriginBadge(host, security) @@ -90,10 +107,16 @@ fun DownloadPromptCard( } Spacer(Modifier.width(14.dp)) Column { - // Never cut the end off: the extension is what tells "invoice.pdf" from "invoice.pdf.apk". - Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 3, overflow = TextOverflow.MiddleEllipsis) - if (sizeBytes >= 0) { - Text(formatBytes(sizeBytes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + // One line, cut in the middle: the extension is what tells "invoice.pdf" from "invoice.pdf.apk". + Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + val details = + listOfNotNull( + sizeBytes.takeIf { it >= 0 }?.let { formatBytes(it) }, + // A network file can come from another host than the page asking (an ad frame, a CDN). + sourceHost?.takeUnless { it.equals(host, ignoreCase = true) }?.let { stringRes(Res.string.browser_pill_download_from, it) }, + ) + if (details.isNotEmpty()) { + Text(details.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } } @@ -116,7 +139,9 @@ fun DownloadPromptCard( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = onDeny) { Text(stringRes(Res.string.browser_pill_cancel)) } Spacer(Modifier.width(8.dp)) - Button(onClick = onAllow) { Text(stringRes(Res.string.browser_pill_download_save)) } + Button(onClick = onAllow, enabled = armed) { Text(stringRes(Res.string.browser_pill_download_save)) } } } } + +private val ARM_DELAY = 500.milliseconds diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index ad595fd2fb..2950d2c9d4 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -141,6 +141,7 @@ class BrowserChromeHost( val security: BrowserChrome.Security, val fileName: String, val sizeBytes: Long, + val sourceHost: String?, val risky: Boolean, val answer: (allow: Boolean) -> Unit, ) @@ -371,6 +372,7 @@ class BrowserChromeHost( security = pending.security, fileName = pending.fileName, sizeBytes = pending.sizeBytes, + sourceHost = pending.sourceHost, risky = pending.risky, onAllow = { downloadPrompt = null diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt index 31a7d57ae1..1676f074d2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -30,6 +30,7 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import android.webkit.URLUtil import android.widget.Toast +import androidx.core.net.toUri import com.vitorpamplona.amethyst.commons.browser.BrowserDownloadRules import com.vitorpamplona.quartz.utils.Log import okhttp3.Request @@ -37,6 +38,7 @@ import java.io.File import java.io.OutputStream import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import com.vitorpamplona.amethyst.commons.R as CommonsR /** @@ -67,22 +69,27 @@ object BrowserDownloads { private val decoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-download-decode").apply { isDaemon = true } } private val main = Handler(Looper.getMainLooper()) - private val unsafeNameChars = Regex("[\\u0000-\\u001f:*?\"<>|]") - /** - * A page-initiated download, resolved to exactly what its consent card shows: [save] writes a file - * named [fileName] and nothing else, and until it runs no byte has been fetched or written. + * A page-initiated download, resolved to what its consent card shows: [save] stores one file named + * [fileName], typed by that name's extension (so the system can't append a different one), and until + * it runs no byte has been fetched or written. [save] runs at most once, however often it is called. */ class DownloadOffer internal constructor( val fileName: String, - /** The exact size in bytes, or -1 when the server didn't say. */ + /** The size in bytes: exact for inline data, the server's advertised length otherwise; -1 when unknown. */ val sizeBytes: Long, + /** The host a network download is fetched from (it can differ from the page's), or null for inline data. */ + val sourceHost: String?, private val start: (Context) -> Unit, ) { + private val started = AtomicBoolean(false) + /** Whether [fileName] is something that can be installed or run, which the card warns about. */ val risky: Boolean get() = BrowserDownloadRules.isRisky(fileName) - fun save(context: Context) = start(context.applicationContext) + fun save(context: Context) { + if (started.compareAndSet(false, true)) start(context.applicationContext) + } } /** @@ -105,7 +112,7 @@ object BrowserDownloads { return } if (!isHttp(url)) return - startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, mimeType, cookie, proxyPort) + startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, cookie, proxyPort) } /** @@ -134,8 +141,8 @@ object BrowserDownloads { } val name = networkName(url, contentDisposition, mimeType) onReady( - DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L) { app -> - startNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) + DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L, url.toUri().host) { app -> + startNetworkDownload(app, url, name, userAgent, cookie, proxyPort) }, ) } @@ -151,10 +158,17 @@ object BrowserDownloads { onReady: (DownloadOffer?) -> Unit, ) { decoder.execute { + // Throwable, not Exception: an OutOfMemoryError here must refuse the download, not kill the + // process (and every tab in it) or leave the caller waiting on a callback that never comes. val offer = - BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data -> - val name = safeName(suggestedName, data.mimeType) - DownloadOffer(name, data.bytes.size.toLong()) { app -> writeInBackground(app, name, data.mimeType, data.bytes) } + try { + BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data -> + val name = safeName(suggestedName, data.mimeType) + DownloadOffer(name, data.bytes.size.toLong(), null) { app -> writeInBackground(app, name, data.bytes) } + } + } catch (e: Throwable) { + Log.w(TAG, "Inline download refused", e) + null } main.post { onReady(offer) } } @@ -173,7 +187,6 @@ object BrowserDownloads { url: String, name: String, userAgent: String?, - mimeType: String?, cookie: String?, proxyPort: Int, ) { @@ -200,8 +213,7 @@ object BrowserDownloads { .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) error("HTTP ${response.code}") - val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } - write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } + write(app, name) { out -> response.body.byteStream().use { it.copyTo(out) } } } }.onFailure { Log.w(TAG, "Download failed for $url", it) } .getOrDefault(false) @@ -209,26 +221,26 @@ object BrowserDownloads { } } - /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly. */ - fun saveDataUrl( - context: Context, + /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly, decoded off the main thread. */ + private fun saveDataUrl( + app: Context, dataUrl: String, suggestedName: String?, ) { - val data = BrowserDownloadRules.decodeDataUrl(dataUrl) ?: return - val mime = data.mimeType ?: "application/octet-stream" - writeInBackground(context.applicationContext, safeName(suggestedName, mime), mime, data.bytes) + decoder.execute { + val data = runCatching { BrowserDownloadRules.decodeDataUrl(dataUrl) }.getOrNull() ?: return@execute + writeInBackground(app, safeName(suggestedName, data.mimeType), data.bytes) + } } private fun writeInBackground( app: Context, name: String, - mimeType: String?, bytes: ByteArray, ) { if (bytes.size > MAX_INLINE_BYTES) return io.execute { - val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) + val ok = runCatching { write(app, name) { it.write(bytes) } }.getOrDefault(false) toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } @@ -241,7 +253,6 @@ object BrowserDownloads { private fun write( context: Context, name: String, - mimeType: String?, body: (OutputStream) -> Unit, ): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -249,7 +260,9 @@ object BrowserDownloads { val values = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, name) - mimeType?.let { put(MediaStore.Downloads.MIME_TYPE, it) } + // Typed by the approved name alone: a page- or server-supplied type that disagrees with + // the extension would make MediaStore append its own ("invoice.pdf" -> "invoice.pdf.apk"). + put(MediaStore.Downloads.MIME_TYPE, mimeTypeOf(name)) put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.Downloads.IS_PENDING, 1) } @@ -269,23 +282,28 @@ object BrowserDownloads { return true } - /** A plain filename: the page's suggestion without path parts, else "download" + the MIME's extension. */ + /** + * The file name to save under: the page's suggestion made safe ([BrowserDownloadRules.safeFileName]), + * else "download" + the MIME's extension. + */ private fun safeName( suggested: String?, mimeType: String?, ): String { - val base = - suggested - ?.substringAfterLast('/') - ?.substringAfterLast('\\') - ?.replace(unsafeNameChars, "_") - ?.trim() - ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } - if (base != null) return base.take(120) + BrowserDownloadRules.safeFileName(suggested)?.let { return it } val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } return if (ext != null) "download.$ext" else "download" } + /** The MIME type [name]'s extension implies, or octet-stream, which MediaStore stores under the name as given. */ + private fun mimeTypeOf(name: String): String = + name + .substringAfterLast('.', "") + .lowercase() + .takeIf { it.isNotEmpty() } + ?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } + ?: "application/octet-stream" + private fun toast( context: Context, text: String, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index d724037235..38d2c5503b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -365,12 +365,13 @@ class NappletBrowserActivity : ComponentActivity() { wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> + // The page's origin; a fresh popup still on about:blank has none, so name the file's own. val origin = chrome ?.ui ?.chrome ?.url - ?.let(BrowserChrome::originOf) ?: return@setDownloadListener + ?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: return@setDownloadListener if (!canOfferDownload(origin)) return@setDownloadListener // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) @@ -971,11 +972,14 @@ class NappletBrowserActivity : ComponentActivity() { } /** - * Whether [origin] may put a download card up now: never over a card already showing (so a page can't - * swap the name under the user's finger) or one still being prepared (so a page can't queue decodes), - * and not within the cooldown after its last card was answered. + * Whether [origin] may put a download card up now: never over another page prompt (so a page can't swap + * the name under the user's finger, or pop the card where a dialog's button just was), never while an + * offer is still being prepared (so a page can't queue decodes), and not within its cooldown. */ - private fun canOfferDownload(origin: String) = chrome?.downloadPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + private fun canOfferDownload(origin: String): Boolean { + val host = chrome ?: return false + return host.downloadPrompt == null && host.dialog == null && host.permissionPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + } /** Runs [prepare] (which answers exactly once, on the main thread) and shows the offer it produces. */ private fun prepareDownloadOffer( @@ -1002,9 +1006,10 @@ class NappletBrowserActivity : ComponentActivity() { security = BrowserChrome.security(host.ui.chrome), fileName = offer.fileName, sizeBytes = offer.sizeBytes, + sourceHost = offer.sourceHost, risky = offer.risky, ) { allowed -> - downloadCooldown.answered(origin) + downloadCooldown.answered(origin, allowed) if (allowed) offer.save(this) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index 0cb87270e3..7b42f651d9 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -188,7 +188,7 @@ object NappletBrowserContract { * Provider → client: a download the page started (an attachment, ``, or an inline * `browser.download`) needs the user's consent before anything is fetched or written into the shared * Downloads collection. Carries [KEY_DOWNLOAD_ID], the WebView-reported [KEY_BROWSER_ORIGIN] (never a - * page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], and + * page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], [KEY_DOWNLOAD_SOURCE], and * [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). Answered with * [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave the `:napplet` process. */ @@ -197,6 +197,9 @@ object NappletBrowserContract { /** Client → provider: the user's answer to [MSG_DOWNLOAD_CONSENT]: [KEY_DOWNLOAD_ID] + [KEY_DOWNLOAD_ALLOWED]. */ const val MSG_DOWNLOAD_CONSENT_RESULT = 35 + /** Provider → client: withdraw the [MSG_DOWNLOAD_CONSENT] card [KEY_DOWNLOAD_ID] (its tab closed). */ + const val MSG_DOWNLOAD_CANCEL = 36 + const val KEY_CAN_GO_FORWARD = "canGoForward" const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" @@ -228,6 +231,9 @@ object NappletBrowserContract { /** The size in bytes the consented download will write, or -1 when the server didn't say. */ const val KEY_DOWNLOAD_SIZE = "downloadSize" + /** The host a network download is fetched from (absent for inline data), shown when it isn't the page's. */ + const val KEY_DOWNLOAD_SOURCE = "downloadSource" + /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ const val KEY_DOWNLOAD_RISKY = "downloadRisky" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index c8e0697818..dcd898d7df 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -316,8 +316,9 @@ class NappletBrowserService : Service() { val data = msg.data ?: return true val pending = pendingDownloads.remove(data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)) ?: return true val tab = tabs[pending.sessionId] ?: return true - tab.downloadCooldown.answered(pending.origin) - if (data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)) pending.offer.save(this) + val allowed = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false) + tab.downloadCooldown.answered(pending.origin, allowed) + if (allowed) pending.offer.save(this) } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() @@ -470,8 +471,12 @@ class NappletBrowserService : Service() { // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() cancelPending(tab) - // An unanswered download card dies with its tab: nothing is saved, and its bytes are freed. - pendingDownloads.values.removeAll { it.sessionId == sessionId } + // An unanswered download card dies with its tab: nothing is saved, its bytes are freed, and the + // client is told so its card doesn't linger with a Save that does nothing. + pendingDownloads.entries.filter { it.value.sessionId == sessionId }.forEach { (id, _) -> + pendingDownloads.remove(id) + sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CANCEL) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) } + } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -489,7 +494,8 @@ class NappletBrowserService : Service() { wv.webChromeClient = BrowserChromeClient(tab) wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> if (tab == null) return@setDownloadListener - val origin = wv.url?.let(BrowserChrome::originOf) ?: return@setDownloadListener + // The page's origin; a fresh popup still on about:blank has none, so name the file's own. + val origin = wv.url?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: return@setDownloadListener if (!canOfferDownload(tab, origin)) return@setDownloadListener // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) @@ -1056,14 +1062,19 @@ class NappletBrowserService : Service() { } /** - * Whether [origin] may put a download card up in [tab] now: never over the tab's card already showing - * (so a page can't swap the name under the user's finger) or one still being prepared (so a page - * can't queue decodes), and not within the cooldown after its last card was answered. + * Whether [origin] may put a download card up in [tab] now: never over another of the tab's page prompts + * (so a page can't swap the name under the user's finger, or pop the card where a dialog's button just + * was), never while an offer is still being prepared (so a page can't queue decodes), and not within + * its cooldown. */ private fun canOfferDownload( tab: BrowserTab, origin: String, - ) = !tab.preparingDownload && pendingDownloads.values.none { it.sessionId == tab.sessionId } && tab.downloadCooldown.allows(origin) + ) = !tab.preparingDownload && + tab.jsDialogs.isEmpty() && + tab.permissionRequests.isEmpty() && + pendingDownloads.values.none { it.sessionId == tab.sessionId } && + tab.downloadCooldown.allows(origin) /** Runs [prepare] (which answers exactly once, on the main thread) and relays the offer it produces. */ private fun prepareDownloadOffer( @@ -1095,6 +1106,7 @@ class NappletBrowserService : Service() { putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, offer.fileName) putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, offer.sizeBytes) + putString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE, offer.sourceHost) putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, offer.risky) } if (sent) pendingDownloads[id] = PendingDownload(tab.sessionId, origin, offer)