Merge remote-tracking branch 'upstream/main' into fix/marmot-updatepath-fork

# Conflicts:
#	cli/tests/marmot/marmot-interop-headless.sh
#	cli/tests/marmot/tests-manage.sh
This commit is contained in:
Vitor Pamplona
2026-09-27 15:56:13 -04:00
68 changed files with 3646 additions and 368 deletions
@@ -1538,6 +1538,12 @@ class Account(
note: Note,
reaction: String,
) {
// A Marmot message reacts inside its MLS group; see AccountMarmotActions.reactToMarmotMessage.
marmot.marmotGroupOf(note)?.let { groupId ->
marmot.reactToMarmotMessage(groupId, note, reaction)
return
}
// Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the
// inner kind-7 only ever travels as ciphertext, so mining it is pure
// waste — those targets skip the queue and sign with the plain signer.
@@ -1703,7 +1709,14 @@ class Account(
suspend fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() && it.event != null }
// Marmot messages are retracted inside their group. A public NIP-09 here would e-tag
// the group's private rumor ids onto public relays.
val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null }
marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) ->
marmot.deleteMarmotMessages(groupId, groupNotes)
}
val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null }
if (myNotes.isNotEmpty()) {
// chunks in 200 elements to avoid going over the 65KB limit for events.
myNotes.chunked(200).forEach { chunkedList ->
@@ -1733,6 +1746,12 @@ class Account(
if (!isWriteable()) return
val targetEvent = target.event ?: return
// In a Marmot group the deletion goes to the group, not to the target's author as a DM.
marmot.marmotGroupOf(target)?.let { groupId ->
marmot.deleteMarmotMessages(groupId, notes)
return
}
val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event }
if (myRumors.isEmpty()) return
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
@@ -246,6 +247,54 @@ class AccountMarmotActions(
manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
}
/**
* The Marmot group [note] was received or sent in, or null when it is not a
* Marmot message.
*
* Only chat rows are indexed, so pass the MESSAGE a reaction or deletion is
* about, not the reaction itself.
*/
fun marmotGroupOf(note: Note): HexKey? = account.marmotGroupList.groupIdForNote(note.idHex)
/**
* React to a Marmot message inside its group.
*
* A Marmot message is an unsigned rumor, which the generic reaction path
* handles as a NIP-17 private note: it gift-wrapped the kind:7 to the
* author as a DM. That never reached the group, so no other client showed
* it, and it moved group activity out of the group's channel. The reaction
* is an ordinary inner kind:7 (MIP-03), encrypted to the group like any
* message.
*/
suspend fun reactToMarmotMessage(
nostrGroupId: HexKey,
target: Note,
reaction: String,
) {
val manager = account.marmotManager ?: return
val targetEvent = target.event ?: return
if (target.hasReacted(account.userProfile(), reaction)) return
val rumor = manager.buildReactionRumor(targetEvent, reaction)
sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId))
}
/**
* Retract our own messages or reactions in a Marmot group with an inner
* kind:5, for the same reason [reactToMarmotMessage] exists: the generic
* private path sent a gift-wrapped NIP-09 to the target's author, so the
* other members never saw the deletion.
*/
suspend fun deleteMarmotMessages(
nostrGroupId: HexKey,
notes: List<Note>,
) {
val manager = account.marmotManager ?: return
val mine = notes.filter { it.author == account.userProfile() }.mapNotNull { it.event }
if (mine.isEmpty()) return
val rumor = manager.buildDeletionRumor(mine)
sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId))
}
/**
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
* Returns a status message describing the outcome.
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.cast
import androidx.compose.runtime.Immutable
import org.jetbrains.compose.resources.StringResource
@Immutable
data class CastDevice(
@@ -34,8 +35,36 @@ data class CastRequest(
val mimeType: String? = null,
val title: String? = null,
val artworkUri: String? = null,
/**
* Whether the receiver should treat this as an endless live stream rather than a seekable
* recording. Resolve it with [resolveCastLiveness] — never from the URL, which cannot tell a
* live `.m3u8` from an on-demand one.
*/
val isLive: Boolean = false,
/**
* What the video is — "H.265 (HEVC) 1920x1080" — taken from the local player, which has already
* decoded it. Reported when a cast fails, because the receiver itself rarely says why. See
* [summarizeCastFormat].
*/
val formatSummary: String? = null,
)
/**
* Decides what to tell the Cast receiver about a stream's liveness.
*
* [learned] is ExoPlayer's verdict for this URL (see HlsLivenessCache), recorded once it has parsed
* the playlist — the only signal that actually distinguishes a live `.m3u8` from an on-demand one.
* It wins whenever we have it. [metadataFlag] is the kind:30311 live-activity flag, which is right
* when set but absent for a live stream shared in a plain kind:1 note, so it is only the fallback.
*
* Defaulting to non-live when we know nothing keeps the previous behaviour for the common case
* (progressive MP4), where a live claim would cost the receiver its seek bar and duration.
*/
fun resolveCastLiveness(
learned: Boolean?,
metadataFlag: Boolean,
): Boolean = learned ?: metadataFlag
// Critical for HLS: sending an `.m3u8` URL with `video/mp4` makes the default
// Cast receiver try to demux a playlist as MP4 and crash, wiping the TV's Cast
// service from the network in the process. Strip query/fragment first so
@@ -66,6 +95,21 @@ sealed class CastSessionState {
data class Error(
val device: CastDevice?,
val message: String,
val message: CastErrorMessage,
) : CastSessionState()
}
/**
* What went wrong, in a form the picker resolves in composition. The caster reports failures from
* Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over
* the resource and the UI formats it.
*
* [text] names the receiver as `%1$s`: the picker supplies the [CastSessionState.Error] device's
* name, or a generic noun when the failure happens before or after we know which device it was.
* A `_detail` resource also takes [detail] — what the video was — as `%2$s`.
*/
@Immutable
data class CastErrorMessage(
val text: StringResource,
val detail: String? = null,
)
@@ -0,0 +1,64 @@
/*
* 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.service.cast
/**
* Short human-readable description of what a video actually is — "H.265 (HEVC) 1920x1080".
*
* A Cast receiver that dislikes a video usually says nothing useful: an LG webOS TV accepts the
* load, reports LOADING, and then goes silent, with no error and no reason. The phone, meanwhile,
* has already decoded the same file locally and knows exactly what it is. Reporting that turns "it
* didn't play" into "it's HEVC", which is the fact that explains the failure and tells the user
* whether another device or another copy would do better.
*
* Codec names are the ones people recognise from device spec sheets rather than the raw mime types,
* because the point of the message is to be recognisable. Anything unrecognised passes through
* unchanged — a mime type we don't have a friendly name for is still better than nothing.
*/
fun summarizeCastFormat(
sampleMimeType: String?,
codecs: String?,
width: Int,
height: Int,
): String? {
val codec = friendlyCodecName(sampleMimeType) ?: codecs?.takeIf { it.isNotBlank() }
val resolution = if (width > 0 && height > 0) "${width}x$height" else null
return when {
codec != null && resolution != null -> "$codec $resolution"
codec != null -> codec
resolution != null -> resolution
else -> null
}
}
private fun friendlyCodecName(sampleMimeType: String?): String? {
val mime = sampleMimeType?.takeIf { it.isNotBlank() } ?: return null
return when (mime.lowercase()) {
"video/avc" -> "H.264"
"video/hevc", "video/dolby-vision" -> "H.265 (HEVC)"
"video/av01" -> "AV1"
"video/x-vnd.on2.vp9" -> "VP9"
"video/x-vnd.on2.vp8" -> "VP8"
"video/mp4v-es" -> "MPEG-4"
"video/mpeg2" -> "MPEG-2"
else -> mime
}
}
@@ -0,0 +1,65 @@
/*
* 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.service.cast
/**
* How a cast request should reach its target receiver, given what is already connected.
*
* @param needsRouteSelection whether MediaRouter has to be asked to move.
* @param expectsPreviousSessionToEnd whether an existing session will be torn down as a *result* of
* that move. The caller must not read that teardown as the new attempt failing.
*/
enum class CastRoutePlan(
val needsRouteSelection: Boolean,
val expectsPreviousSessionToEnd: Boolean,
) {
/** Already connected to this very receiver — load straight onto the live session. */
REUSE_SESSION(needsRouteSelection = false, expectsPreviousSessionToEnd = false),
/** Connected to a *different* receiver — selecting the target ends that session first. */
SWAP_RECEIVER(needsRouteSelection = true, expectsPreviousSessionToEnd = true),
/** Nothing connected — select the route and wait for the session to start. */
SELECT_FRESH(needsRouteSelection = true, expectsPreviousSessionToEnd = false),
}
/**
* Chooses the [CastRoutePlan] for a cast to [targetRouteId].
*
* The distinction that matters is between reusing a session and swapping receivers. Treating any
* connected session as reusable — as this used to — means a session on the soundbar is taken as
* proof that a cast to the TV is already connected: the start completes instantly, `selectRoute()`
* then tears that session down, and by the time the media is loaded the session is gone, so the
* load is skipped and the TV sits on its splash screen having "connected" successfully.
*
* [hasConnectedSession] must reflect a session that is actually connected; a stale or suspended one
* cannot take a load and has to go through a fresh start.
*/
fun planRouteSelection(
targetRouteId: String,
selectedRouteId: String?,
hasConnectedSession: Boolean,
): CastRoutePlan =
when {
!hasConnectedSession -> CastRoutePlan.SELECT_FRESH
selectedRouteId == targetRouteId -> CastRoutePlan.REUSE_SESSION
else -> CastRoutePlan.SWAP_RECEIVER
}
@@ -74,12 +74,15 @@ import com.vitorpamplona.amethyst.model.VideoButtonLocation
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.service.cast.resolveCastLiveness
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
import com.vitorpamplona.amethyst.ui.cast.rememberCastWithLocalNetworkPermission
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -229,6 +232,7 @@ fun RenderTopButtons(
RenderTopButtons(
mediaData = mediaData,
hasMultipleQualities = hasMultipleQualities,
castFormatSummary = castFormatSummary(videoGroup),
qualityButton = {
VideoQualityButton(
player = player,
@@ -294,6 +298,9 @@ fun RenderTopButtons(
fun RenderTopButtons(
mediaData: MediaItemData,
hasMultipleQualities: Boolean,
// What the local player decoded this as. Only the Cast failure messages use it: a receiver that
// refuses a video usually will not say why, so this is the only description of it available.
castFormatSummary: String? = null,
qualityButton: @Composable () -> Unit,
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
@@ -313,6 +320,7 @@ fun RenderTopButtons(
val captionsContentDescription =
stringRes(if (captionsEnabled) Res.string.captions_turn_off else Res.string.captions_turn_on)
val shareDialogVisible = remember { mutableStateOf(false) }
val context = LocalContext.current
val castDialogVisible = remember { mutableStateOf(false) }
val castSessionState by Amethyst.instance.castRegistry.sessionState
.collectAsStateWithLifecycle()
@@ -321,15 +329,19 @@ fun RenderTopButtons(
val castIcon = if (isThisVideoCasting) MaterialSymbols.CastConnected else MaterialSymbols.Cast
val castContentDescription =
stringRes(if (isThisVideoCasting) Res.string.cast_stop_casting else Res.string.cast_to_device)
// Android 17 Local Network Protection blocks Cast device discovery until the
// user grants ACCESS_LOCAL_NETWORK, so gate the picker behind the request.
val showCastPicker = remember { { castDialogVisible.value = true } }
val openCastPicker = rememberCastWithLocalNetworkPermission(context, showCastPicker)
val onCastButtonClick =
remember(isThisVideoCasting) {
remember(isThisVideoCasting, openCastPicker) {
{
if (isThisVideoCasting) {
Amethyst.instance.applicationIOScope.launch {
Amethyst.instance.castRegistry.stopCasting()
}
} else {
castDialogVisible.value = true
openCastPicker()
}
Unit
}
@@ -495,6 +507,17 @@ fun RenderTopButtons(
mimeType = mediaData.mimeType,
title = mediaData.title,
artworkUri = mediaData.artworkUri,
// By the time the cast button is reachable the player has already parsed the
// playlist, so the learned verdict is normally available here.
isLive =
resolveCastLiveness(
learned = HlsLivenessCache.verdict(mediaData.videoUri),
metadataFlag = mediaData.isLiveStream,
),
// The local player has already decoded this, so it knows what the receiver
// is about to be handed — the only description of the media available when
// the receiver refuses it without saying why.
formatSummary = castFormatSummary,
),
onDismiss = { castDialogVisible.value = false },
)
@@ -26,10 +26,12 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.service.cast.summarizeCastFormat
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
@@ -37,6 +39,23 @@ import kotlin.math.ceil
internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 }
// Describes the video being played, for the Cast failure messages. Uses the highest-resolution
// track in the group: that is the one an adaptive stream will climb to, so it is the one a
// receiver with limited codec or resolution support will choke on.
@OptIn(UnstableApi::class)
internal fun castFormatSummary(group: Tracks.Group?): String? {
if (group == null) return null
var best: Format? = null
for (i in 0 until group.length) {
val format = group.getTrackFormat(i)
if (best == null || format.width.toLong() * format.height > best!!.width.toLong() * best!!.height) {
best = format
}
}
val format = best ?: return null
return summarizeCastFormat(format.sampleMimeType, format.codecs, format.width, format.height)
}
/**
* Constrains adaptive selection to the area the player is actually drawn in.
*
@@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -221,6 +222,11 @@ fun BlossomBlobManagerScreen(
},
)
},
bottomBar = {
AppBottomBar(Route.ManageBlossomBlobs, nav, accountViewModel) { route ->
if (route != Route.ManageBlossomBlobs) nav.navBottomBar(route)
}
},
) { padding ->
Column(
modifier =
@@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cast_generic_receiver
import com.vitorpamplona.amethyst.commons.resources.cast_searching_for_devices
import com.vitorpamplona.amethyst.commons.resources.cast_to_device_dialog_title
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
@@ -105,9 +106,10 @@ fun CastDevicePickerDialog(
}
}
if (sessionState is CastSessionState.Error) {
val error = sessionState as? CastSessionState.Error
if (error != null) {
Text(
text = (sessionState as CastSessionState.Error).message,
text = castErrorText(error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
@@ -115,3 +117,11 @@ fun CastDevicePickerDialog(
}
}
}
@Composable
private fun castErrorText(error: CastSessionState.Error): String =
stringRes(
error.message.text,
error.device?.name ?: stringRes(Res.string.cast_generic_receiver),
error.message.detail,
)
@@ -0,0 +1,122 @@
/*
* 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.ui.cast
import android.Manifest
import android.content.Context
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_message
import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_open_settings
import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_title
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.call.hasPermission
import com.vitorpamplona.amethyst.ui.call.openAppSettings
// Android 17 (API 37) Local Network Protection gates the mDNS/multicast device
// discovery the Cast SDK relies on behind the dangerous ACCESS_LOCAL_NETWORK
// runtime permission. Without it the picker silently finds zero receivers.
// Older OSes have no LNP and never define the permission, so the gate is a
// no-op there.
private const val LOCAL_NETWORK_PROTECTION_SDK = 37
private fun needsLocalNetworkPermission(): Boolean = Build.VERSION.SDK_INT >= LOCAL_NETWORK_PROTECTION_SDK
fun hasLocalNetworkPermission(context: Context): Boolean = !needsLocalNetworkPermission() || hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
/**
* Returns a click handler that ensures [Manifest.permission.ACCESS_LOCAL_NETWORK]
* is granted before running [onGranted] (which opens the Cast device picker).
*
* On Android 17+ the permission is requested on first tap; if the user denies
* it, a dialog deep-links to app settings. On older OSes the permission does
* not exist, so [onGranted] runs immediately.
*/
@Composable
fun rememberCastWithLocalNetworkPermission(
context: Context,
onGranted: () -> Unit,
): () -> Unit {
var showDeniedDialog by remember { mutableStateOf(false) }
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
// A silent deny (permanently-denied, where Android skips the dialog)
// also lands here with granted=false, so surface the deep-link
// dialog rather than failing silently with an empty picker.
if (granted) onGranted() else showDeniedDialog = true
}
if (showDeniedDialog) {
LocalNetworkPermissionDeniedDialog(
onDismiss = { showDeniedDialog = false },
onOpenSettings = {
showDeniedDialog = false
openAppSettings(context)
},
)
}
return remember(onGranted) {
{
if (hasLocalNetworkPermission(context)) {
onGranted()
} else {
launcher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
}
}
}
@Composable
private fun LocalNetworkPermissionDeniedDialog(
onDismiss: () -> Unit,
onOpenSettings: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.cast_local_network_permission_title)) },
text = { Text(stringRes(Res.string.cast_local_network_permission_message)) },
confirmButton = {
TextButton(onClick = onOpenSettings) {
Text(stringRes(Res.string.cast_local_network_permission_open_settings))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(Res.string.cancel))
}
},
)
}
@@ -54,6 +54,14 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot"
fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) == true
// Per-entry hint stamped by Nav.navDrawer marking that the entry was opened
// from the navigation drawer. It sits on top of the stack like any push (back
// arrow, slide animation), but Nav.showsBottomBar keeps the bottom bar on it:
// a drawer destination is a top-level section, not a detail screen.
const val DRAWER_ROOT_KEY = "drawerRoot"
fun NavBackStackEntry.isDrawerRoot(): Boolean = savedStateHandle.get<Boolean>(DRAWER_ROOT_KEY) == true
/**
* The shell's current layout tier, mirrored for the transition specs below. Transition
* lambdas run when a navigation starts — outside composition — so they can't read
@@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]),
* exclusive of the system navigation-bar inset. Used by FAB callers that want to
* reserve the same vertical space when the bar hides itself on canPop entries. */
* reserve the same vertical space when the bar hides itself on in-app pushes. */
val AppBottomBarHeight = 50.dp
@Composable
@@ -94,9 +94,9 @@ fun AppBottomBar(
// permanently docked drawer (Expanded).
if (LocalScreenLayout.current.isLargeScreen) return
// Hide the bar on entries that aren't a tab root (drawer or in-app
// pushes). Mirrors the back-arrow rule in canPop().
if (nav.canPop()) return
// Hide the bar on in-app pushes. Tab roots, Home and screens opened from
// the drawer keep it, even though the drawer ones still show a back arrow.
if (!nav.showsBottomBar()) return
val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems
.collectAsStateWithLifecycle()
@@ -210,7 +210,7 @@ private fun DrawerContentBody(
accountViewModel: AccountViewModel,
) {
val onClickUser = {
nav.nav(routeFor(accountViewModel.userProfile()))
nav.navDrawer(routeFor(accountViewModel.userProfile()))
nav.closeDrawer()
}
@@ -641,24 +641,32 @@ fun ListContent(
}
}
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
/**
* The Create section's rows — composer entry points, none of which is a catalog destination. They
* open as plain pushes rather than through [INav.navDrawer]: those screens host no bottom bar, and
* a drawer stamp would tell FabBottomBarPadding that one is showing.
*/
@Composable
private fun CreateRows(nav: INav) {
NavigationRow(
IconRow(
title = Res.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
onClick = {
nav.closeDrawer()
nav.nav(Route.NewHlsVideo)
},
)
if (isDebug) {
NavigationRow(
IconRow(
title = Res.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
onClick = {
nav.closeDrawer()
nav.nav(Route.Chess)
},
)
}
}
@@ -742,7 +750,7 @@ private fun ScheduledPostsNavigationRow(
badgeCount = pendingCount,
onClick = {
nav.closeDrawer()
nav.nav { def.resolveRoute(accountViewModel) }
nav.navDrawer { def.resolveRoute(accountViewModel) }
},
)
}
@@ -850,7 +858,7 @@ fun NavigationRow(
tint,
onClick = {
nav.closeDrawer()
nav.nav(route)
nav.navDrawer(route)
},
)
}
@@ -871,7 +879,7 @@ fun NavigationRow(
tint,
onClick = {
nav.closeDrawer()
nav.nav(computeRoute)
nav.navDrawer(computeRoute)
},
)
}
@@ -890,7 +898,7 @@ fun NavigationRow(
tint = tint,
onClick = {
nav.closeDrawer()
nav.nav(route)
nav.navDrawer(route)
},
)
}
@@ -909,7 +917,7 @@ fun NavigationRow(
tint = tint,
onClick = {
nav.closeDrawer()
nav.nav(computeRoute)
nav.navDrawer(computeRoute)
},
)
}
@@ -36,7 +36,9 @@ import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY
import com.vitorpamplona.amethyst.ui.navigation.DRAWER_ROOT_KEY
import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot
import com.vitorpamplona.amethyst.ui.navigation.isDrawerRoot
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -89,6 +91,31 @@ class Nav(
}
}
override fun navDrawer(route: Route) {
navigationScope.launch {
ime.settle()
navigateFromDrawer(route)
}
}
override fun navDrawer(computeRoute: suspend () -> Route?) {
navigationScope.launch {
ime.settle()
computeRoute()?.let { navigateFromDrawer(it) }
}
}
/**
* Same push as [nav], then stamps the new entry [DRAWER_ROOT_KEY] so [showsBottomBar] keeps
* the bottom bar on it. The stamp lands in the same frame as the navigate, before the entry
* composes, the way [navBottomBar] stamps its tab roots.
*/
private fun navigateFromDrawer(route: Route) {
if (getRouteWithArguments(route::class, controller) == route) return
controller.navigate(route)
controller.currentBackStackEntry?.savedStateHandle?.set(DRAWER_ROOT_KEY, true)
}
override fun newStack(route: Route) {
navigationScope.launch {
ime.settle()
@@ -107,9 +134,10 @@ class Nav(
// A nav-bar tap asks for a tab, never for whatever the user pushed on top of one. Drop
// those pushes first, and without saving them, so the restoreState below can never hand
// a deep stack back. On phones this is always a no-op — AppBottomBar hides itself off
// tab roots, so the bar is only ever tapped from one — but the large-screen rail stays
// on screen the whole time and is routinely tapped from three screens deep.
// a deep stack back. On phones the bar shows only on tab roots and on screens opened
// from the drawer (dropped here, back to the tab they were opened over), but the
// large-screen rail stays on screen the whole time and is routinely tapped from three
// screens deep.
popPushesAboveTabRoot()
// Dropping those pushes is often the whole job — re-tapping the tab the user is inside,
@@ -211,11 +239,24 @@ class Nav(
// Outside a NavHost destination (shell chrome, drawer) the current owner
// is the account-scoped ViewModelStoreOwner, not an entry; fall back to
// the globally-current entry so those callers keep their prior behavior.
val entry =
(LocalViewModelStoreOwner.current as? NavBackStackEntry)
?: controller.currentBackStackEntry
?: return false
val entry = ownEntry() ?: return false
return canPop(entry)
}
@Composable
override fun showsBottomBar(): Boolean {
val entry = ownEntry() ?: return true
// Drawer destinations can pop (they sit on whatever screen the drawer was opened over),
// but they are top-level sections, so they keep the bar the tab roots have.
return entry.isDrawerRoot() || !canPop(entry)
}
@Composable
private fun ownEntry(): NavBackStackEntry? =
(LocalViewModelStoreOwner.current as? NavBackStackEntry)
?: controller.currentBackStackEntry
private fun canPop(entry: NavBackStackEntry): Boolean {
// Hidden on tab roots (reached via the bottom nav) and on Home (the
// graph's start destination): nothing sits below either that a back
// arrow could return to. Every other entry is a push on top of Home,
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose
@@ -125,6 +126,11 @@ fun CordnGroupListScreen(
}
}
},
bottomBar = {
AppBottomBar(Route.CordnGroupList, nav, accountViewModel) { route ->
if (route != Route.CordnGroupList) nav.navBottomBar(route)
}
},
) { padding ->
if (runtime == null) {
EmptyState(
@@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
@@ -136,16 +137,24 @@ fun MarmotGroupListScreen(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(Res.string.back),
)
// No arrow as a bottom-nav tab root: there is nothing below it to return to.
if (nav.canPop()) {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(Res.string.back),
)
}
}
},
title = { Text(stringRes(Res.string.marmot_groups_title)) },
)
},
bottomBar = {
AppBottomBar(Route.MarmotGroupList, nav, accountViewModel) { route ->
if (route != Route.MarmotGroupList) nav.navBottomBar(route)
}
},
floatingActionButton = {
FabBottomBarPadded(nav) {
FloatingActionButton(onClick = { nav.nav(Route.CreateMarmotGroup) }, shape = CircleShape) {
@@ -353,7 +353,7 @@ fun ConcordChannelListScreen(
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned Concord community works both as a pushed detail and as a bottom-nav tab.
AppBottomBar(Route.ConcordServer(communityId), nav, accountViewModel) { route ->
if (route != Route.ConcordServer(communityId)) nav.navBottomBar(route)
@@ -137,7 +137,7 @@ fun ConcordHomeScreen(
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so the same screen works both as a pushed destination and a bottom-nav tab.
AppBottomBar(Route.Concords, nav, accountViewModel) { route ->
if (route != Route.Concords) nav.navBottomBar(route)
@@ -56,7 +56,7 @@ fun PublicChatChannelScreen(
PublicChatTopBar(it, accountViewModel, nav)
}
},
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned public chat works both as a pushed detail and as a bottom-nav tab.
bottomBar = {
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
@@ -439,7 +439,7 @@ fun RelayGroupChannelListScreen(
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned NIP-29 relay works both as a pushed detail and as a bottom-nav tab.
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
if (route != selfRoute) nav.navBottomBar(route)
@@ -58,7 +58,7 @@ fun RelayGroupChatScreen(
RelayGroupTopBar(it, inviteCode, accountViewModel, nav)
}
},
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()),
// so a pinned relay group works both as a pushed detail and as a bottom-nav tab.
bottomBar = {
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
@@ -107,8 +107,12 @@ fun GeocachesScreen(
}
},
bottomBar = {
AppBottomBar(Route.Geocaches(), nav, accountViewModel) { route ->
if (route is Route.Geocaches) {
// Geocaches and Hunts are two separate pinnable tabs of this one screen, so match the
// exact route: a class check highlighted the wrong one and turned a tap on the other
// into a scroll-to-top instead of a tab switch.
val selfRoute = Route.Geocaches(initialTab)
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
if (route == selfRoute) {
nearby.sendToTop()
} else {
nav.navBottomBar(route)
@@ -37,11 +37,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_none_found
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription
@@ -90,6 +92,11 @@ fun NappletsScreen(
Scaffold(
topBar = { NappletsTopBar(accountViewModel, nav) },
bottomBar = {
AppBottomBar(Route.Napplets, nav, accountViewModel) { route ->
if (route != Route.Napplets) nav.navBottomBar(route)
}
},
) { padding ->
if (visible.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
@@ -37,10 +37,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nsite_none_found
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription
@@ -88,6 +90,11 @@ fun NsitesScreen(
Scaffold(
topBar = { NsitesTopBar(accountViewModel, nav) },
bottomBar = {
AppBottomBar(Route.Nsites, nav, accountViewModel) { route ->
if (route != Route.Nsites) nav.navBottomBar(route)
}
},
) { padding ->
if (visible.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
@@ -125,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
@@ -193,6 +194,11 @@ fun Nip46SignerScreen(
Scaffold(
topBar = { TopBarWithBackButton(stringRes(Res.string.nip46_signer_title), nav) },
bottomBar = {
AppBottomBar(Route.Nip46Signer(), nav, accountViewModel) { route ->
if (route != Route.Nip46Signer()) nav.navBottomBar(route)
}
},
) { padding ->
Column(
modifier =
+7
View File
@@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Android 17 (API 37) Local Network Protection gates the mDNS/multicast
device discovery the Cast SDK relies on behind this dangerous runtime
permission; without it the picker silently finds zero receivers.
Play flavor only — F-Droid ships no casting at all. Older OSes have no
LNP and ignore the unknown permission. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
android:name=".Amethyst">
@@ -20,13 +20,19 @@
*/
package com.vitorpamplona.amethyst.service.cast.chromecast
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.CastStatusCodes
import com.google.android.gms.cast.MediaError
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaMetadata
@@ -38,16 +44,31 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.images.WebImage
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cast_error_connect_failed
import com.vitorpamplona.amethyst.commons.resources.cast_error_device_offline
import com.vitorpamplona.amethyst.commons.resources.cast_error_load_failed
import com.vitorpamplona.amethyst.commons.resources.cast_error_play_services_unavailable
import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed
import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed_detail
import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding
import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding_detail
import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media
import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media_detail
import com.vitorpamplona.amethyst.service.cast.CastDevice
import com.vitorpamplona.amethyst.service.cast.CastErrorMessage
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastRoutePlan
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
import com.vitorpamplona.amethyst.service.cast.planRouteSelection
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@@ -55,6 +76,29 @@ private const val TAG = "ChromecastCaster"
private const val SESSION_START_TIMEOUT_MS = 30_000L
private const val STOP_AWAIT_TIMEOUT_MS = 5_000L
/**
* How long to wait for the session to actually end after asking the receiver app to stop.
*
* Unselecting the route drops the connection, so doing it before the receiver has processed
* STOP_APP leaves the app running and the TV parked on the Default Media Receiver splash — the
* "default renderer" screen the user has to leave with the TV remote. Observed at ~25-170ms on a
* webOS TV, longer the more playback there was to flush, so this is generous.
*/
private const val SESSION_END_TIMEOUT_MS = 5_000L
/**
* How long the receiver gets to move off LOADING before we call it stalled.
*
* A healthy receiver takes ~1s. A wedged one — the state an LG webOS TV lands in after its Cast
* service crashes, cleared only by power-cycling the TV — accepts the load, reports LOADING, and
* then reports nothing ever again: no progress, no error, no session end. Generous by design, since
* overshooting only delays an error message while undershooting aborts a slow but working load.
*/
private const val LOAD_PROGRESS_TIMEOUT_MS = 20_000L
/** Android 17 — the first release to enforce Local Network Protection. See [ChromecastCaster.localNetworkState]. */
private const val LOCAL_NETWORK_PROTECTION_SDK = 37
/**
* Google Cast (Chromecast) caster.
*
@@ -123,15 +167,29 @@ class ChromecastCaster(
override fun onStatusUpdated() {
val client = currentMediaClient ?: return
val status = client.mediaStatus
Log.d(TAG) {
val idleReason = status?.idleReason ?: -1
Log.i(TAG) {
"media.onStatusUpdated playerState=${playerStateName(client.playerState)} " +
"idleReason=${idleReasonName(status?.idleReason ?: -1)} " +
"idleReason=${idleReasonName(idleReason)} " +
"pos=${client.approximateStreamPosition}/${client.streamDuration}ms"
}
// Any real progress means the receiver is alive and the watchdog has done its job.
if (client.playerState == MediaStatus.PLAYER_STATE_PLAYING || client.playerState == MediaStatus.PLAYER_STATE_BUFFERING) {
cancelLoadWatchdog()
}
// The receiver can also fail without ever calling onMediaError — dropping to IDLE
// with an ERROR reason is the terminal signal in that case.
if (client.playerState == MediaStatus.PLAYER_STATE_IDLE && idleReason == MediaStatus.IDLE_REASON_ERROR) {
reportMediaFailure(null)
}
}
override fun onMediaError(mediaError: com.google.android.gms.cast.MediaError) {
Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" }
override fun onMediaError(mediaError: MediaError) {
Log.w(TAG) {
"media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} " +
"type=${mediaError.type} media=${castingFormat ?: "unknown"} customData=${mediaError.customData}"
}
reportMediaFailure(mediaError.detailedErrorCode)
}
}
@@ -155,6 +213,14 @@ class ChromecastCaster(
currentMediaClient = null
}
/**
* Cast surfaces failures as bare ints spread across several unrelated ranges (CommonStatusCodes,
* CastStatusCodes, and internal codes documented nowhere). [CastStatusCodes.getStatusCodeString]
* is the SDK's own lookup, so it decodes far more than the public constants do — keep the raw
* number alongside it for the ones it doesn't recognise either.
*/
private fun statusName(code: Int): String = "$code(${CastStatusCodes.getStatusCodeString(code)})"
private fun playerStateName(state: Int): String =
when (state) {
MediaStatus.PLAYER_STATE_IDLE -> "IDLE"
@@ -185,7 +251,7 @@ class ChromecastCaster(
session: CastSession,
sessionId: String,
) {
Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" }
Log.i(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" }
attachMediaClientCallback(session)
pendingSessionStart?.complete(true)
pendingSessionStart = null
@@ -195,10 +261,22 @@ class ChromecastCaster(
session: CastSession,
error: Int,
) {
Log.w(TAG) { "session.onStartFailed error=$error" }
Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" }
cancelLoadWatchdog()
pendingSessionStart?.complete(false)
pendingSessionStart = null
sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)")
if (error == CastStatusCodes.CANCELED) {
// The user backed out of the connection; that is not a failure to report.
sessionFlow.value = CastSessionState.Idle
return
}
// The raw SDK integer belongs in the log, not in front of the user. Every code that
// reaches here means the same thing to them — the device would not accept a
// connection — and on a webOS TV whose Cast service has died (2252, seen repeatedly
// once it wedges) restarting it is genuinely the fix.
val device = currentDevice()
sessionFlow.value =
CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_connect_failed))
}
override fun onSessionEnding(session: CastSession) {
@@ -209,12 +287,25 @@ class ChromecastCaster(
session: CastSession,
error: Int,
) {
Log.d(TAG) { "session.onEnded error=$error" }
detachMediaClientCallback()
// Whoever is tearing down gets told first, before any of the swap/failure handling
// below decides to return early — stopCasting() is blocked on this.
pendingSessionEnd?.complete(Unit)
// Moving to a different receiver ends the outgoing session by design, and that
// callback lands *after* cast() has installed the pending start for the incoming
// one. Failing it here is what made every device-to-device switch report
// "session start refused" and skip the load.
if (expectingSessionSwapEnd) {
expectingSessionSwapEnd = false
Log.i(TAG) { "session.onEnded error=${statusName(error)} — expected teardown while switching receivers" }
return
}
Log.i(TAG) { "session.onEnded error=${statusName(error)}" }
cancelLoadWatchdog()
// If a cast() was awaiting a session start, this is also a terminal
// outcome — the session never reached a usable state. Without
// completing here the cast coroutine hangs and the discovery
// ref-count leaks +1 for every failed attempt.
detachMediaClientCallback()
pendingSessionStart?.complete(false)
pendingSessionStart = null
sessionFlow.value = CastSessionState.Idle
@@ -242,7 +333,7 @@ class ChromecastCaster(
session: CastSession,
error: Int,
) {
Log.w(TAG) { "session.onResumeFailed error=$error" }
Log.w(TAG) { "session.onResumeFailed error=${statusName(error)}" }
pendingSessionStart?.complete(false)
pendingSessionStart = null
}
@@ -263,6 +354,102 @@ class ChromecastCaster(
@Volatile
private var pendingSessionStart: CompletableDeferred<Boolean>? = null
/**
* Armed while a [CastRoutePlan.SWAP_RECEIVER] is in flight — between asking MediaRouter to move
* to a different receiver and the outgoing session's teardown callback. That teardown is the
* expected consequence of the move, not the new attempt failing, and must not complete the
* pending start. Cleared as soon as the attempt resolves, so a later genuine end still counts.
*/
@Volatile
private var expectingSessionSwapEnd = false
/**
* Fires when the receiver accepted a load and then went quiet — see [LOAD_PROGRESS_TIMEOUT_MS].
* Nothing else covers this: the media callbacks only speak when the receiver does, and the
* session is still perfectly connected, so without this the picker claims to be casting forever
* while the device sits on its splash screen.
*/
private val loadWatchdog =
Runnable {
val state = currentMediaClient?.playerState
val progressed = state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING
if (progressed || sessionFlow.value is CastSessionState.Error) return@Runnable
val device = watchedDevice ?: currentDevice() ?: castingDevice
Log.w(TAG) {
"load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}"
}
val format = castingFormat
val message =
if (format != null) {
Res.string.cast_error_receiver_not_responding_detail
} else {
Res.string.cast_error_receiver_not_responding
}
sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message, format))
watchedDevice = null
}
/** The device a load is currently being watched for, so the message can name it. */
@Volatile
private var watchedDevice: CastDevice? = null
/** What the in-flight cast is, so a failure can say what the receiver refused. */
@Volatile
private var castingFormat: String? = null
/**
* Which device the in-flight cast targets. [currentDevice] reads it back off the session state,
* which a teardown has usually already reset by the time a late failure arrives — leaving the
* message to name "the cast device" instead of the TV the user was looking at.
*/
@Volatile
private var castingDevice: CastDevice? = null
/** Set while [stopCasting] waits for the receiver app to actually go away. */
@Volatile
private var pendingSessionEnd: CompletableDeferred<Unit>? = null
private fun armLoadWatchdog(device: CastDevice) {
main.removeCallbacks(loadWatchdog)
watchedDevice = device
main.postDelayed(loadWatchdog, LOAD_PROGRESS_TIMEOUT_MS)
}
private fun cancelLoadWatchdog() {
main.removeCallbacks(loadWatchdog)
watchedDevice = null
}
/**
* Turns a receiver-side playback failure into a [CastSessionState.Error] the picker can show.
*
* Without this the UI stays on [CastSessionState.Casting] — set the moment `load()` is
* submitted — while the receiver has already given up, so a rejected video looks exactly like a
* working one: the device sits on its splash screen and nothing ever explains why.
*
* The first report wins. A failure usually arrives twice (onMediaError, then IDLE/ERROR) and the
* earlier one carries the detailed code, so it is the more specific of the two.
*/
private fun reportMediaFailure(detailedErrorCode: Int?) {
cancelLoadWatchdog()
if (sessionFlow.value is CastSessionState.Error) return
val device = currentDevice() ?: castingDevice
val unsupported =
detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED ||
detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_DECODE
val format = castingFormat
val message =
when {
unsupported && format != null -> Res.string.cast_error_unsupported_media_detail
unsupported -> Res.string.cast_error_unsupported_media
format != null -> Res.string.cast_error_playback_failed_detail
else -> Res.string.cast_error_playback_failed
}
Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" }
sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message, format))
}
private fun currentDevice(): CastDevice? =
when (val s = sessionFlow.value) {
is CastSessionState.Connecting -> s.device
@@ -275,7 +462,7 @@ class ChromecastCaster(
val gms = GoogleApiAvailability.getInstance()
val status = gms.isGooglePlayServicesAvailable(appContext)
if (status != ConnectionResult.SUCCESS) {
Log.d(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." }
Log.w(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." }
return null
}
return try {
@@ -301,15 +488,15 @@ class ChromecastCaster(
}
fun startDiscovery() {
Log.d(TAG) { "startDiscovery (already registered? $registered)" }
Log.i(TAG) { "startDiscovery (already registered? $registered) ${localNetworkState()}" }
main.post {
if (registered) {
Log.d(TAG) { "startDiscovery: already registered, no-op" }
Log.i(TAG) { "startDiscovery: already registered, no-op" }
return@post
}
val ctx = ensureCastContext()
if (ctx == null) {
Log.d(TAG) { "startDiscovery: CastContext unavailable, aborting" }
Log.w(TAG) { "startDiscovery: CastContext unavailable, aborting" }
return@post
}
val router = MediaRouter.getInstance(appContext)
@@ -322,11 +509,25 @@ class ChromecastCaster(
mediaRouter = router
routeSelector = selector
registered = true
Log.d(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" }
Log.i(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" }
updateRoutes(router)
}
}
/**
* Android 17 (API 37) Local Network Protection gates the mDNS/multicast traffic the Cast SDK
* uses for discovery behind [Manifest.permission.ACCESS_LOCAL_NETWORK]. When it is denied the
* SDK reports no error at all — the picker simply stays empty forever — so the grant state is
* the single most important thing a discovery log can tell us apart from the route count.
*/
private fun localNetworkState(): String {
if (Build.VERSION.SDK_INT < LOCAL_NETWORK_PROTECTION_SDK) return "lnp=n/a(sdk${Build.VERSION.SDK_INT})"
val granted =
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_LOCAL_NETWORK) ==
PackageManager.PERMISSION_GRANTED
return "lnp=sdk${Build.VERSION.SDK_INT} ACCESS_LOCAL_NETWORK=${if (granted) "GRANTED" else "DENIED"}"
}
fun stopDiscovery() {
Log.d(TAG) { "stopDiscovery (registered=$registered)" }
main.post {
@@ -357,7 +558,11 @@ class ChromecastCaster(
name = route.name,
)
}
Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" }
// Log the unfiltered router total alongside the kept count: an empty picker with
// seen=0 means discovery itself never saw anything (LNP / Wi-Fi / mDNS), whereas
// seen>0 with count=0 means the routes exist but none advertises the Cast control
// category — two completely different faults that look identical from the UI.
Log.i(TAG) { "updateRoutes: seen=${router.routes.size} kept=${list.size} -> [${list.joinToString { it.name }}]" }
devicesFlow.value = list
}
@@ -365,11 +570,12 @@ class ChromecastCaster(
device: CastDevice,
request: CastRequest,
) {
Log.d(TAG) { "cast device=${device.name} url=${request.url}" }
Log.i(TAG) { "cast device=${device.name} url=${request.url}" }
val ctx = withContext(Dispatchers.Main) { ensureCastContext() }
if (ctx == null) {
Log.w(TAG, "cast: CastContext unavailable")
sessionFlow.value = CastSessionState.Error(device, "Google Play services unavailable")
sessionFlow.value =
CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_play_services_unavailable))
return
}
val route =
@@ -378,7 +584,7 @@ class ChromecastCaster(
}
if (route == null) {
Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" }
sessionFlow.value = CastSessionState.Error(device, "Device went offline")
sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_device_offline))
return
}
@@ -390,21 +596,34 @@ class ChromecastCaster(
Log.d(TAG) {
"cast: existing session connected=${existing?.isConnected} hasClient=${existing?.remoteMediaClient != null}"
}
val plan =
planRouteSelection(
targetRouteId = route.id,
selectedRouteId = mediaRouter?.selectedRoute?.id,
hasConnectedSession = existing?.isConnected == true,
)
Log.i(TAG) { "cast: plan=$plan target=${route.id} selected=${mediaRouter?.selectedRoute?.id}" }
val pending = CompletableDeferred<Boolean>()
// If a previous cast() is still awaiting a callback, fail it
// before swapping in our deferred — otherwise the earlier call
// hangs to the 30s timeout.
pendingSessionStart?.complete(false)
pendingSessionStart = pending
// Arm this before selectRoute, not after: the teardown callback for the outgoing
// session can arrive on the very next main-thread tick.
expectingSessionSwapEnd = plan.expectsPreviousSessionToEnd
try {
Log.d(TAG) { "cast: selectRoute id=${route.id}" }
mediaRouter?.selectRoute(route)
if (existing?.isConnected == true) {
Log.d(TAG) { "cast: reusing already-connected session, completing immediately" }
if (plan.needsRouteSelection) {
Log.i(TAG) { "cast: selectRoute id=${route.id}" }
mediaRouter?.selectRoute(route)
} else {
Log.i(TAG) { "cast: reusing the session already connected to this receiver" }
pending.complete(true)
}
} catch (t: Throwable) {
Log.w(TAG, "cast: selectRoute threw", t)
expectingSessionSwapEnd = false
pending.complete(false)
}
// Defence in depth: if a callback is somehow missed (SDK bug,
@@ -417,12 +636,18 @@ class ChromecastCaster(
Log.w(TAG) { "cast: session start timed out after ${SESSION_START_TIMEOUT_MS}ms" }
pendingSessionStart = null
}
// However this attempt ended, the swap it was waiting on is over. Leaving the flag
// armed would make the *next* genuine session end get swallowed as an expected one.
expectingSessionSwapEnd = false
outcome ?: false
}
if (!started) {
Log.w(TAG, "cast: session start refused")
sessionFlow.value = CastSessionState.Error(device, "Cast session refused")
// onSessionStartFailed usually got here first with a better-informed message; keep it.
sessionFlow.value =
sessionFlow.value as? CastSessionState.Error
?: CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_connect_failed))
return
}
@@ -438,21 +663,57 @@ class ChromecastCaster(
false
} else {
try {
client.load(buildLoadRequest(request))
Log.d(TAG) { "cast: load() submitted (status=${client.playerState})" }
val loadRequest = buildLoadRequest(request)
val info = loadRequest.mediaInfo
castingFormat = request.formatSummary
castingDevice = device
Log.i(TAG) {
"cast: load() submitting contentType=${info?.contentType} " +
"streamType=${info?.streamType} media=${request.formatSummary ?: "unknown"} " +
"url=${info?.contentId}"
}
// load() returns a PendingResult carrying the receiver's verdict. Dropping it
// (as this used to) makes a refused load indistinguishable from a successful
// one: the coroutine reports Casting, the TV sits on its splash screen, and
// nothing anywhere records why. This callback is the only place the receiver
// ever tells us what it disliked about the media.
armLoadWatchdog(device)
client.load(loadRequest).setResultCallback { result ->
val status = result.status
if (status.isSuccess) {
Log.i(TAG) { "cast: load() accepted by receiver" }
} else {
Log.w(TAG) {
"cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " +
"msg=${status.statusMessage}"
}
// CANCELED is what the receiver answers when the load was
// abandoned because we tore the session down — i.e. the user pressed
// stop. That is the outcome they asked for, not a failure to report.
if (status.statusCode != CastStatusCodes.CANCELED) {
reportMediaFailure(null)
}
}
}
true
} catch (t: Throwable) {
cancelLoadWatchdog()
Log.w(TAG, "cast: remoteMediaClient.load failed", t)
false
}
}
}
// A receiver can reject the media before this coroutine gets here — onMediaError has been
// seen ~150ms after load(). This attempt set Connecting on entry, so any Error sitting here
// now came from its own callbacks and carries the receiver's reason; don't paper over it
// with a Casting state that claims a video is playing when it already failed.
val reportedFailure = sessionFlow.value as? CastSessionState.Error
sessionFlow.value =
if (ok) {
CastSessionState.Casting(device, request)
} else {
CastSessionState.Error(device, "Could not load media on receiver")
when {
reportedFailure != null -> reportedFailure
ok -> CastSessionState.Casting(device, request)
else -> CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_load_failed))
}
}
@@ -462,10 +723,19 @@ class ChromecastCaster(
request.artworkUri?.let {
runCatching { metadata.addImage(WebImage(it.toUri())) }
}
// A live HLS playlist has no #EXT-X-ENDLIST and no duration. Declaring it BUFFERED asks the
// receiver for a seekable stream of known length, which it cannot resolve — the LG webOS
// receiver sits in LOADING forever rather than reporting an error.
val streamType =
if (request.isLive) {
MediaInfo.STREAM_TYPE_LIVE
} else {
MediaInfo.STREAM_TYPE_BUFFERED
}
val info =
MediaInfo
.Builder(request.url)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setStreamType(streamType)
.setContentType(request.effectiveMimeType())
.setMetadata(metadata)
.build()
@@ -476,8 +746,30 @@ class ChromecastCaster(
.build()
}
/**
* Serialises teardown. A stop against an unresponsive receiver can take seconds to ack, so the
* user reasonably presses stop again — and two overlapping teardowns wreck each other: both
* call `stop()` (the second erroring), both call `endCurrentSession`, and the later one installs
* its session-end wait after `onSessionEnded` has already fired, so it waits out the full
* timeout for an event that will never come again.
*/
private val stopInFlight = Mutex()
suspend fun stopCasting() {
Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" }
if (!stopInFlight.tryLock()) {
Log.i(TAG) { "stopCasting: a teardown is already running; ignoring the repeat request" }
return
}
try {
stopCastingLocked()
} finally {
stopInFlight.unlock()
}
}
private suspend fun stopCastingLocked() {
Log.i(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" }
withContext(Dispatchers.Main) { cancelLoadWatchdog() }
// Await MEDIA_STOP before endCurrentSession() — racing them on the
// same main-thread tick loses the stop on some receivers (LG webOS).
val client = currentMediaClient
@@ -487,8 +779,8 @@ class ChromecastCaster(
try {
client.stop().setResultCallback { result ->
val status = result.status
Log.d(TAG) {
"remoteMediaClient.stop ack code=${status.statusCode} msg=${status.statusMessage}"
Log.i(TAG) {
"remoteMediaClient.stop ack code=${statusName(status.statusCode)} msg=${status.statusMessage}"
}
stopAck.complete(status.statusCode)
}
@@ -502,16 +794,37 @@ class ChromecastCaster(
Log.w(TAG) { "remoteMediaClient.stop did not ack within ${STOP_AWAIT_TIMEOUT_MS}ms" }
}
}
val ended = CompletableDeferred<Unit>()
withContext(Dispatchers.Main) {
pendingSessionEnd = ended
try {
// false: receiver app already halted media via stop() above.
// true previously triggered an extra teardown that compounded
// the race — keep the receiver running on its splash screen
// so the next cast can reuse the connection cleanly.
castContext?.sessionManager?.endCurrentSession(false)
// true: shut the receiver application down, don't just detach from it.
//
// This was false, to "keep the receiver running on its splash screen so the next
// cast can reuse the connection cleanly". That is what strands an LG webOS TV on the
// Default Media Receiver holding screen instead of returning it to its home screen,
// and the reused connection is not clean: the SDK hands the same session id back to
// later casts, and after a few stop/start cycles the receiver stops accepting
// connections at all (every start fails 2252) until the TV is power-cycled.
//
// The race that motivated false is now handled directly — the MEDIA_STOP ack is
// awaited above before we get here, and a receiver swap no longer mistakes the
// outgoing session's teardown for a failure of the incoming one.
Log.i(TAG) { "stopCasting: ending session and stopping the receiver app" }
castContext?.sessionManager?.endCurrentSession(true)
} catch (t: Throwable) {
Log.w(TAG, "endCurrentSession failed", t)
ended.complete(Unit)
}
}
// Wait for the session to be gone before unselecting. Unselecting drops the connection the
// STOP_APP message travels over, so doing it on the same tick — as this used to — can beat
// the message to the TV and leave the receiver running on its splash screen.
if (withTimeoutOrNull(SESSION_END_TIMEOUT_MS) { ended.await() } == null) {
Log.w(TAG) { "stopCasting: session did not end within ${SESSION_END_TIMEOUT_MS}ms; unselecting anyway" }
}
withContext(Dispatchers.Main) {
pendingSessionEnd = null
mediaRouter?.unselect(MediaRouter.UNSELECT_REASON_STOPPED)
}
sessionFlow.value = CastSessionState.Idle
@@ -0,0 +1,53 @@
/*
* 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.service.cast
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CastLivenessTest {
@Test
fun playerVerdictWinsOverMetadataWhenItSaysLive() {
// A live .m3u8 shared in a plain kind:1 note carries no live-activity flag, so only
// ExoPlayer's parsed verdict can save it from being cast as a seekable recording.
assertTrue(resolveCastLiveness(learned = true, metadataFlag = false))
}
@Test
fun playerVerdictWinsOverMetadataWhenItSaysOnDemand() {
// A kind:30311 recording stays flagged live long after the broadcast ended; the parsed
// playlist is the ground truth and must override it.
assertFalse(resolveCastLiveness(learned = false, metadataFlag = true))
}
@Test
fun fallsBackToMetadataWhileTheUrlIsStillUnclassified() {
assertTrue(resolveCastLiveness(learned = null, metadataFlag = true))
assertFalse(resolveCastLiveness(learned = null, metadataFlag = false))
}
@Test
fun progressiveMediaStaysBufferedWhenNothingIsKnown() {
// The common case: an MP4 with no verdict and no flag must keep its seek bar.
assertFalse(resolveCastLiveness(learned = null, metadataFlag = false))
}
}
@@ -0,0 +1,66 @@
/*
* 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.service.cast
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CastMediaSummaryTest {
@Test
fun namesTheCodecTheReceiverIsMostLikelyToRefuse() {
// The whole point: "HEVC" is the word that explains why a TV took the file and went quiet.
assertEquals(
"H.265 (HEVC) 1920x1080",
summarizeCastFormat("video/hevc", codecs = "hev1.1.6.L93.B0", width = 1920, height = 1080),
)
}
@Test
fun namesTheCommonCodecsInTermsPeopleRecognise() {
assertEquals("H.264 640x480", summarizeCastFormat("video/avc", null, 640, 480))
assertEquals("AV1 3840x2160", summarizeCastFormat("video/av01", null, 3840, 2160))
assertEquals("VP9 1280x720", summarizeCastFormat("video/x-vnd.on2.vp9", null, 1280, 720))
}
@Test
fun fallsBackToTheRawMimeTypeForCodecsWeDoNotNameOurselves() {
assertEquals("video/quirky 100x50", summarizeCastFormat("video/quirky", null, 100, 50))
}
@Test
fun omitsTheResolutionWhenItIsNotKnown() {
assertEquals("H.264", summarizeCastFormat("video/avc", null, width = -1, height = -1))
assertEquals("H.264", summarizeCastFormat("video/avc", null, width = 0, height = 0))
}
@Test
fun fallsBackToTheCodecStringWhenTheMimeTypeIsMissing() {
// ExoPlayer can report a codec string without a sample mime type on some containers.
assertEquals("hev1.1.6.L93.B0 1920x1080", summarizeCastFormat(null, "hev1.1.6.L93.B0", 1920, 1080))
}
@Test
fun isNullWhenThereIsNothingWorthSaying() {
assertNull(summarizeCastFormat(null, null, -1, -1))
assertNull(summarizeCastFormat("", "", 0, 0))
}
}
@@ -0,0 +1,80 @@
/*
* 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.service.cast
import org.junit.Assert.assertEquals
import org.junit.Test
class CastRoutePlanTest {
private val tv = "route-tv"
private val soundbar = "route-soundbar"
@Test
fun reusesTheSessionOnlyWhenItBelongsToTheTargetRoute() {
assertEquals(
CastRoutePlan.REUSE_SESSION,
planRouteSelection(targetRouteId = tv, selectedRouteId = tv, hasConnectedSession = true),
)
}
@Test
fun switchingReceiversIsASwapNotAReuse() {
// The regression: a session connected to the soundbar was treated as reusable for the TV.
// cast() completed its start immediately, selectRoute() then tore that session down, and the
// load was skipped against a dead session — the TV connected and showed nothing.
assertEquals(
CastRoutePlan.SWAP_RECEIVER,
planRouteSelection(targetRouteId = tv, selectedRouteId = soundbar, hasConnectedSession = true),
)
}
@Test
fun aDisconnectedSessionOnTheTargetRouteStillNeedsAFreshStart() {
assertEquals(
CastRoutePlan.SELECT_FRESH,
planRouteSelection(targetRouteId = tv, selectedRouteId = tv, hasConnectedSession = false),
)
}
@Test
fun theFirstCastOfTheSessionSelectsFresh() {
assertEquals(
CastRoutePlan.SELECT_FRESH,
planRouteSelection(targetRouteId = tv, selectedRouteId = null, hasConnectedSession = false),
)
}
@Test
fun onlyASwapExpectsTheOldSessionToEnd() {
// This is what stops the outgoing session's onSessionEnded from failing the incoming
// attempt, which is why switching devices used to report "session start refused".
assertEquals(true, CastRoutePlan.SWAP_RECEIVER.expectsPreviousSessionToEnd)
assertEquals(false, CastRoutePlan.SELECT_FRESH.expectsPreviousSessionToEnd)
assertEquals(false, CastRoutePlan.REUSE_SESSION.expectsPreviousSessionToEnd)
}
@Test
fun onlyReuseSkipsTheRouteSelection() {
assertEquals(false, CastRoutePlan.REUSE_SESSION.needsRouteSelection)
assertEquals(true, CastRoutePlan.SWAP_RECEIVER.needsRouteSelection)
assertEquals(true, CastRoutePlan.SELECT_FRESH.needsRouteSelection)
}
}
@@ -42,8 +42,8 @@ import org.junit.Test
/**
* A nav-bar tap must land on the tab itself, never on whatever the user had pushed on top of one.
*
* The phone bottom bar gets this for free — it hides itself off tab roots, so it can only ever be
* tapped from one. The large-screen navigation rail stays on screen the whole time, which is where
* The phone bottom bar shows only on tab roots and drawer destinations, so it is tapped from at most
* one screen above a tab. The large-screen navigation rail stays on screen the whole time, which is where
* the gap showed: tapping Home from a thread three screens deep came back to that thread instead of
* the feed.
*
@@ -0,0 +1,118 @@
/*
* 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.ui.navigation
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
/**
* A screen opened from the navigation drawer is a top-level section: it keeps the bottom bar
* (issue #4141, where Pictures and every other drawer destination lost it). It still sits on top of
* the screen the drawer was opened over, so it keeps its back arrow too; the bar is told apart
* from an in-app push by the [DRAWER_ROOT_KEY] stamp these tests pin down.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class NavDrawerTest {
private fun entry(
isTabRoot: Boolean = false,
isDrawerRoot: Boolean = false,
): NavBackStackEntry =
mockk<NavBackStackEntry>(relaxed = true) {
every { savedStateHandle.get<Boolean>(BOTTOM_NAV_ROOT_KEY) } returns isTabRoot
every { savedStateHandle.get<Boolean>(DRAWER_ROOT_KEY) } returns isDrawerRoot
}
/** A controller whose current entry becomes [pushed] once [route] is navigated to. */
private fun controllerPushing(
route: Route,
pushed: NavBackStackEntry,
): NavHostController {
var current = entry(isTabRoot = true)
return mockk<NavHostController>(relaxed = true) {
every { currentBackStackEntry } answers { current }
every { navigate(route) } answers { current = pushed }
}
}
@Test
fun stampsTheEntryItOpens() =
runTest {
val pushed = entry()
val controller = controllerPushing(Route.Pictures(), pushed)
Nav(controller, this).navDrawer(Route.Pictures())
advanceUntilIdle()
verify(exactly = 1) { controller.navigate(Route.Pictures()) }
verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true }
}
@Test
fun stampsTheEntryOfAResolvedRoute() =
runTest {
val pushed = entry()
val controller = controllerPushing(Route.Articles, pushed)
Nav(controller, this).navDrawer { Route.Articles }
advanceUntilIdle()
verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true }
}
@Test
fun plainPushesAreNotStamped() =
runTest {
val pushed = entry()
val controller = controllerPushing(Route.Pictures(), pushed)
Nav(controller, this).nav(Route.Pictures())
advanceUntilIdle()
verify(exactly = 0) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = any<Boolean>() }
}
@Test
fun aTabTappedFromADrawerScreenDropsItFirst() =
runTest {
// Home > Pictures (from the drawer): the bar now shows on Pictures, so it can be tapped
// from there. The drawer entry is not a tab root and must not be saved into the tab.
val controller =
mockk<NavHostController>(relaxed = true) {
every { currentBackStackEntry } returnsMany
listOf(entry(isDrawerRoot = true), entry(isTabRoot = true))
every { popBackStack() } returns true
}
Nav(controller, this).navBottomBar(Route.Message)
advanceUntilIdle()
verify(exactly = 1) { controller.popBackStack() }
}
}
@@ -211,6 +211,7 @@ ALL_TESTS=(
test_28_retention_wn_to_amy
test_29_disband_amy_to_wn
test_30_wn_commit_after_app_data_update
test_31_reaction_materializes_on_wn
)
# --tests runs a subset in the order given. Most tests read state a previous
+36
View File
@@ -301,3 +301,39 @@ test_30_wn_commit_after_app_data_update() {
record_result "$id" fail "amy applied the rename but cannot decrypt wn's next message (forked)"
fi
}
# A reaction White Noise can SEE. Test 09 only proves wn's raw event log holds a
# kind:7; the app renders the materialized timeline, which attaches a reaction
# to its target by its own rules. An amy reaction the raw log kept but the
# timeline dropped read as a pass there and as "no reaction" in the app.
test_31_reaction_materializes_on_wn() {
banner "Test 31 — amy's reaction shows in wn's materialized timeline"
local id="31 reaction materialized"
local out gid mls_gid b_gid anchor_id
out=$(amy_json marmot group create --name "Interop-31") || { record_result "$id" fail "amy group create failed"; return; }
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; }
b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; }
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; }
wn_b messages send "$mls_gid" "31 anchor" >/dev/null 2>&1 || true
amy_json marmot await message "$gid" --match "31 anchor" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got the anchor"; return; }
anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages \
| jq -r 'select((.plaintext // .content // "") == "31 anchor") | (.message_id // .event_id)' | head -n 1)
[[ -n "$anchor_id" && "$anchor_id" != "null" ]] || { record_result "$id" fail "no anchor id in amy's log"; return; }
amy_json marmot message react "$gid" "$anchor_id" "🍕" >/dev/null || { record_result "$id" fail "amy react failed"; return; }
local deadline=$(( $(date +%s) + 90 )) tl=""
while [[ $(date +%s) -lt $deadline ]]; do
tl=$(wn_b --json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true)
if printf '%s' "$tl" | grep -q '🍕'; then
record_result "$id" pass; return
fi
sleep 3
done
printf '%s\n' "$tl" >> "$LOG_FILE"
record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)"
}
@@ -87,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -608,13 +609,20 @@ class MarmotManager(
targetEvent: Event,
reaction: String,
): Event {
val hint =
com.vitorpamplona.quartz.nip01Core.hints
.EventHintBundle(targetEvent)
// A custom-emoji reaction (":name:url") carries its image in an emoji
// tag, exactly as the public NIP-25 path builds it.
val emojiUrl = if (reaction.startsWith(":")) EmojiUrlTag.decode(reaction) else null
val template =
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(
reaction,
com.vitorpamplona.quartz.nip01Core.hints
.EventHintBundle(targetEvent),
)
if (emojiUrl != null) {
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(emojiUrl, hint)
} else {
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(reaction, hint)
}
return com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip25Reactions.ReactionEvent>(
signer.pubKey,
@@ -777,14 +785,18 @@ class MarmotManager(
targetEvents: List<Event>,
persistOwn: Boolean = true,
): TextMessageBundle {
require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" }
val template = DeletionRequestEvent.build(targetEvents)
val innerEvent = RumorAssembler.assembleRumor<DeletionRequestEvent>(signer.pubKey, template)
val innerEvent = buildDeletionRumor(targetEvents)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/** The inner kind:5 deletion rumor alone. See [buildTextRumor]. */
suspend fun buildDeletionRumor(targetEvents: List<Event>): DeletionRequestEvent {
require(targetEvents.isNotEmpty()) { "buildDeletionRumor: targetEvents must not be empty" }
return RumorAssembler.assembleRumor(signer.pubKey, DeletionRequestEvent.build(targetEvents))
}
/**
* A single invitee for [addMemberInvites]: whose KeyPackage is consumed, the bare
* KeyPackage bytes as published, and the id of the event that carried them
@@ -0,0 +1,83 @@
/*
* 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.marmot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The inner rumors the app sends for reactions and deletions in a Marmot group.
* Both used to leave the group as NIP-17 gift wraps to the target's author; they
* are now plain MIP-03 inner events, so their shape is what peers validate.
*/
class MarmotInnerRumorBuildersTest {
private val signer = NostrSignerInternal(KeyPair())
private val manager = MarmotManager(signer, SnapshotStateStore())
private val target =
Event(
id = "b".repeat(64),
pubKey = "c".repeat(64),
createdAt = 1_790_000_000,
kind = 9,
tags = emptyArray(),
content = "react to me",
sig = "",
)
@Test
fun aReactionNamesItsTargetFirstAndIsAnUnsignedRumor() =
runBlocking<Unit> {
val rumor = manager.buildReactionRumor(target, "🎉")
assertEquals(ReactionEvent.KIND, rumor.kind)
assertEquals("🎉", rumor.content)
assertEquals(signer.pubKey, rumor.pubKey)
assertTrue(rumor.sig.isEmpty(), "MIP-03 inner events are unsigned rumors")
// MDK attaches a reaction to the FIRST e tag.
assertEquals(target.id, rumor.tags.first { it[0] == "e" }[1])
}
@Test
fun aCustomEmojiReactionCarriesItsImage() =
runBlocking<Unit> {
val rumor = manager.buildReactionRumor(target, ":soapbox:https://example.com/soapbox.png")
assertEquals(":soapbox:", rumor.content)
val emoji = rumor.tags.first { it[0] == "emoji" }
assertEquals(listOf("emoji", "soapbox", "https://example.com/soapbox.png"), emoji.take(3))
}
@Test
fun aDeletionTargetsEachRetractedEvent() =
runBlocking<Unit> {
val rumor = manager.buildDeletionRumor(listOf(target))
assertEquals(DeletionRequestEvent.KIND, rumor.kind)
assertEquals(signer.pubKey, rumor.pubKey)
assertTrue(rumor.sig.isEmpty())
assertEquals(listOf(target.id), rumor.tags.filter { it[0] == "e" }.map { it[1] })
}
}
@@ -653,6 +653,19 @@
<string name="mark_all_known_as_read">सब ज्ञात को पढ लिया चिह्नित करें</string>
<string name="mark_all_new_as_read">सब नये को पढ लिया चिह्नित करें</string>
<string name="mark_all_as_read">सब को पढ लिया चिह्नित करें</string>
<string name="account_backup_headline">आपकी गुप्त कुंचिका आपका लेखा है</string>
<string name="account_backup_tap_to_reveal">दबाएँ देखने के लिए</string>
<string name="account_backup_write_down_hint">इसे इस क्रम मे लिख लें। बडे अथवा छोटे अक्षर दोनों कार्य करेंगे।</string>
<string name="account_backup_qr_code">क्यूआर अक्षरराशि</string>
<string name="account_backup_encrypted_title">पारणशब्द रक्षित अनुकृति</string>
<string name="account_backup_encrypted_password">पारणशब्द</string>
<string name="account_backup_encrypted_body">एक ncryptsec1… जो केवल आपके पारणशब्द से कार्य करता है।</string>
<string name="account_backup_password_min_length">न्यूनतम %1$d अक्षर</string>
<string name="account_backup_encrypted_repeat_password">पारणशब्द दोहराएँ</string>
<string name="account_backup_encrypted_password_mismatch">पारणशब्दों में मेल नहीं</string>
<string name="account_backup_encrypt">रहस्यीकरण कुंचिका</string>
<string name="account_backup_encrypting">रहस्यीकरणमध्य…</string>
<string name="account_backup_encrypt_again">एक पृथक पारणशब्द का उपयोग करें</string>
<string name="backup_keys_nudge_title">अपने कुंचिकाओं को सुरक्षित रखें</string>
<string name="backup_keys_nudge_body">आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय।</string>
<string name="backup_keys_nudge_backup_now">अभी इसी समय सुरक्षित अनुकृति बनाएँ</string>
@@ -5453,6 +5466,10 @@
<string name="browser_pill_info_certificate">प्रमाणपत्र</string>
<string name="browser_pill_info_certificate_desc">निर्गत %1$s को %2$s द्वारा। मान्य %3$s तक</string>
<string name="browser_pill_info_permissions">अनुमतियाँ</string>
<string name="browser_pill_info_site_data">सेवाप्रसंग अभिलेख तथा जालस्थान जानकारी</string>
<string name="browser_pill_info_site_data_desc">प्रवेशांकन तथा आद्यताएँ जो इस जालस्थान ने अभिलेखित किया इस लेखा के लिए</string>
<string name="browser_pill_info_clear">जालस्थान जानकारी रिक्त करें</string>
<string name="browser_pill_info_clear_confirm">क्या इस जालस्थान से निर्गमनांकन करें तथा इसकी जानकारी मिटा दें।</string>
<string name="backup_action_block_again">पुनःबाधित करें %1$s को</string>
<string name="backup_action_keep_count">रखें %1$s</string>
<string name="backup_action_rejoin">पुनःजुडें %1$s</string>
@@ -5777,6 +5794,14 @@
<string name="backup_type_trust_providers_explainer">सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए।</string>
<string name="backup_type_trusted_relays">विश्वसनीय पुनःप्रसारक सूची</string>
<string name="backup_type_trusted_relays_explainer">पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए।</string>
<string name="cordn_action_delete">मिटाएँ</string>
<string name="cordn_action_edit">सम्पादन</string>
<string name="cordn_action_editing">आपके सन्देश का सम्पादन</string>
<string name="cordn_action_go_to_message">सन्देश पर जाएँ</string>
<string name="cordn_action_pin">टाँकें</string>
<string name="cordn_action_reply">उत्तर</string>
<string name="cordn_action_unpin">टाँका हटाएँ</string>
<string name="cordn_admin_action_failed">वह परिवर्तन असफल।</string>
<string name="cordn_admin_not_a_member">%1$s इस समुह में नहीं है।</string>
<string name="cordn_admin_someone">वह व्यक्ति</string>
<string name="cordn_backup_contents_title">अभिलेख में क्या है</string>
@@ -5795,4 +5820,149 @@
<string name="cordn_coordinators_add_action">जोडें</string>
<string name="cordn_coordinators_add_failed">उस समायोजक को खोलने में असफल।</string>
<string name="cordn_coordinators_copy_key">समायोजक के एनपुब॰ की अनुकृति</string>
<string name="cordn_coordinators_discover_action">समायोजकों के लिए ढूँढें</string>
<string name="cordn_coordinators_discover_add">जोडें</string>
<string name="cordn_coordinators_discover_failed">घषणाओं को पढने में असफल</string>
<string name="cordn_coordinators_discover_none">आपके पुनःप्रसारकों में कोई भी घषणा नहीं कर रहा।</string>
<string name="cordn_coordinators_discover_seen">घोषित %1$s पूर्व</string>
<string name="cordn_coordinators_discover_seen_on">पूर्व घोषित %1$s</string>
<string name="cordn_coordinators_health_ok">उत्तर दे रहा है।</string>
<string name="cordn_coordinators_health_retrying">एक आह्वान असफल। पुनःप्रयासमध्य।</string>
<string name="cordn_coordinators_health_unknown">उससे कुछ पूछा नहीं गया अब तक।</string>
<string name="cordn_coordinators_identify">पूछें कि कौन हैं</string>
<string name="cordn_coordinators_key">समायोजक कुंचिका</string>
<string name="cordn_coordinators_label">उसके लिए एक नाम। (आपका। विककल्पात्मक)</string>
<string name="cordn_coordinators_more">अधिक क्रियाएँ</string>
<string name="cordn_coordinators_none">कोई समायोजक नहीं अब तक।</string>
<string name="cordn_coordinators_purge">मिटाएँ</string>
<string name="cordn_coordinators_purge_title">क्या इस समायोजक को मिटा दें।</string>
<string name="cordn_coordinators_relays">%1$s</string>
<string name="cordn_coordinators_relays_label">उत्तर सक्रिय</string>
<string name="cordn_coordinators_relays_more">%1$s स %2$d अधिक</string>
<string name="cordn_coordinators_remove">हटाएँ</string>
<string name="cordn_coordinators_rename">पुनःनामकरण</string>
<string name="cordn_coordinators_server">कहता है कि यह है। %1$s</string>
<string name="cordn_coordinators_server_silent">उत्तर दिया। पर कुछ भी नामित नहीं।</string>
<string name="cordn_coordinators_show_all">सब कुछ दिखाएँ %1$d</string>
<string name="cordn_coordinators_show_fewer">अल्पतर संख्या दिखाएँ</string>
<string name="cordn_create_action">समूह बनाएँ</string>
<plurals name="cordn_create_action_invite">
<item quantity="one">बनाएँ तथा %1$d व्यक्ति को आमन्त्रण दें</item>
<item quantity="other">बनाएँ तथा %1$d व्यक्तियों को आमन्त्रण दें</item>
</plurals>
<string name="cordn_create_add_member">किसी को जोडें</string>
<string name="cordn_create_admin">प्रशासक</string>
<string name="cordn_create_admin_only_me">केवल मैं इस समूह का प्रबन्धक</string>
<string name="cordn_create_coordinator">समायोजक</string>
<string name="cordn_create_coordinator_change">परिवर्तन</string>
<string name="cordn_create_coordinator_discover">समायोजक ढूँढें</string>
<string name="cordn_create_coordinator_hide">हो गया</string>
<string name="cordn_create_coordinator_new">एक अन्य समायोजक</string>
<string name="cordn_create_coordinator_pubkey">समायोजक ख्याप्य कुंचिका। षोडशांक अथवा एनपुब॰</string>
<string name="cordn_create_coordinator_relays">पुनःप्रसारक जिन पर यह उत्तर देता है। एक प्रति पंक्ति</string>
<string name="cordn_create_description">विवरण (विकल्पात्मक)</string>
<string name="cordn_create_egalitarian">सभी जोड सकते हैं तथा हटा सकते हैं</string>
<string name="cordn_create_failed">समूह बनाने में असफल।</string>
<string name="cordn_create_name">समूह नाम</string>
<string name="cordn_create_no_coordinator">कोई समायोजक चयनित नहीं</string>
<string name="cordn_create_outcome_failed">समायोजक ने आमन्त्रण अस्वीकार किया</string>
<string name="cordn_create_outcome_link">योजक भेजें</string>
<string name="cordn_create_outcome_open">चर्चा खोलें</string>
<string name="cordn_create_outcome_skipped">कोई कुंचिका नहीं यहाँ। इसलिए कोई स्वागत सन्देश छोडा नहीं जा सका</string>
<string name="cordn_create_outcome_title">समूह है</string>
<string name="cordn_create_outcome_waiting">उनकी प्रतीक्षा में</string>
<string name="cordn_create_people_done">हो गया</string>
<string name="cordn_create_people_none">कोई नहीं अब तक। दबाएँ लोगों को जोडने के लिए</string>
<plurals name="cordn_create_reach_count">
<item quantity="one">अभिगम्य %1$d समायोजक पर</item>
<item quantity="other">अभिगम्य %1$d समायोजकों पर</item>
</plurals>
<string name="cordn_create_reach_none">कोई कुंचिका नहीं आपके द्वारा उपयुक्त किसी समायोजक के पास</string>
<string name="cordn_create_reach_unknown">अब तक जाँच नहीं की</string>
<string name="cordn_create_step_admins">कौन जोड सकता है तथा हटा सकता है</string>
<string name="cordn_create_step_name">नामकरण</string>
<string name="cordn_create_step_people">कौन इसमें हैं</string>
<string name="cordn_create_step_where">कहाँ उसका आवास</string>
<string name="cordn_create_title">नया कोर्डन समूह</string>
<string name="cordn_create_you">आप। सर्वदा प्रशासक</string>
<string name="cordn_delete_confirm_title">सन्देश लौटाएँ</string>
<string name="cordn_delivery_accepted">समायोजक द्वारा स्वीकृत</string>
<string name="cordn_exposure_close">समझ गया</string>
<string name="cordn_exposure_open">यह समायोजक क्या देख सकता है</string>
<string name="cordn_group_info">समूह जानकारी</string>
<plurals name="cordn_groups_count">
<item quantity="one">%1$d समूह</item>
<item quantity="other">%1$d समूह</item>
</plurals>
<string name="cordn_groups_manage">समायोजक</string>
<string name="cordn_groups_none">कोई कोर्डन समूह नहीं अब तक</string>
<string name="cordn_groups_start">एक का आरम्भ करें</string>
<string name="cordn_info_add_member">किसी को जोडें</string>
<string name="cordn_info_add_member_none">उस नाम से कोई प्राप्त नहीं।</string>
<string name="cordn_info_add_member_placeholder">नाम अथवा एनपुब॰ अथवा नाम@जालक्षेत्र</string>
<string name="cordn_info_admin_badge">प्रशासक</string>
<string name="cordn_info_coordinator">समायोजक</string>
<string name="cordn_info_coordinator_key">समायोजक कुंचिका</string>
<string name="cordn_info_coordinator_nprofile">समायोजक योजक</string>
<string name="cordn_info_copy_nprofile">समायोजक एनप्रोफैल॰ की अनुकृति</string>
<string name="cordn_info_edit_details">विवरण सम्पादन</string>
<string name="cordn_info_epoch">युग</string>
<string name="cordn_info_gid">समूह परिचायक</string>
<string name="cordn_info_has_key_package">इस समायोजक पर जोडा जा सकता है</string>
<string name="cordn_info_members">सदस्य</string>
<string name="cordn_info_remove_confirm_title">क्या समूह से हटाएँ।</string>
<string name="cordn_info_remove_member">हटाएँ</string>
<string name="cordn_info_save">अभिलेखन</string>
<string name="cordn_info_technical">तन्त्रज्ञानात्मक विवरण</string>
<string name="cordn_invitations_accept">जुडें</string>
<string name="cordn_invitations_decline">अस्वीकार</string>
<string name="cordn_invitations_load_failed">आमन्त्रणों को पढने में असफल।</string>
<string name="cordn_invitations_members_more">स %1$d अधिक</string>
<string name="cordn_invitations_none">कोई आमन्त्रण प्रतीक्षा नहीं कर रहा।</string>
<string name="cordn_invitations_refresh">पुनःजाचें</string>
<string name="cordn_invitations_skipped">एक आमन्त्रण छोडा गया एक अन्य यन्त्र के लिए</string>
<string name="cordn_invitations_title">कोर्डन आमन्त्रण</string>
<string name="cordn_invitations_unreachable">%1$s ने उत्तर नहीं दिया</string>
<string name="cordn_invitations_via">%1$s द्वारा</string>
<string name="cordn_keypackages_failed">कुंचिका पोटलियाँ पढने में असफल।</string>
<string name="cordn_keypackages_publish">एक का प्रकाशन</string>
<string name="cordn_keypackages_publish_last_resort">अन्तिम उपाय का प्रकाशन</string>
<string name="cordn_keypackages_withdraw_all">सब लौटा लें</string>
<string name="cordn_keypackages_withdraw_orphans">उनको लौटा लें</string>
<string name="cordn_link_request">इस समूह से जुडने का अनुरोध करें</string>
<string name="cordn_link_request_failed">जुडने का अनुरोध असफल।</string>
<string name="cordn_link_scan">परखें</string>
<string name="cordn_media_download_failed">उस अभिलेख को खोलने में असफल।</string>
<string name="cordn_media_unreadable">वह अभिलेख पठनशक्य नहीं।</string>
<string name="cordn_media_upload_failed">उस अभिलेख को भेजने में असफल।</string>
<plurals name="cordn_member_count">
<item quantity="one">%1$d सदस्य</item>
<item quantity="other">%1$d सदस्य</item>
</plurals>
<string name="cordn_message_deleted">सन्देश मिटाया गया</string>
<string name="cordn_message_details_coordinator">समायोजक</string>
<string name="cordn_message_details_cursor">धावक</string>
<string name="cordn_message_details_sent_at">भेजा गया</string>
<string name="cordn_message_details_title">सन्देश विवरण</string>
<string name="cordn_message_edited">सम्पादित</string>
<string name="cordn_pinned_by">टाँका गया %1$s द्वारा</string>
<string name="cordn_pinned_next">अगला टाँका गया सन्देश</string>
<string name="cordn_pinned_previous">पिछला टाँका गया सन्देश</string>
<string name="cordn_pinned_show_all">सभी टाँके गए सन्देशों को दिखाएँ</string>
<string name="cordn_pinned_title">टाँके गए सन्देश</string>
<string name="cordn_reactions_title">प्रतिक्रियाएँ</string>
<string name="cordn_requests_accept">जोडें</string>
<string name="cordn_requests_check">अनुरोधों के लिए जाँचें</string>
<string name="cordn_requests_decline">हटाएँ</string>
<string name="cordn_requests_failed">अनुरोधों को पढने में असफल।</string>
<string name="cordn_requests_none">जुडने की प्रतीक्षा में कोई नहीं।</string>
<string name="cordn_requests_title">जुडने के अनुरोध</string>
<string name="cordn_send">भेजें</string>
<string name="cordn_send_failed">भेजने में असफल।</string>
<string name="cordn_share_copied">अनुकृत</string>
<string name="cordn_share_copy">योजक अनुकृति</string>
<string name="cordn_share_title">इस समूह को बाँटें</string>
<string name="cordn_voice_play">ध्वनि टीका चलाएँ</string>
<string name="cordn_voice_record">ध्वनि टीका का अभिलेखन करें</string>
<string name="cordn_voice_stop">रोकें तथा भेजें</string>
</resources>
@@ -669,6 +669,24 @@
<string name="mark_all_known_as_read">Zaznacz wszystkie popularne jako przeczytane</string>
<string name="mark_all_new_as_read">Zaznacz wszystkie nowe jako przeczytane</string>
<string name="mark_all_as_read">Zaznacz wszystkie jako przeczytane</string>
<string name="account_backup_headline">Twój tajny klucz to Twoje konto</string>
<string name="account_backup_intro">Każdy, kto go posiada, może publikować posty pod twoim imieniem. Jeśli go zgubisz, nikt nie będzie w stanie go odzyskać, nawet Amethyst.</string>
<string name="account_backup_tap_to_reveal">Kliknij, aby przejrzeć</string>
<string name="account_backup_write_down_hint">Zapisz to w tej kolejności. Można używać zarówno wielkich, jak i małych liter.</string>
<string name="account_backup_qr_code">Kod QR</string>
<string name="account_backup_encrypted_title">Kopia chroniona hasłem</string>
<string name="account_backup_encrypted_password">Hasło</string>
<string name="account_backup_encrypted_body">Ncryptsec1… który działa tylko z twoim hasłem.</string>
<string name="account_backup_password_min_length">Co najmniej %1$d znaków</string>
<string name="account_backup_encrypted_repeat_password">Powtórz hasło</string>
<string name="account_backup_encrypted_password_mismatch">Hasła nie są identyczne</string>
<string name="account_backup_encrypt">Zaszyfruj klucz</string>
<string name="account_backup_encrypting">Szyfrowanie…</string>
<string name="account_backup_encrypted_saved_hint">Można je bezpiecznie przechowywać w menedżerze haseł lub notatkach w chmurze: nie da się ich otworzyć bez hasła, a zapomnianego hasła nie da się odzyskać.</string>
<string name="account_backup_encrypt_again">Użyj innego hasła</string>
<string name="account_backup_tip_storage">Schowaj ten zapis w miejscu, które znasz tylko ty, albo zapisz klucz w menedżerze haseł.</string>
<string name="account_backup_tip_trust">Nigdy nie wpisuj swojego klucza na stronach internetowych ani w aplikacjach, do których nie masz zaufania.</string>
<string name="account_backup_tip_developers">Programiści serwisu Amethyst nigdy nie poproszą Cię o podanie klucza.</string>
<string name="backup_keys_nudge_title">Kopia zapasowa kluczy</string>
<string name="backup_keys_nudge_body">Twój tajny klucz jest jedynym sposobem na dostęp do tego konta. Jeśli go zgubisz, nigdy nie będzie można go odzyskać. Zapisz go w bezpiecznym miejscu.</string>
<string name="backup_keys_nudge_backup_now">Utwórz kopię zapasową</string>
@@ -5476,6 +5494,12 @@
<string name="cordn_health_unknown">Jeszcze się nie skontaktowano</string>
<string name="cordn_health_ok">Odpowiedź</string>
<string name="cordn_health_down">Brak odpowiedzi</string>
<plurals name="cordn_health_failures">
<item quantity="one">%1$d nieudana próba z rzędu</item>
<item quantity="few">%1$d nieudanych prób z rzędu</item>
<item quantity="many">%1$d nieudanych prób z rzędu</item>
<item quantity="other">%1$d nieudane próby z rzędu</item>
</plurals>
<string name="cordn_link_explainer">Wklej link „cordn1…” udostępniony przez inną osobę, aby sprawdzić, który koordynator prowadzi tę grupę oraz jakie informacje o tobie uzyskałby ten operator, gdybyś do niej dołączył.</string>
<string name="cordn_link_field">cordn1…</string>
<string name="cordn_link_inspect">Zbadaj</string>
@@ -5564,6 +5588,12 @@
<string name="cordn_migrate_import">Przynieś grupy tutaj</string>
<string name="cordn_migrate_importing">Pobieranie…</string>
<string name="cordn_migrate_replaces_warning">Powyższe dane zastępują wszelkie grupy „cordn” już istniejące w tym telefonie. Nie można ich połączyć.</string>
<plurals name="cordn_migrate_done">
<item quantity="one">%1$d grupa przeniesiona</item>
<item quantity="few">%1$d grup przeniesionych</item>
<item quantity="many">%1$d grup przeniesiono</item>
<item quantity="other">%1$d grupy przeniesione</item>
</plurals>
<string name="cordn_migrate_no_groups">Na tym urządzeniu nie ma żadnych grup cordn, które można by przenieść.</string>
<string name="cordn_migrate_no_server">Najpierw skonfiguruj serwer multimediów — zaszyfrowane dokumenty muszą gdzieś się znaleźć, dopóki drugi telefon je pobierze.</string>
<string name="cordn_migrate_exposure_title">Co opuszcza to urządzenie</string>
@@ -2708,6 +2708,20 @@
<string name="nickname_private">Only visible to you</string>
<string name="cast_to_device_dialog_title">Cast to…</string>
<string name="cast_searching_for_devices">Searching for devices on your Wi-Fi…</string>
<string name="cast_local_network_permission_title">Permission needed</string>
<string name="cast_local_network_permission_message">Amethyst needs local network access to find Cast devices on your Wi-Fi. Please enable it in the app settings.</string>
<string name="cast_local_network_permission_open_settings">Open settings</string>
<string name="cast_generic_receiver">The cast device</string>
<string name="cast_error_unsupported_media">%1$s can't play this video's format</string>
<string name="cast_error_playback_failed">%1$s couldn't play this video</string>
<string name="cast_error_receiver_not_responding">%1$s stopped responding. Restarting the device usually fixes it.</string>
<string name="cast_error_connect_failed">Couldn't connect to %1$s. Restarting the device usually fixes it.</string>
<string name="cast_error_device_offline">%1$s went offline</string>
<string name="cast_error_load_failed">Couldn't load this video on %1$s</string>
<string name="cast_error_play_services_unavailable">Casting needs Google Play services, which aren't available on this device</string>
<string name="cast_error_unsupported_media_detail">%1$s can't play this video's format (%2$s)</string>
<string name="cast_error_playback_failed_detail">%1$s couldn't play this video (%2$s)</string>
<string name="cast_error_receiver_not_responding_detail">%1$s never started playing this video (%2$s). It may not support that format, or restarting the device may help.</string>
<string name="hls_pick_video_primary">Pick a video</string>
<string name="hls_pick_video_helper">Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection.</string>
<string name="hls_change_video">Change</string>
@@ -199,7 +199,7 @@ private fun ScaffoldLayout(
}.firstOrNull()?.measure(looseConstraints)
}
// When the bar lambda is provided but its content emits nothing (e.g. AppBottomBar
// hides itself on canPop entries, or while the keyboard is up), reserve the
// hides itself on in-app pushes, or while the keyboard is up), reserve the
// system-nav-bar inset so the FAB and content stay clear of the navigation bar instead
// of sliding under it. Subtract the IME inset: the root imePadding has already lifted the
// whole scaffold above the keyboard, and the IME inset spans the nav-bar band, so
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
/**
* Reserves the visual space the `AppBottomBar` occupies on root tab entries so a
* FloatingActionButton stays at the same vertical position whether or not the bar is
* rendered. `AppBottomBar` hides itself on canPop entries (drawer pushes, in-app
* rendered. `AppBottomBar` hides itself off [INav.showsBottomBar] entries (in-app
* navigations) — without this padding the FAB drops by `AppBottomBarHeight` there.
*
* The system-navigation-bar inset is already handled by the surrounding Scaffold, so
@@ -41,8 +41,8 @@ val FABPaddingFromBottom = 30.dp
@Composable
fun Modifier.fabBottomBarPadding(nav: INav): Modifier =
if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) {
// canPop entries hide the bar on phones; large screens never render it at all.
if (!nav.showsBottomBar() || LocalScreenLayout.current.isLargeScreen) {
// In-app pushes hide the bar on phones; large screens never render it at all.
padding(bottom = FABPaddingFromBottom)
} else {
this
@@ -52,9 +52,26 @@ interface INav {
fun navBottomBar(route: Route)
/**
* Opens [route] as a top-level destination picked from the navigation drawer. It still
* stacks on top of the current screen (so it [canPop]), but unlike an in-app push it keeps
* the bottom bar — see [showsBottomBar].
*/
fun navDrawer(route: Route) = nav(route)
/** [navDrawer], for a route that has to be resolved first. */
fun navDrawer(computeRoute: suspend () -> Route?) = nav(computeRoute)
@Composable
fun canPop(): Boolean
/**
* Whether this screen renders the bottom bar: tab roots, the start destination and
* destinations opened from the drawer ([navDrawer]) do; in-app pushes don't.
*/
@Composable
fun showsBottomBar(): Boolean = !canPop()
fun popBack()
fun <T : Route> popUpTo(
+30 -4
View File
@@ -3,8 +3,8 @@
A standalone [Nostr](https://github.com/nostr-protocol/nips) relay for the JVM,
built on Quartz's relay-server code (Ktor CIO). It speaks the core relay
protocol plus NIP-11 (info doc), NIP-42 (AUTH), NIP-45 (COUNT), NIP-50
(full-text search), NIP-77 (Negentropy sync), and NIP-86 (relay management),
stores events in SQLite (or a filesystem backend), and can mirror upstream
(full-text search), NIP-77 (Negentropy sync), NIP-86 (relay management) and
NIP-FE (REQ/COUNT/EVENT over plain HTTP), stores events in SQLite (or a filesystem backend), and can mirror upstream
relays strfry-router style.
geode depends only on `:quartz` — no Android, no Compose. `amy serve` (the
@@ -97,8 +97,34 @@ geode --version
Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools),
`[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search),
`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and
`[admin]` (NIP-86 management). See the example file for every knob.
`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring),
`[http]` (NIP-FE limits) and `[admin]` (NIP-86 management). See the example
file for every knob.
### Commands over HTTP (NIP-FE)
Besides the websocket, geode answers one command per HTTP `POST` to the relay
URL: the body is the frame you would send on the socket, and the answer is the
socket's frames as NDJSON, streamed — no socket, no subscription left open:
```bash
curl -N --compressed -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/
# ["EVENT","q",{"id":"…","kind":1,…}]
# ["EVENT","q",{"id":"…","kind":1,…}]
# ["EOSE","q"]
curl -d '["COUNT","c",{"kinds":[1]}]' http://localhost:7447/ # ["COUNT","c",{"count":2}]
curl -d "[\"EVENT\",$(cat signed-event.json)]" http://localhost:7447/ # ["OK","<id>",true,""]
```
A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or
`OK` (EVENT) was cut off. On an AUTH-gated relay the answer is `401` until the
request carries a NIP-98 `Authorization: Nostr …` header whose `u` is the
relay's http URL and whose `payload` is the body's sha256. NIP-86 admin calls
share the URL, told apart by `Content-Type: application/nostr+json+rpc`.
Streamed answers are gzipped for clients that send `Accept-Encoding: gzip`
(`curl --compressed`), sync-flushed so events still arrive as they are found;
`[http].gzip = false` turns it off. Quartz's `HttpRelayClient` does all of this
for clients, over OkHttp via `OkHttpRelayTransport` or any `HttpRelayTransport`.
## Verbs
+39 -1
View File
@@ -19,7 +19,8 @@ contact = "admin@example.com"
# pubkey = "..."
# Override the supported NIPs advertised on the NIP-11 endpoint. If
# omitted, the relay advertises the NIPs it actually implements.
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62]
# Hex-named NIPs go in as strings.
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62, "FE"]
[network]
host = "0.0.0.0"
@@ -165,6 +166,43 @@ require_auth = false
# backfill_seconds = 3600
# filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}'
[http]
# NIP-FE: relay commands over HTTP. One REQ, COUNT or EVENT frame per
# POST to the relay URL, exactly as it would go on the websocket,
# answered as NDJSON (application/x-ndjson) in the socket's own frames and
# streamed as it is found; nothing stays open afterwards. NIP-86 calls
# share the URL, told apart by their application/nostr+json+rpc type.
# NIP-98 `Authorization: Nostr ...` headers sign a request in, exactly as
# NIP-42 AUTH would on the socket. On by default; turning it off also
# drops "FE" from the default NIP-11 list.
enabled = true
# Every request is its own connection, so the websocket's
# per-connection limits don't bound HTTP clients. These do: over the
# per-client cap a request gets 429, over the global cap 503, both with
# Retry-After. 0 = no limit.
max_concurrent_requests = 256
max_requests_per_client = 16
# A body still arriving after this is dropped with 408, freeing its slot.
body_timeout_seconds = 10
# An answer still running after this ends on a CLOSED line.
deadline_seconds = 30
max_body_bytes = 524288
# Gzip streamed answers for clients that send Accept-Encoding: gzip. Each
# batch of lines is sync-flushed, so events still arrive as they are
# found. Turn off if a proxy in front already compresses.
gzip = true
retry_after_seconds = 1
# Other addresses this relay is reachable at: a NIP-98 token may name
# any of them, as well as [info].relay_url.
# alternate_urls = ["ws://youraddress.onion/"]
# Behind a reverse proxy every request comes from the proxy's address.
# List the proxies here and the per-client cap counts the address the
# proxy writes in client_address_header instead (its last entry). The
# header is ignored from anyone else. Also set `proxy_buffering off`
# (nginx) or equivalent; the relay sends X-Accel-Buffering: no.
# trusted_proxies = ["127.0.0.1"]
# client_address_header = "X-Forwarded-For"
[admin]
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
# accepts HTTP POST application/nostr+json+rpc on the same URL,
@@ -20,13 +20,16 @@
*/
package com.vitorpamplona.geode
import com.vitorpamplona.geode.server.HttpCommandSettings
import com.vitorpamplona.geode.server.Nip11HttpRoute
import com.vitorpamplona.geode.server.Nip86HttpRoute
import com.vitorpamplona.geode.server.NipFEHttpRoute
import com.vitorpamplona.geode.server.WebSocketSessionPump
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler
import io.ktor.server.application.install
import io.ktor.server.application.serverConfig
import io.ktor.server.cio.CIO
@@ -34,6 +37,7 @@ import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.get
import io.ktor.server.routing.options
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
@@ -77,6 +81,11 @@ class KtorRelay(
val workerGroupSize: Int? = null,
/** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */
val callGroupSize: Int? = null,
/**
* NIP-FE: relay commands POSTed to the relay's URL, beside NIP-86. On by default; null turns them
* off, every POST going to NIP-86 again (and the operator should then drop `FE` from NIP-11).
*/
val httpCommands: HttpCommandSettings? = HttpCommandSettings(),
) {
/**
* NIP-86 HTTP adapter. Wraps the engine's [RelayEngine.nip86Server]
@@ -104,6 +113,20 @@ class KtorRelay(
private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json })
/**
* NIP-FE. Each request runs on its own session of the same engine, so the websocket's policies
* and limits apply. A NIP-98 token must name `relay.url` read as http(s), or one of the
* configured alternate URLs (a .onion).
*/
private val nipFERoute =
httpCommands?.let { settings ->
val origins = (listOf(relay.url) + settings.alternateUrls).map { it.toHttp() }
NipFEHttpRoute(
handler = HttpRelayHandler(relay.server, origins = { origins }, deadline = settings.deadline),
settings = settings,
)
}
private var engine: CIOApplicationEngine? = null
private var resolvedPort: Int = -1
@@ -164,12 +187,18 @@ class KtorRelay(
get(path) {
nip11Route.handle(call)
}
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
// Always mounted; an empty admin allow-list on the engine just means
// every request fails the allow-list check (403).
// Two POSTs share the relay URL, told apart by Content-Type:
// - NIP-86: application/nostr+json+rpc with a NIP-98 signed
// Authorization header → JSON-RPC dispatch. Always mounted; an
// empty admin allow-list just means every call fails it (403).
// - NIP-FE: anything else is one REQ/COUNT/EVENT frame, answered
// as NDJSON in the socket's own frames.
post(path) {
nip86Route.handle(call)
val commands = nipFERoute
if (commands != null && commands.isCommand(call)) commands.handle(call) else nip86Route.handle(call)
}
nipFERoute?.let { route ->
options(path) { route.preflight(call) }
}
webSocket(path) {
if (shuttingDown) {
@@ -35,9 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
@@ -356,6 +354,7 @@ private fun serve(args: Array<String>) {
connectionGroupSize = config.network.connection_group_size,
workerGroupSize = config.network.worker_group_size,
callGroupSize = config.network.call_group_size,
httpCommands = config.http.toSettings(),
).start()
// `[[mirror]]` upstreams: dial each configured relay and stream its
@@ -526,9 +525,9 @@ private fun composePolicy(
val pieces = mutableListOf<IRelayPolicy>()
if (requireAuth) {
pieces += FullAuthPolicy(advertisedUrl)
pieces += SignInPolicy(advertisedUrl)
} else if (optionalAuth) {
pieces += OptionalAuthPolicy(advertisedUrl)
pieces += OptionalSignInPolicy(advertisedUrl)
}
config.options.reject_future_seconds?.let { secs ->
@@ -62,9 +62,10 @@ data class RelayInfo(
* - 62 NIP-62 right to vanish
* - 77 NIP-77 negentropy reconciliation
* - 86 NIP-86 relay management API (when admin pubkeys configured)
* - FE NIP-FE relay commands over HTTP (KtorRelay; `[http].enabled`)
*/
val SUPPORTED_NIPS: List<String> =
listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86")
listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86", "FE")
/** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */
fun default(url: NormalizedRelayUrl): RelayInfo =
@@ -0,0 +1,43 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy
/**
* Geode's AUTH decides nothing past the NIP-42 proof, so a key a NIP-98 header proved on a NIP-FE
* command signs in just the same: the transport's proof is all the socket's would have been.
*/
internal class SignInPolicy(
relay: NormalizedRelayUrl,
) : FullAuthPolicy(relay) {
override suspend fun authorizeTransport(pubkey: HexKey): String? = null
}
/** [SignInPolicy] for optional AUTH. */
internal class OptionalSignInPolicy(
relay: NormalizedRelayUrl,
) : OptionalAuthPolicy(relay) {
override suspend fun authorizeTransport(pubkey: HexKey): String? = null
}
@@ -21,12 +21,15 @@
package com.vitorpamplona.geode.config
import cc.ekblad.toml.decode
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.geode.server.HttpCommandSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
import kotlin.time.Duration.Companion.seconds
/**
* Operator-facing **boot-time** configuration. Parsed once from a TOML
@@ -45,10 +48,14 @@ data class StaticConfig(
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
val negentropy: NegentropySection = NegentropySection(),
val http: HttpSection = HttpSection(),
/** `[[mirror]]` entries — upstream relays this relay streams from. */
val mirror: List<MirrorSection> = emptyList(),
) {
fun resolveInfo(fullTextSearch: Boolean = true): RelayInfo =
fun resolveInfo(
fullTextSearch: Boolean = true,
httpCommands: Boolean = http.enabled,
): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = info.name ?: RelayInfo.NAME,
@@ -59,10 +66,10 @@ data class StaticConfig(
software = info.software ?: RelayInfo.SOFTWARE,
version = info.version ?: RelayInfo.VERSION,
supported_nips =
info.supported_nips?.map(Int::toString)
info.supported_nips
// An explicit [info] nips list is operator-authoritative;
// the default list stays honest about search.
?: if (fullTextSearch) RelayInfo.SUPPORTED_NIPS else RelayInfo.SUPPORTED_NIPS - "50",
// the default list stays honest about search and HTTP commands.
?: RelayInfo.SUPPORTED_NIPS.filter { (fullTextSearch || it != "50") && (httpCommands || it != "FE") },
privacy_policy = info.privacy_policy,
terms_of_service = info.terms_of_service,
relay_countries = info.relay_countries,
@@ -80,7 +87,8 @@ data class StaticConfig(
val icon: String? = null,
val software: String? = null,
val version: String? = null,
val supported_nips: List<Int>? = null,
/** NIP numbers, and the hex-named NIPs as strings: `[1, 11, 42, "FE"]`. */
val supported_nips: List<String>? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val relay_countries: List<String>? = null,
@@ -223,6 +231,59 @@ data class StaticConfig(
val live_index: Boolean = true,
)
/**
* NIP-FE: relay commands over HTTP — one REQ, COUNT or EVENT frame POSTed to the relay's URL,
* answered as NDJSON in the socket's own frames. Each request is its own connection, so the
* concurrency caps here stand in for the websocket's per-connection limits.
*/
data class HttpSection(
val enabled: Boolean = true,
/** Requests running at once across all clients; over it, 503. 0 = no limit. */
val max_concurrent_requests: Int = 256,
/** Requests one client address may run at once; over it, 429. 0 = no limit. */
val max_requests_per_client: Int = 16,
/** How long a request body may take to arrive before the request is dropped with 408. */
val body_timeout_seconds: Long = 10,
/** How long one answer may run before it ends on a `CLOSED` line. */
val deadline_seconds: Long = 30,
/** Largest request body read; larger is 413. */
val max_body_bytes: Int = 512 * 1024,
/** Gzip streamed answers when the client sends `Accept-Encoding: gzip`, flushed line by line. */
val gzip: Boolean = true,
/** `Retry-After` on the relay's own 429 and 503. */
val retry_after_seconds: Int = 1,
/**
* Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name).
* A NIP-98 token's `u` may name any of them, read as http(s), or `[info].relay_url`.
*/
val alternate_urls: List<String> = emptyList(),
/**
* Addresses of the reverse proxies in front of the relay. Only from these peers is
* [client_address_header] believed when counting a client's requests.
*/
val trusted_proxies: List<String> = emptyList(),
val client_address_header: String = "X-Forwarded-For",
) {
/** The transport settings, or null when commands over HTTP are off. */
fun toSettings(): HttpCommandSettings? =
if (!enabled) {
null
} else {
HttpCommandSettings(
maxConcurrent = max_concurrent_requests,
maxPerClient = max_requests_per_client,
bodyTimeout = body_timeout_seconds.seconds,
deadline = deadline_seconds.seconds,
maxBodyBytes = max_body_bytes,
compress = gzip,
retryAfterSeconds = retry_after_seconds,
alternateUrls = alternate_urls.map { it.normalizeRelayUrl() },
trustedProxies = trusted_proxies.toSet(),
clientAddressHeader = client_address_header,
)
}
}
data class AuthorizationSection(
val pubkey_whitelist: List<String> = emptyList(),
val pubkey_blacklist: List<String> = emptyList(),
@@ -297,6 +358,12 @@ data class StaticConfig(
* the store.
*/
fun validate() {
require(http.max_concurrent_requests >= 0) { "[http].max_concurrent_requests must be >= 0 (0 = no limit), got ${http.max_concurrent_requests}" }
require(http.max_requests_per_client >= 0) { "[http].max_requests_per_client must be >= 0 (0 = no limit), got ${http.max_requests_per_client}" }
require(http.body_timeout_seconds > 0) { "[http].body_timeout_seconds must be > 0, got ${http.body_timeout_seconds}" }
require(http.deadline_seconds > 0) { "[http].deadline_seconds must be > 0, got ${http.deadline_seconds}" }
require(http.max_body_bytes > 0) { "[http].max_body_bytes must be > 0, got ${http.max_body_bytes}" }
require(http.retry_after_seconds >= 0) { "[http].retry_after_seconds must be >= 0, got ${http.retry_after_seconds}" }
database.readers?.let {
require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" }
}
@@ -308,7 +375,11 @@ data class StaticConfig(
}
companion object {
private val mapper = tomlMapper { }
private val mapper =
tomlMapper {
// `supported_nips = [1, 11, "FE"]`: numbered NIPs are integers, hex-named ones strings.
decoder { it: TomlValue.Integer -> it.value.toString() }
}
fun fromToml(toml: String): StaticConfig = mapper.decode<StaticConfig>(toml)
@@ -0,0 +1,54 @@
/*
* 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.geode.server
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receiveChannel
import io.ktor.utils.io.readAvailable
/**
* Reads the request body up to [cap] bytes. Returns null when it is larger — by its declared
* `Content-Length` or by what actually arrives — without reading past the cap; the caller answers 413.
* The buffer is sized to the declared length, or grows from a small start, so a 50-byte command does
* not cost a cap-sized allocation.
*/
internal suspend fun readBoundedBody(
call: ApplicationCall,
cap: Int,
): ByteArray? {
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > cap) return null
val ch = call.receiveChannel()
// One byte past the declared length, so a body longer than it claimed is still caught at the cap.
var buf = ByteArray(if (declared != null) declared.toInt() + 1 else minOf(cap + 1, INITIAL_BODY_BUFFER))
var pos = 0
while (pos <= cap) {
if (pos == buf.size) buf = buf.copyOf(minOf(cap + 1, buf.size * 2))
val read = ch.readAvailable(buf, pos, buf.size - pos)
if (read <= 0) break
pos += read
}
if (pos > cap) return null
return if (pos == buf.size) buf else buf.copyOf(pos)
}
private const val INITIAL_BODY_BUFFER = 4 * 1024
@@ -0,0 +1,88 @@
/*
* 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.geode.server
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.writeFully
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.GZIPOutputStream
/**
* A gzip body written line by line onto [out], for NIP-FE's streamed answers. Every [flush] is a
* gzip sync flush: the deflater gives up everything it holds, byte-aligned, so the client can
* inflate every line written so far. Without it the compressor sits on the first lines until its
* window fills, and streaming delivers nothing until the answer is nearly done.
*/
internal class GzipLines(
private val out: ByteWriteChannel,
) {
private val compressed = Pending()
// Level 1: on Nostr events it compresses to ~42% of raw against ~39% at the JDK's default 6, for
// about 2.5x less CPU; ids, keys and signatures are hex and barely compress at any level.
private val gzip =
object : GZIPOutputStream(compressed, BUFFER, true) {
init {
def.setLevel(Deflater.BEST_SPEED)
}
}
/** Compresses [line] and its newline. Moves compressed bytes to the socket once enough piled up, so a long burst still meets backpressure. */
suspend fun line(line: String) {
gzip.write(line.encodeToByteArray())
gzip.write(NEWLINE)
if (compressed.size() >= BUFFER) drain()
}
/** A sync flush, then everything onto the socket. */
suspend fun flush() {
gzip.flush()
drain()
out.flush()
}
/** Writes the gzip trailer; the body is complete after this. */
suspend fun finish() {
gzip.finish()
drain()
out.flush()
}
/** Frees the deflater, finished or not. */
fun close() = gzip.close()
private suspend fun drain() {
if (compressed.size() == 0) return
compressed.writeTo(out)
compressed.reset()
}
/** The compressed bytes not yet on the socket, written from its own array rather than a copy. */
private class Pending : ByteArrayOutputStream(BUFFER) {
suspend fun writeTo(out: ByteWriteChannel) = out.writeFully(buf, 0, count)
}
companion object {
private const val BUFFER = 16 * 1024
private val NEWLINE = byteArrayOf('\n'.code.toByte())
}
}
@@ -0,0 +1,89 @@
/*
* 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.geode.server
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* How many NIP-FE requests run at once, per client and in all. Each HTTP request is its own
* connection, so the per-connection limits the websocket leans on (open subscriptions, one search at
* a time) bound nothing here; this does. A request over its client's share is that client's to
* slow down (429); one over the relay's is everyone's (503).
*/
internal class HttpAdmission(
/** Requests running at once across all clients; 0 is no limit. */
private val maxConcurrent: Int,
/** Requests one client may run at once; 0 is no limit. */
private val maxPerClient: Int,
) {
enum class Verdict { ADMITTED, CLIENT_BUSY, RELAY_BUSY }
private val running = AtomicInteger()
private val perClient = ConcurrentHashMap<String, Int>()
/** Number of requests running now. */
val inFlight: Int get() = running.get()
/** Runs [block] if [client] and the relay have room; otherwise says which of them had none. */
suspend fun admit(
client: String,
block: suspend () -> Unit,
): Verdict {
val verdict = enter(client)
if (verdict != Verdict.ADMITTED) return verdict
try {
block()
} finally {
leave(client)
}
return verdict
}
private fun enter(client: String): Verdict {
var clientFull = false
perClient.compute(client) { _, n ->
val now = n ?: 0
if (maxPerClient in 1..now) {
clientFull = true
n
} else {
now + 1
}
}
if (clientFull) return Verdict.CLIENT_BUSY
if (running.incrementAndGet().let { maxConcurrent in 1 until it }) {
running.decrementAndGet()
release(client)
return Verdict.RELAY_BUSY
}
return Verdict.ADMITTED
}
private fun leave(client: String) {
running.decrementAndGet()
release(client)
}
private fun release(client: String) {
perClient.computeIfPresent(client) { _, n -> if (n <= 1) null else n - 1 }
}
}
@@ -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.geode.server
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: a `POST` to
* the relay's URL carrying one REQ, COUNT or EVENT frame. The engine's policies and limits apply as
* they do on the websocket; these bound what the websocket's per-connection limits cannot, since
* every request is its own connection.
*/
data class HttpCommandSettings(
/** Requests running at once across all clients before the rest get 503; 0 is no limit. */
val maxConcurrent: Int = 256,
/** Requests one client address may run at once before its next gets 429; 0 is no limit. */
val maxPerClient: Int = 16,
/** How long the body may take to arrive; past it, 408. It holds an admission slot meanwhile. */
val bodyTimeout: Duration = 10.seconds,
/** How long one answer may run, first byte to last. */
val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE,
/** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */
val maxBodyBytes: Int = 512 * 1024,
/** Gzip streamed answers for clients that accept it, sync-flushed so lines still arrive as found. */
val compress: Boolean = true,
/** The `Retry-After` sent with a 429 or 503 the relay decides itself. */
val retryAfterSeconds: Int = 1,
/** Other URLs this relay answers at (its .onion); a NIP-98 `u` may name any of them. */
val alternateUrls: List<NormalizedRelayUrl> = emptyList(),
/**
* Peers whose [clientAddressHeader] is believed: the reverse proxies in front of the relay. A
* request from anyone else is counted under its own address, whatever the header says.
*/
val trustedProxies: Set<String> = emptySet(),
/** Where a trusted proxy writes the client's address; its last entry is the one the proxy saw. */
val clientAddressHeader: String = "X-Forwarded-For",
)
@@ -26,9 +26,7 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.header
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respondText
import io.ktor.utils.io.readAvailable
/**
* Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by
@@ -55,7 +53,12 @@ internal class Nip86HttpRoute(
private val handler: Nip86HttpHandler,
) {
suspend fun handle(call: ApplicationCall) {
val body = readBoundedBody(call, handler.maxBodyBytes) ?: return // 413 already sent
val body =
readBoundedBody(call, handler.maxBodyBytes) ?: return call.respondText(
"request body exceeds ${handler.maxBodyBytes}-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
val authHeader = call.request.header(HttpHeaders.Authorization)
when (val r = handler.handle(authHeader, body)) {
@@ -111,43 +114,6 @@ internal class Nip86HttpRoute(
}
}
/**
* Bounded read using [cap]. Returns null after sending a 413 if
* the request body exceeds the cap — either the declared
* `Content-Length` or what we actually pull off the wire.
*/
private suspend fun readBoundedBody(
call: ApplicationCall,
cap: Int,
): ByteArray? {
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > cap) {
call.respondText(
"request body exceeds $cap-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
val ch = call.receiveChannel()
val buf = ByteArray(cap + 1)
var pos = 0
while (pos <= cap) {
val read = ch.readAvailable(buf, pos, buf.size - pos)
if (read <= 0) break
pos += read
}
if (pos > cap) {
call.respondText(
"request body exceeds $cap-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
return buf.copyOfRange(0, pos)
}
/**
* Single-line stderr audit. Operators grep on "nip86" / pubkey /
* method without needing a logging framework. Best-effort — a
@@ -0,0 +1,231 @@
/*
* 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.geode.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayLines
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayRequest
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayResponse
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayStatus
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.header
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytesWriter
import io.ktor.server.response.respondText
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
/**
* NIP-FE over Ktor: the host half of [HttpRelayHandler], on POSTs to the relay's URL that are not
* NIP-86 calls (see [isCommand]). It admits the request, reads the body up to
* the cap, and writes the handler's answer as `application/x-ndjson` — one refusal line with its
* status, or a 200 streamed and flushed frame by frame — with the headers the status calls for
* (`WWW-Authenticate` on 401, `Retry-After` on 429 and 503) and the CORS and no-buffering headers
* every answer carries.
*/
internal class NipFEHttpRoute(
private val handler: HttpRelayHandler,
private val settings: HttpCommandSettings,
) {
private val admission = HttpAdmission(settings.maxConcurrent, settings.maxPerClient)
private val bodyCap: Int = minOf(settings.maxBodyBytes.toLong(), handler.maxBodyBytes ?: Long.MAX_VALUE).toInt()
/** Requests being answered right now. */
val inFlight: Int get() = admission.inFlight
/** A CORS preflight: any origin may POST with a NIP-98 `Authorization`; no cookies are involved. */
suspend fun preflight(call: ApplicationCall) {
call.response.header(HttpHeaders.AccessControlAllowOrigin, "*")
call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS")
call.response.header(HttpHeaders.AccessControlAllowHeaders, "Authorization, Content-Type")
call.response.header(HttpHeaders.AccessControlMaxAge, PREFLIGHT_MAX_AGE_SECONDS.toString())
call.respond(HttpStatusCode.NoContent)
}
/**
* Whether a POST to the relay's URL is a NIP-FE command: anything but NIP-86's
* `application/nostr+json+rpc`, since commands need no `Content-Type` at all.
*/
fun isCommand(call: ApplicationCall): Boolean {
// Compared as text: parsing it would throw on a malformed header, and a command needs none.
val type = call.request.header(HttpHeaders.ContentType) ?: return true
return !type.substringBefore(';').trim().equals(Nip86HttpHandler.CONTENT_TYPE, ignoreCase = true)
}
/** Answers the command in the body. Admission runs first, so a refused request spends no NIP-98 token. */
suspend fun handle(call: ApplicationCall) {
call.response.header(HttpHeaders.AccessControlAllowOrigin, "*")
call.response.header(HttpHeaders.AccessControlExposeHeaders, "${HttpHeaders.WWWAuthenticate}, ${HttpHeaders.RetryAfter}")
call.response.header(HttpHeaders.CacheControl, "no-store")
// nginx and friends buffer responses by default, which holds back the lines streaming delivers.
call.response.header(ACCEL_BUFFERING, "no")
val verdict =
admission.admit(clientOf(call)) {
// Bounded in time too: a body trickled in forever would hold this admission slot forever.
val body =
try {
withTimeout(settings.bodyTimeout) { readBoundedBody(call, bodyCap) }
} catch (_: TimeoutCancellationException) {
call.response.header(HttpHeaders.Connection, "close")
return@admit respondLine(
call,
REQUEST_TIMEOUT,
HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the body did not arrive within ${settings.bodyTimeout}")),
)
} ?: return@admit respondLine(
call,
HttpRelayStatus.PAYLOAD_TOO_LARGE,
HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")),
)
handler.handle(HttpRelayRequest(call.request.header(HttpHeaders.Authorization), body), Answer(call))
}
when (verdict) {
HttpAdmission.Verdict.ADMITTED -> {}
HttpAdmission.Verdict.CLIENT_BUSY -> {
respondLine(
call,
HttpRelayStatus.TOO_MANY_REQUESTS,
HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")),
)
}
HttpAdmission.Verdict.RELAY_BUSY -> {
respondLine(
call,
HttpRelayStatus.UNAVAILABLE,
HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")),
)
}
}
}
/**
* Who the request counts against: the peer's address, or, when the peer is a trusted proxy, the
* last address in its [HttpCommandSettings.clientAddressHeader] — the one that proxy saw. Earlier
* entries are whatever the client claimed and are never believed.
*/
private fun clientOf(call: ApplicationCall): String {
val peer = call.request.local.remoteAddress
if (peer !in settings.trustedProxies) return peer
return call.request
.header(settings.clientAddressHeader)
?.substringAfterLast(',')
?.trim()
?.ifEmpty { null } ?: peer
}
/**
* Whether `Accept-Encoding` takes gzip: listed by name or as `*`, without `q=0`. A one-line
* answer is never compressed; only a streamed one is worth it.
*/
private fun acceptsGzip(call: ApplicationCall): Boolean =
call.request
.header(HttpHeaders.AcceptEncoding)
.orEmpty()
.split(',')
.any { entry ->
val parts = entry.split(';').map { it.trim() }
val coding = parts.first().lowercase()
val q =
parts
.drop(1)
.firstOrNull { it.startsWith("q=") }
?.removePrefix("q=")
?.toDoubleOrNull() ?: 1.0
(coding == "gzip" || coding == "*") && q > 0.0
}
/** A one-line answer: a refusal, or a command answered at once. */
private suspend fun respondLine(
call: ApplicationCall,
status: Int,
frame: String,
) {
when (status) {
HttpRelayStatus.UNAUTHORIZED -> call.response.header(HttpHeaders.WWWAuthenticate, WWW_AUTHENTICATE)
HttpRelayStatus.TOO_MANY_REQUESTS, HttpRelayStatus.UNAVAILABLE -> call.response.header(HttpHeaders.RetryAfter, settings.retryAfterSeconds.toString())
}
call.respondText(frame + "\n", NDJSON, HttpStatusCode.fromValue(status))
}
private inner class Answer(
private val call: ApplicationCall,
) : HttpRelayResponse {
override suspend fun single(
status: Int,
frame: String,
) = respondLine(call, status, frame)
/**
* Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the
* writer. Gzipped when the client takes it and the relay allows it, sync-flushed at every
* flush so the lines still arrive as they are found.
*/
override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) {
val gzip = settings.compress && acceptsGzip(call)
call.response.header(HttpHeaders.Vary, HttpHeaders.AcceptEncoding)
if (gzip) call.response.header(HttpHeaders.ContentEncoding, "gzip")
call.respondBytesWriter(NDJSON, HttpStatusCode.OK) {
val out = this
if (gzip) {
val body = GzipLines(out)
try {
object : HttpRelayLines {
override suspend fun line(frame: String) = body.line(frame)
override suspend fun flush() = body.flush()
}.lines()
body.finish()
} finally {
body.close()
}
} else {
object : HttpRelayLines {
override suspend fun line(frame: String) {
out.writeStringUtf8(frame)
out.writeStringUtf8("\n")
}
override suspend fun flush() = out.flush()
}.lines()
}
}
}
}
companion object {
val NDJSON = ContentType("application", "x-ndjson")
const val REQUEST_TIMEOUT = 408
const val WWW_AUTHENTICATE = "Nostr"
const val ACCEL_BUFFERING = "X-Accel-Buffering"
const val PREFLIGHT_MAX_AGE_SECONDS = 86_400
}
}
@@ -0,0 +1,482 @@
/*
* 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.geode
import com.vitorpamplona.geode.server.HttpCommandSettings
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.RawEvent
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient
import com.vitorpamplona.quartz.nipFERelayOverHttp.OkHttpRelayTransport
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.GzipSource
import okio.buffer
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.TimeUnit
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay],
* covering the answer shape, the status table, the headers each status carries, NIP-98 sign-in
* on an AUTH-gated relay, and sharing the relay URL with NIP-86.
*/
class NipFEHttpTest {
private val http = OkHttpClient.Builder().build()
private val alice = NostrSignerInternal(KeyPair())
private val running = mutableListOf<Pair<KtorRelay, RelayEngine>>()
@AfterTest
fun teardown() {
running.forEach { (server, relay) ->
server.stop(0, 1_000)
relay.close()
}
}
/** A relay whose advertised URL is the one it listens on, so a NIP-98 `u` names a reachable endpoint. */
private fun start(
path: String = "/",
settings: HttpCommandSettings? = HttpCommandSettings(),
policy: ((NormalizedRelayUrl) -> IRelayPolicy)? = null,
store: (IEventStore) -> IEventStore = { it },
): NormalizedRelayUrl {
val port = ServerSocket(0).use { it.localPort }
val url = "ws://127.0.0.1:$port$path".normalizeRelayUrl()
val events = store(EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy))
val relay = RelayEngine(url, events, policyBuilder = { policy?.invoke(url) ?: EmptyPolicy })
val server = KtorRelay(relay, host = "127.0.0.1", port = port, path = path, httpCommands = settings).start()
running += server to relay
return url
}
private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(OkHttpRelayTransport { http }, signer)
private suspend fun note(text: String): Event = alice.sign(TextNoteEvent.build(text))
private fun post(
url: String,
body: String,
authorization: String? = null,
contentType: String = "text/plain",
): Response =
http
.newCall(
Request
.Builder()
.url(url)
.post(body.toRequestBody(contentType.toMediaType()))
.apply { authorization?.let { header("Authorization", it) } }
.build(),
).execute()
private fun Response.lines() = body.string().lines().filter { it.isNotEmpty() }
@Test
fun aReqStreamsWhatWasPublishedAndEndsOnEose() =
runBlocking {
val relay = start()
val notes = listOf(note("one"), note("two"), note("three"))
notes.forEach { assertTrue(assertIs<OkMessage>(client().publish(relay, it).last).success) }
val got = mutableListOf<Event>()
val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), onEvent = got::add)
assertEquals(200, answer.status)
assertTrue(answer.complete)
assertIs<EoseMessage>(answer.last)
assertEquals(notes.map { it.id }.toSet(), got.map { it.id }.toSet())
}
@Test
fun theWireIsNdjsonInTheSocketsOwnFrames() =
runBlocking {
val relay = start()
val n = note("hello")
client().publish(relay, n)
post(relay.toHttp(), """["REQ","q",{"ids":["${n.id}"]}]""").use { response ->
assertEquals(200, response.code)
assertTrue(response.header("Content-Type")!!.startsWith("application/x-ndjson"))
assertEquals("no", response.header("X-Accel-Buffering"))
assertEquals("*", response.header("Access-Control-Allow-Origin"))
val lines = response.lines()
assertEquals(listOf("""["EVENT","q",${n.toJson()}]""", """["EOSE","q"]"""), lines)
}
}
@Test
fun aCountIsOneCountLine() =
runBlocking {
val relay = start()
client().publish(relay, note("a"))
client().publish(relay, note("b"))
val answer = client().count(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))))
assertTrue(answer.complete)
assertEquals(2, assertIs<CountMessage>(answer.last).result.count)
}
@Test
fun aDuplicateIs200AndAForgeryIs400() =
runBlocking {
val relay = start(policy = { VerifyPolicy })
val n = note("once")
assertEquals(200, client().publish(relay, n).status)
assertEquals(200, client().publish(relay, n).status)
val forged = n.toJson().replace("\"once\"", "\"twice\"")
post(relay.toHttp(), """["EVENT",$forged]""").use { response ->
assertEquals(400, response.code)
val line = response.lines().single()
assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line)
}
}
@Test
fun aBodyThatIsNotACommandIs400AndOneOverTheCapIs413() {
val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64))
for (body in listOf("hello", """{"kinds":[1]}""", """["CLOSE","q"]""")) {
post(relay.toHttp(), body).use { response ->
assertEquals(400, response.code, body)
assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:"""))
}
}
post(relay.toHttp(), """["REQ","q",{"authors":["${"a".repeat(64)}"]}]""").use { response ->
assertEquals(413, response.code)
assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:"""))
}
}
@Test
fun aRateLimitedRefusalIs429WithRetryAfter() {
val relay =
start(
settings = HttpCommandSettings(retryAfterSeconds = 7),
policy = {
object : PassThroughPolicy() {
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Rejected("rate-limited: slow down")
}
},
)
post(relay.toHttp(), """["REQ","q",{}]""").use { response ->
assertEquals(429, response.code)
assertEquals("7", response.header("Retry-After"))
assertEquals("""["CLOSED","q","rate-limited: slow down"]""", response.lines().single())
}
}
@Test
fun aPreflightLetsAnyOriginPostWithAuthorization() {
val relay = start()
val preflight =
Request
.Builder()
.url(relay.toHttp())
.method("OPTIONS", null)
.header("Origin", "https://app.example")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "authorization")
.build()
http.newCall(preflight).execute().use { response ->
assertEquals(204, response.code)
assertEquals("*", response.header("Access-Control-Allow-Origin"))
assertTrue(response.header("Access-Control-Allow-Methods")!!.contains("POST"))
assertTrue(response.header("Access-Control-Allow-Headers")!!.contains("Authorization"))
}
}
@Test
fun anAuthGatedRelayAnswers401UntilANip98TokenSignsTheRequestIn() =
runBlocking {
val relay = start(policy = ::SignInPolicy)
post(relay.toHttp(), """["REQ","q",{}]""").use { response ->
assertEquals(401, response.code)
assertEquals("Nostr", response.header("WWW-Authenticate"))
assertTrue(response.lines().single().startsWith("""["CLOSED","q","auth-required:"""))
}
val unsigned = client().req(relay, listOf(Filter(kinds = listOf(1)))) {}
assertEquals(401, unsigned.status)
assertTrue(unsigned.complete)
val n = note("signed in")
val published = client(alice).publish(relay, n)
assertEquals(200, published.status)
assertTrue(assertIs<OkMessage>(published.last).success)
val got = mutableListOf<Event>()
val read = HttpRelayClient(OkHttpRelayTransport { http }, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add)
assertEquals(200, read.status)
assertTrue(read.complete)
assertEquals(listOf(n.id), got.map { it.id })
}
@Test
fun aTokenForAnotherBodyDoesNotSignIn() =
runBlocking {
val relay = start(policy = ::SignInPolicy)
val url = relay.toHttp()
val signed = """["REQ","q",{"kinds":[1]}]"""
val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", signed.encodeToByteArray())).toAuthToken()
post(url, """["REQ","q",{"kinds":[0]}]""", token).use { response ->
assertEquals(401, response.code)
assertTrue(response.lines().single().contains("payload"))
}
post(url, signed, token).use { assertEquals(200, it.code) }
post(url, signed, token).use { assertEquals(200, it.code, "good again for the same body within its window") }
}
@Test
fun aTokenSignedAtTheOnionAddressVerifies() =
runBlocking {
val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl()
val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion)))
val body = """["REQ","q",{"kinds":[1]}]"""
val token = alice.sign(HTTPAuthorizationEvent.build(onion.toHttp(), "POST", body.encodeToByteArray())).toAuthToken()
post(relay.toHttp(), body, token).use { assertEquals(200, it.code) }
}
@Test
fun commandsGoToTheRelayUrlPathIncluded() =
runBlocking {
val relay = start(path = "/nostr")
assertTrue(relay.toHttp().trimEnd('/').endsWith("/nostr"))
assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete)
post(relay.toHttp().replace("/nostr", ""), """["REQ","q",{}]""").use { assertEquals(404, it.code) }
}
@Test
fun nip86CallsKeepTheRelayUrlByTheirContentType() {
val relay = start()
post(relay.toHttp(), """{"method":"supportedmethods","params":[]}""", contentType = "application/nostr+json+rpc").use { response ->
assertEquals(401, response.code, "NIP-86 asks for its own NIP-98 token")
assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson"))
}
}
@Test
fun turnedOffEveryPostIsNip86Again() {
val relay = start(settings = null)
post(relay.toHttp(), """["REQ","q",{}]""").use { response ->
assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson"))
assertEquals(401, response.code)
}
}
/** Answers a filter naming [HELD_KIND] with what is stored, then holds its EOSE until [release]. */
private fun holdingEose(release: CompletableDeferred<Unit>): (IEventStore) -> IEventStore =
{ real ->
object : IEventStore by real {
override suspend fun rawQuery(
filters: List<Filter>,
onEach: (RawEvent) -> Unit,
) {
real.rawQuery(filters, onEach)
if (filters.any { it.kinds?.contains(HELD_KIND) == true }) release.await()
}
}
}
@Test
fun aGzippedAnswerStillArrivesLineByLine() =
runBlocking {
val release = CompletableDeferred<Unit>()
val relay = start(store = holdingEose(release))
val held = alice.sign<Event>(TimeUtils.now(), HELD_KIND, emptyArray(), "held")
client().publish(relay, held)
// Asking for gzip by hand turns off OkHttp's own inflating, so the body is read as sent.
val patient = http.newBuilder().readTimeout(10, TimeUnit.SECONDS).build()
val request =
Request
.Builder()
.url(relay.toHttp())
.post("""["REQ","q",{"kinds":[$HELD_KIND]}]""".toRequestBody("text/plain".toMediaType()))
.header("Accept-Encoding", "gzip")
.build()
patient.newCall(request).execute().use { response ->
assertEquals("gzip", response.header("Content-Encoding"))
val lines = GzipSource(response.body.source()).buffer()
// The store has not answered EOSE: this line can only be here if the gzip was flushed.
assertEquals("""["EVENT","q",${held.toJson()}]""", lines.readUtf8Line())
assertFalse(release.isCompleted)
release.complete(Unit)
assertEquals("""["EOSE","q"]""", lines.readUtf8Line())
assertEquals(null, lines.readUtf8Line())
}
}
@Test
fun theClientReadsAGzippedAnswerAsItStreams() =
runBlocking {
val release = CompletableDeferred<Unit>()
val relay = start(store = holdingEose(release))
val held = alice.sign<Event>(TimeUtils.now(), HELD_KIND, emptyArray(), "held")
client().publish(relay, held)
val first = CompletableDeferred<Event>()
val answer = async(Dispatchers.IO) { client().req(relay, listOf(Filter(kinds = listOf(HELD_KIND))), onEvent = { first.complete(it) }) }
assertEquals(held.id, withTimeout(10.seconds) { first.await() }.id, "the event arrives before EOSE is written")
release.complete(Unit)
assertTrue(answer.await().complete)
}
@Test
fun withoutGzipOrWithItOffTheBodyIsPlain() {
for ((settings, accept) in listOf(HttpCommandSettings() to "identity", HttpCommandSettings(compress = false) to "gzip")) {
val relay = start(settings = settings)
val request =
Request
.Builder()
.url(relay.toHttp())
.post("""["REQ","q",{"kinds":[1]}]""".toRequestBody("text/plain".toMediaType()))
.header("Accept-Encoding", accept)
.build()
http.newCall(request).execute().use { response ->
assertEquals(null, response.header("Content-Encoding"), accept)
assertEquals(listOf("""["EOSE","q"]"""), response.lines())
}
}
}
/** Writes [request] on a plain socket to the relay at [relay] and returns the status line of the answer. */
private fun rawStatus(
relay: NormalizedRelayUrl,
request: String,
): String =
Socket(
"127.0.0.1",
relay
.toHttp()
.substringAfterLast(':')
.trimEnd('/')
.toInt(),
).use { socket ->
socket.soTimeout = 10_000
socket.getOutputStream().write(request.encodeToByteArray())
socket.getOutputStream().flush()
socket.getInputStream().bufferedReader().readLine()
}
private fun chunked(body: String) = "${body.length.toString(16)}\r\n$body\r\n0\r\n\r\n"
@Test
fun aMalformedContentTypeIsStillACommand() {
val relay = start()
val body = """["REQ","q",{"kinds":[1]}]"""
val status = rawStatus(relay, "POST / HTTP/1.1\r\nHost: x\r\nContent-Type: garbage\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body")
assertEquals("HTTP/1.1 200 OK", status)
}
@Test
fun aBodyThatNeverFinishesIsDroppedAndFreesItsSlot() {
val relay = start(settings = HttpCommandSettings(maxPerClient = 1, bodyTimeout = 500.milliseconds))
val port =
relay
.toHttp()
.substringAfterLast(':')
.trimEnd('/')
.toInt()
Socket("127.0.0.1", port).use { slow ->
slow.soTimeout = 10_000
slow.getOutputStream().write("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n[\"REQ\"".encodeToByteArray())
slow.getOutputStream().flush()
assertEquals("HTTP/1.1 408 Request Timeout", slow.getInputStream().bufferedReader().readLine())
}
val body = """["REQ","q",{"kinds":[1]}]"""
assertEquals("HTTP/1.1 200 OK", rawStatus(relay, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body"))
}
@Test
fun aChunkedBodyGrowsItsBufferAndStopsAtTheCap() {
val relay = start(settings = HttpCommandSettings(maxBodyBytes = 20_000))
// No Content-Length: the buffer starts small and grows past it.
val big = """["REQ","q",{"search":"${"x".repeat(10_000)}"}]"""
val head = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
assertEquals("HTTP/1.1 200 OK", rawStatus(relay, head + chunked(big)))
val over = """["REQ","q",{"search":"${"x".repeat(30_000)}"}]"""
assertEquals("HTTP/1.1 413 Payload Too Large", rawStatus(relay, head + chunked(over)))
}
@Test
fun nip11AdvertisesFE() {
val relay = start()
val request =
Request
.Builder()
.url(relay.url.replace("ws://", "http://"))
.header("Accept", "application/nostr+json")
.build()
http.newCall(request).execute().use { response ->
assertTrue(response.body.string().contains("\"FE\""))
}
}
@Test
fun noFirstFrameWithinTheDeadlineIs503WithRetryAfter() =
runBlocking {
val relay = start(settings = HttpCommandSettings(deadline = Duration.ZERO, retryAfterSeconds = 3))
val answer = client().req(relay, listOf(Filter(kinds = listOf(1)))) {}
assertEquals(503, answer.status)
assertEquals("3", answer.retryAfter)
assertTrue(answer.complete)
assertFalse(answer.last is EoseMessage)
}
private companion object {
/** A regular kind (stored), not a text note, so no other test reads it. */
const val HELD_KIND = 7_777
}
}
@@ -28,6 +28,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class StaticConfigTest {
@Test
@@ -219,7 +220,7 @@ class StaticConfigTest {
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals("Example", c.info.name)
assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips)
assertEquals(listOf("1", "9", "11", "42"), c.info.supported_nips)
assertEquals("127.0.0.1", c.network.host)
assertEquals(9988, c.network.port)
@@ -249,6 +250,56 @@ class StaticConfigTest {
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
}
@Test
fun supportedNipsTakeHexNamedNipsAsStrings() {
val c =
StaticConfig.fromToml(
"""
[info]
supported_nips = [1, 11, "FE"]
""".trimIndent(),
)
assertEquals(listOf("1", "11", "FE"), c.resolveInfo().document.supported_nips)
}
@Test
fun httpCommandsAreOnByDefaultAndAdvertised() {
val c = StaticConfig.fromToml("")
assertTrue(c.http.enabled)
assertNotNull(c.http.toSettings())
assertTrue("FE" in c.resolveInfo().document.supported_nips!!)
}
@Test
fun httpSectionParsesAndTurningItOffDropsFE() {
val c =
StaticConfig.fromToml(
"""
[http]
enabled = false
max_concurrent_requests = 10
max_requests_per_client = 2
deadline_seconds = 5
alternate_urls = ["ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/"]
trusted_proxies = ["127.0.0.1"]
""".trimIndent(),
)
assertEquals(10, c.http.max_concurrent_requests)
assertEquals(2, c.http.max_requests_per_client)
assertEquals(listOf("127.0.0.1"), c.http.trusted_proxies)
assertEquals(null, c.http.toSettings())
assertTrue("FE" !in c.resolveInfo().document.supported_nips!!)
val on = c.copy(http = c.http.copy(enabled = true)).http.toSettings()!!
assertEquals(5.seconds, on.deadline)
assertEquals(1, on.alternateUrls.size)
}
@Test
fun httpLimitsMustBeSane() {
assertFailsWith<IllegalArgumentException> { StaticConfig.fromToml("[http]\ndeadline_seconds = 0").validate() }
assertFailsWith<IllegalArgumentException> { StaticConfig.fromToml("[http]\nmax_requests_per_client = -1").validate() }
}
@Test
fun loadsTheBundledExampleConfigCleanly() {
// The example file lives at the module root so operators have a
@@ -0,0 +1,71 @@
/*
* 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.geode.server
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import kotlin.test.Test
import kotlin.test.assertEquals
class HttpAdmissionTest {
@Test
fun aClientOverItsShareIsBusyAndARelayOverItsCapIsTooWhileOthersStillRun() =
runBlocking {
val admission = HttpAdmission(maxConcurrent = 2, maxPerClient = 1)
val release = CompletableDeferred<Unit>()
val a = async { admission.admit("a") { release.await() } }
val b = async { admission.admit("b") { release.await() } }
while (admission.inFlight < 2) yield()
assertEquals(HttpAdmission.Verdict.CLIENT_BUSY, admission.admit("a") {})
assertEquals(HttpAdmission.Verdict.RELAY_BUSY, admission.admit("c") {})
release.complete(Unit)
assertEquals(HttpAdmission.Verdict.ADMITTED, a.await())
assertEquals(HttpAdmission.Verdict.ADMITTED, b.await())
assertEquals(0, admission.inFlight)
assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {})
assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("c") {})
}
@Test
fun zeroIsNoLimit() =
runBlocking {
val admission = HttpAdmission(maxConcurrent = 0, maxPerClient = 0)
val release = CompletableDeferred<Unit>()
val held = List(50) { async { admission.admit("a") { release.await() } } }
while (admission.inFlight < 50) yield()
assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {})
release.complete(Unit)
held.forEach { assertEquals(HttpAdmission.Verdict.ADMITTED, it.await()) }
}
@Test
fun aFailingRequestStillLeaves() =
runBlocking {
val admission = HttpAdmission(maxConcurrent = 1, maxPerClient = 1)
runCatching { admission.admit("a") { error("boom") } }
assertEquals(0, admission.inFlight)
assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {})
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ activityCompose = "1.13.0"
# 9.4.0's PerModuleBundleTask rejects an AAB entry whose name contains a colon, which every
# .kotlin_module named after a Gradle project path has. Worked around in amethyst/build.gradle.kts
# (stripColonNamedEntries) rather than by pinning to 9.3.1; drop that block if AGP fixes it.
agp = "9.4.0"
agp = "9.4.1"
android-compileSdk = "37"
android-minSdk = "26"
android-targetSdk = "37"
@@ -189,10 +189,25 @@ class RelaySession(
}
/**
* Dispatches an already-parsed command, for a transport that parsed and
* sized it itself (NIP-FE's HTTP bodies). [IRelayPolicy.acceptMessage]
* is not run here — it judges wire text — so such a caller applies the
* message-length limit before calling.
* [receive] for a transport that already parsed [parsed] out of [command]
* (NIP-FE's HTTP bodies): [IRelayPolicy.acceptMessage] still judges the
* wire text, as it would on the socket, but the frame is not parsed twice.
*/
suspend fun receive(
command: String,
parsed: Command,
) {
policy.acceptMessage(command)?.let { reason ->
send(NoticeMessage(reason))
return
}
receive(parsed)
}
/**
* Dispatches an already-parsed command. [IRelayPolicy.acceptMessage] is
* not run here — it judges wire text — so a caller that has the text
* uses the overload that takes both.
*/
suspend fun receive(cmd: Command) {
if (!cmd.isValid()) {
@@ -0,0 +1,84 @@
/*
* 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.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
/**
* A NIP-FE answer as the client got it. [complete] is false when the body stopped before the frame
* that ends the command's answer: what came is a prefix, and a REQ's prefix looks exactly like a
* short result, so a caller MUST NOT read an incomplete answer as the whole one.
*/
class HttpRelayAnswer(
val status: Int,
/** The frame the answer ended on (EOSE, CLOSED, COUNT, OK, NOTICE), or the last one read when cut off. */
val last: Message?,
/** Whether the answer reached its end, rather than being cut off. */
val complete: Boolean,
/** The `Retry-After` the relay sent with a 429 or 503. */
val retryAfter: String? = null,
)
/**
* NIP-FE, client side: reads an answer one line at a time as it arrives and keeps track of whether
* it ended where [command]'s answer ends. A 200 streams up to that frame; any other status is one
* refusal line, which is the whole answer whatever frame it is.
*/
class HttpRelayAnswerReader(
val command: HttpRelayCommand,
val status: Int,
) {
var last: Message? = null
private set
private var lines = 0
private var ended = false
private var broken = false
/**
* The frame on [line], or null for a blank line. A line that does not parse, or anything after
* the answer ended, marks the answer incomplete: the body is not what this NIP says it is.
*/
fun read(line: String): Message? {
if (line.isBlank()) return null
if (ended || broken) {
broken = true
return null
}
val message =
try {
OptimizedJsonMapper.fromJsonToMessage(line)
} catch (_: Exception) {
broken = true
return null
}
lines++
last = message
ended = status != HttpRelayStatus.OK || command.ends(message)
return message
}
/** Whether the body read so far is a whole answer. Asked once the body ends. */
val complete: Boolean get() = ended && !broken && (status == HttpRelayStatus.OK || lines == 1)
fun answer(retryAfter: String? = null) = HttpRelayAnswer(status, last, complete, retryAfter)
}
@@ -0,0 +1,127 @@
/*
* 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.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
/**
* NIP-FE client: one relay command per request, POSTed to the relay's URL as the frame the
* websocket would carry, its answer read line by line as the relay writes it, in the socket's own
* frames. Nothing stays open after a call returns. [transport] carries the bytes (OkHttp on
* JVM/Android: `OkHttpRelayTransport`), as a websocket builder does for the NostrClient.
*
* With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for
* its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the
* answer. [signFirst] sends the token with the first try instead, saving the round trip against a
* relay known to want it.
*/
class HttpRelayClient(
private val transport: HttpRelayTransport,
private val signer: NostrSigner? = null,
private val signFirst: Boolean = false,
) {
/** Stored events matching [filters], each to [onEvent] as it arrives. Complete only if it ended on EOSE or CLOSED. */
suspend fun req(
relay: NormalizedRelayUrl,
filters: List<Filter>,
subId: String = newSubId(),
onEvent: (Event) -> Unit,
): HttpRelayAnswer =
send(relay, ReqCmd(subId, filters)) {
if (it is EventMessage) onEvent(it.event)
}
/** NIP-45: [HttpRelayAnswer.last] is the COUNT, or the refusal. */
suspend fun count(
relay: NormalizedRelayUrl,
filters: List<Filter>,
queryId: String = newSubId(),
): HttpRelayAnswer = send(relay, CountCmd(queryId, filters))
/** Publishes [event]: [HttpRelayAnswer.last] is its OK, or the refusal. */
suspend fun publish(
relay: NormalizedRelayUrl,
event: Event,
): HttpRelayAnswer = send(relay, EventCmd(event))
/** Posts [cmd] (a REQ, COUNT or EVENT) to [relay], handing every frame to [onMessage] as it is read. */
suspend fun send(
relay: NormalizedRelayUrl,
cmd: Command,
onMessage: (Message) -> Unit = {},
): HttpRelayAnswer {
val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" }
val url = relay.toHttp()
val body = cmd.toJson().encodeToByteArray()
if (signer == null || signFirst) return post(relay, command, url, body, token(url, body), onMessage, retrying = false)
val first = post(relay, command, url, body, null, onMessage, retrying = true)
if (first.status != HttpRelayStatus.UNAUTHORIZED) return first
return post(relay, command, url, body, token(url, body), onMessage, retrying = false)
}
private suspend fun token(
url: String,
body: ByteArray,
): String? = signer?.sign(HTTPAuthorizationEvent.build(url, "POST", body))?.toAuthToken()
private suspend fun post(
relay: NormalizedRelayUrl,
command: HttpRelayCommand,
url: String,
body: ByteArray,
authorization: String?,
onMessage: (Message) -> Unit,
/** A signed try follows a 401, so that refusal is not the answer and is not handed on. */
retrying: Boolean,
): HttpRelayAnswer {
var reader: HttpRelayAnswerReader? = null
var retryAfter: String? = null
var deliver = true
transport.post(
relay = relay,
url = url,
body = body,
authorization = authorization,
onStatus = { status, after ->
reader = HttpRelayAnswerReader(command, status)
retryAfter = after
deliver = !(retrying && status == HttpRelayStatus.UNAUTHORIZED)
},
onLine = { line ->
val message = checkNotNull(reader) { "a line before the status" }.read(line)
if (message != null && deliver) onMessage(message)
},
)
return checkNotNull(reader) { "the transport returned without a status" }.answer(retryAfter)
}
}
@@ -26,50 +26,22 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
/**
* NIP-FE: the client commands HTTP carries, one path each. A body is the command's arguments
* after its subscription id (a lone object where the command takes one); the answer ends on the
* first frame [ends] accepts.
* NIP-FE: the client commands an HTTP request may carry, each as the frame a client sends on the
* websocket, and the frame that ends each one's answer.
*/
enum class HttpRelayCommand(
val path: String,
) {
REQ("/req"),
COUNT("/count"),
EVENT("/event"),
enum class HttpRelayCommand {
REQ,
COUNT,
EVENT,
;
/**
* The client frame [body] stands for, or null when it plainly is not this command's arguments.
* The body is spliced in as sent and the engine parses the frame, as it parses socket text, so
* any other malformed body is the engine's NOTICE. The verb and subscription id come first and
* the parser reads one value, so nothing a body holds can make it another command.
*/
fun frameOf(body: String): String? {
val text = body.trim()
return when (this) {
REQ, COUNT -> {
val filters =
when {
text.startsWith('{') -> text
text.startsWith('[') && text.endsWith(']') -> text.substring(1, text.length - 1).trim().ifEmpty { return null }
else -> return null
}
"[\"${if (this == REQ) ReqCmd.LABEL else CountCmd.LABEL}\",\"$SUB_ID\",$filters]"
}
EVENT -> {
if (!text.startsWith('{')) return null
"[\"${EventCmd.LABEL}\",$text]"
}
}
}
/** Whether [message] is the last frame of this command's answer. */
/** Whether [message] is the last frame of this command's answer. A NOTICE ends any: the command never ran. */
fun ends(message: Message): Boolean =
message is NoticeMessage ||
when (this) {
@@ -79,12 +51,25 @@ enum class HttpRelayCommand(
}
companion object {
/**
* The subscription id every HTTP command runs under inside the engine. NIP-FE answers carry
* none, so [HttpRelayHandler] takes it back out of each frame before it goes out.
*/
const val SUB_ID = "http"
/** The kind of [cmd], or null for one HTTP does not carry (AUTH, CLOSE, NEG-*). */
fun of(cmd: Command): HttpRelayCommand? =
when (cmd) {
is ReqCmd -> REQ
is CountCmd -> COUNT
is EventCmd -> EVENT
else -> null
}
fun forPath(path: String): HttpRelayCommand? = entries.firstOrNull { it.path == path }
/** The frame that refuses [cmd] with [reason], as the socket would: CLOSED for a REQ or COUNT, OK false for an EVENT. */
fun refusal(
cmd: Command,
reason: String,
): Message =
when (cmd) {
is EventCmd -> OkMessage(cmd.event.id, false, reason)
is ReqCmd -> ClosedMessage(cmd.subId, reason)
is CountCmd -> ClosedMessage(cmd.queryId, reason)
else -> NoticeMessage(reason)
}
}
}
@@ -21,13 +21,16 @@
package com.vitorpamplona.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.server.RelayServerBase
import com.vitorpamplona.quartz.nip01Core.relay.server.SessionSink
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import com.vitorpamplona.quartz.nip98HttpAuth.tags.UrlTag
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
@@ -36,18 +39,19 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
/** One NIP-FE request as the handler needs it. The host routes by [HttpRelayCommand.path]. */
/** One NIP-FE request as the handler needs it: a POST to the relay's URL that is not a NIP-86 call. */
class HttpRelayRequest(
val command: HttpRelayCommand,
/** The `Authorization` header as sent, or null. */
val authorization: String?,
/**
* The body. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the engine measures a
* frame in characters, and a UTF-8 character takes up to three bytes.
* The body: one client frame. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the
* engine measures a frame in characters, and a UTF-8 character takes up to three bytes.
*/
val body: ByteArray,
)
@@ -82,16 +86,17 @@ interface HttpRelayLines {
class HttpRelayReaderStalled : Exception("the client stopped reading the answer")
/**
* NIP-FE: one relay command per HTTP request, run on its own [RelayServerBase] session, so every
* limit and policy the socket applies applies here, and answered with the relay's own frames up to
* the command's answer, without their subscription id. Nothing outlives the request.
* NIP-FE: one relay command per HTTP request. The body is the frame a client would send on the
* websocket; it runs on its own [RelayServerBase] session, fed as socket text, so every limit and
* policy the socket applies applies here; and the answer is the session's frames as the socket
* would carry them, up to the one that ends the command's answer. Nothing outlives the request.
*
* Admission (how many requests a client may run) is the host's: gate before calling [handle], so a
* refused request does not spend a NIP-98 token the handler would have verified.
*/
class HttpRelayHandler(
private val server: RelayServerBase,
/** The prefixes a NIP-98 `u` may carry (the relay's http origin, its .onion), asked per request; never from the request. */
/** The URLs a NIP-98 `u` may name (the relay's http URL, its .onion), asked per request; never from the request. */
private val origins: () -> List<String>,
/** How long one answer may run, first byte to last. [Duration.INFINITE] turns the deadline off. */
private val deadline: Duration = DEFAULT_DEADLINE,
@@ -107,36 +112,35 @@ class HttpRelayHandler(
request: HttpRelayRequest,
response: HttpRelayResponse,
) {
val command = request.command
val max = server.limits?.maxMessageLength
val tooLarge = "invalid: the command exceeds $max characters"
maxBodyBytes?.let { cap ->
if (request.body.size > cap) {
return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters"))
}
if (request.body.size > cap) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge))
}
val frame =
command.frameOf(request.body.decodeToString())
?: return response.single(HttpRelayStatus.BAD_REQUEST, closed("invalid: the body is not ${command.name}'s arguments"))
val text = request.body.decodeToString()
// Characters, as the engine's own limit counts them.
if (max != null && frame.length > max) {
return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters"))
}
if (max != null && text.length > max) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge))
// Parsed here, once: to refuse the commands HTTP does not carry, to know what ends the
// answer and how to refuse it, and for the session, which still runs the policies that
// judge the raw text before it dispatches the parsed command.
val cmd =
try {
OptimizedJsonMapper.fromJsonToCommand(text)
} catch (_: Exception) {
null
}
val command =
cmd?.let { HttpRelayCommand.of(it) }
?: return response.single(HttpRelayStatus.BAD_REQUEST, notice("invalid: the body is not one REQ, COUNT or EVENT frame"))
val signedIn =
when (val proof = proofOf(request)) {
is Proof.Anonymous -> {
null
}
is Proof.Signed -> {
proof.pubkey
}
is Proof.Refused -> {
val reason = proof.reason
return response.single(HttpRelayStatus.forReason(reason), closed(reason))
}
is Proof.Anonymous -> null
is Proof.Signed -> proof.pubkey
is Proof.Refused -> return response.single(HttpRelayStatus.forReason(proof.reason), HttpRelayCommand.refusal(cmd, proof.reason).toJson())
}
exchange(frame, command, signedIn, response)
exchange(text, cmd, command, signedIn, response)
}
/** A frame as queued: its wire text, its type when the engine built one, and whether it ends the answer. */
@@ -147,7 +151,8 @@ class HttpRelayHandler(
)
private suspend fun exchange(
frame: String,
text: String,
cmd: Command,
command: HttpRelayCommand,
signedIn: HexKey?,
response: HttpRelayResponse,
@@ -165,23 +170,25 @@ class HttpRelayHandler(
}
}
fun fail(reason: String) = offer(Frame(closed(reason), ClosedMessage(HttpRelayCommand.SUB_ID, reason), last = true))
fun refusal(reason: String) = HttpRelayCommand.refusal(cmd, reason)
fun fail(reason: String) = refusal(reason).let { offer(Frame(it.toJson(), it, last = true)) }
val sink =
object : SessionSink {
override fun message(message: Message) {
// The challenge every connection opens with; this one proves its key by NIP-98 instead.
if (message is AuthMessage) return
offer(Frame(withoutSubId(message.toJson()), message, command.ends(message)))
offer(Frame(message.toJson(), message, command.ends(message)))
}
override fun raw(json: String) = offer(Frame(withoutSubId(json), null, last = false))
override fun raw(json: String) = offer(Frame(json, null, last = false))
}
val session =
launch {
try {
server.serve(sink) { session ->
val refused = signedIn?.let { session.authenticateByTransport(it) }
if (refused != null) fail(refused) else session.receive(frame)
if (refused != null) fail(refused) else session.receive(text, cmd)
ended.await()
}
} catch (e: CancellationException) {
@@ -202,7 +209,7 @@ class HttpRelayHandler(
val status = HttpRelayStatus.of(first?.message)
when {
first == null -> {
single(HttpRelayStatus.UNAVAILABLE, closed("error: no answer within $deadline"))
single(HttpRelayStatus.UNAVAILABLE, notice("error: no answer within $deadline"))
}
first.last || status != HttpRelayStatus.OK -> {
@@ -214,8 +221,8 @@ class HttpRelayHandler(
bounded(due) {
when (drain(first, frames, due)) {
Ending.ANSWERED -> {}
Ending.DEADLINE -> line(closed("error: the answer ran past $deadline"))
Ending.CUT -> line(closed("error: slow reader, over $maxQueuedFrames frames waiting"))
Ending.DEADLINE -> line(refusal("error: the answer ran past $deadline").toJson())
Ending.CUT -> line(refusal("error: slow reader, over $maxQueuedFrames frames waiting").toJson())
}
flush()
}
@@ -284,56 +291,55 @@ class HttpRelayHandler(
}
/**
* A NIP-98 header, checked against every address in [origins], so a token signed at the .onion
* verifies there. It must bind the body's hash: it authorizes one command.
* Another scheme (a proxy's Basic, a client's Bearer) is not addressed to the relay and is ignored.
* A NIP-98 header. Its `u` may name any address in [origins], with or without the trailing
* slash, so a token signed at the .onion verifies there; the token is checked once, against the
* address it names. It must bind the body's hash and be within 60 seconds of now; within that
* window it may come again for the same body. Another scheme (a proxy's Basic, a client's
* Bearer) is not addressed to the relay and is ignored.
*/
private suspend fun proofOf(request: HttpRelayRequest): Proof {
val header = request.authorization?.trim().orEmpty()
val scheme = Nip98AuthVerifier.SCHEME
if (!header.regionMatches(0, scheme, 0, scheme.length, ignoreCase = true)) return Proof.Anonymous
val token = scheme + header.substring(scheme.length).trim()
val accepted = origins().map { it.trimEnd('/') + request.command.path }
if (accepted.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign"))
val addresses = origins()
if (addresses.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign"))
// The address the token names, when it is one of ours; otherwise the first, and the
// verifier refuses the mismatch (or whatever else is wrong with the token) itself.
val signed = claimedUrl(token)
val url = signed?.takeIf { u -> addresses.any { it.trimEnd('/') == u.trimEnd('/') } } ?: addresses.first()
// A fresh verifier each time: it remembers the tokens it accepts, and a NIP-FE token is not
// single-use. A request can land on any instance, which no one process's memory can follow,
// and the body's hash already limits a captured token to the command it signs, in its window.
var refusal: Nip98AuthVerifier.Result.Malformed? = null
for (url in accepted) {
when (val r = Nip98AuthVerifier().verify(token, "POST", url, request.body)) {
is Nip98AuthVerifier.Result.Verified -> return Proof.Signed(r.pubkey)
is Nip98AuthVerifier.Result.Missing -> return Proof.Anonymous
is Nip98AuthVerifier.Result.Malformed -> refusal = refusal ?: r
}
// single-use. Every command it can sign is idempotent, so a repeat only repeats a read or
// re-sends an event the relay has, and a client may retry without signing again.
return when (val r = Nip98AuthVerifier(toleranceSeconds = TOKEN_WINDOW_SECONDS).verify(token, "POST", url, request.body)) {
is Nip98AuthVerifier.Result.Verified -> Proof.Signed(r.pubkey)
is Nip98AuthVerifier.Result.Missing -> Proof.Anonymous
is Nip98AuthVerifier.Result.Malformed -> Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${r.reason}"))
}
return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${refusal?.reason}"))
}
private fun closed(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson())
/** The `u` a NIP-98 [token] names, or null when it does not decode; the verifier then says why. */
@OptIn(ExperimentalEncodingApi::class)
private fun claimedUrl(token: String): String? =
try {
val event = OptimizedJsonMapper.fromJson(Base64.decode(token.substring(Nip98AuthVerifier.SCHEME.length)).decodeToString())
event.tags.firstNotNullOfOrNull(UrlTag::parse)
} catch (_: Exception) {
null
}
companion object {
/** A `NOTICE` line: how a request is refused before its command runs (400, 413, 429, 503), by the handler or its host. */
fun notice(reason: String) = NoticeMessage(reason).toJson()
val DEFAULT_DEADLINE = 30_000.milliseconds
/** The websocket's slow-consumer bound in the reference relays. */
const val DEFAULT_MAX_QUEUED_FRAMES = 8192
val DEFAULT_TAIL_GRACE = 5_000.milliseconds
/** NIP-FE: a token is good for 60 seconds either side of its `created_at`. */
const val TOKEN_WINDOW_SECONDS = 60L
}
}
/** The frames that carry a subscription id in the engine; NIP-FE sends them without it. */
private val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT")
private const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\""
/**
* [frame] as NIP-FE sends it: the engine's frame with its `"http"` subscription id taken out,
* `["EVENT","http",{…}]` → `["EVENT",{…}]`, `["EOSE","http"]` → `["EOSE"]`. Other frames pass as they are.
*/
internal fun withoutSubId(frame: String): String {
if (!frame.startsWith("[\"")) return frame
val verbEnd = frame.indexOf('"', 2)
if (verbEnd < 0 || frame.substring(2, verbEnd) !in SUBSCRIPTION_FRAMES) return frame
if (!frame.startsWith(SUB_ID_FIELD, verbEnd + 1)) return frame
return frame.substring(0, verbEnd + 1) + frame.substring(verbEnd + 1 + SUB_ID_FIELD.length)
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* How [HttpRelayClient] reaches a relay over HTTP: one POST, its response read line by line. The
* NIP-FE side of the exchange (which URL, what body, signing, reading the answer) stays in the
* client; an implementation only moves bytes, the way a
* [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder] does for [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient].
*/
interface HttpRelayTransport {
/**
* POSTs [body] to [url], [relay]'s HTTP URL, with an `Authorization` header when [authorization]
* is not null. Calls [onStatus] once with the status and any `Retry-After`, then [onLine] for each
* body line as it arrives, and returns when the body ends. A connection that drops mid-body
* returns normally: the answer reader tells a cut-off answer from a whole one. Cancelling the
* caller cancels the request.
*/
suspend fun post(
relay: NormalizedRelayUrl,
url: String,
body: ByteArray,
authorization: String?,
onStatus: (status: Int, retryAfter: String?) -> Unit,
onLine: (String) -> Unit,
)
}
@@ -0,0 +1,101 @@
/*
* 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.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** NIP-FE, client side: an answer's lines back into frames, and a whole answer told from a cut one. */
class HttpRelayAnswerReaderTest {
private val event =
"""{"id":"a8e0b2f2c1e7e4f5b0a1f9c1b3d2e6a7c8b9d0e1f2a3b4c5d6e7f8091a2b3c4d","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",""" +
""""created_at":1700000000,"kind":1,"tags":[],"content":"hi","sig":"${"0".repeat(128)}"}"""
private fun read(
command: HttpRelayCommand,
status: Int,
vararg lines: String,
) = HttpRelayAnswerReader(command, status).also { reader -> lines.forEach { reader.read(it) } }
@Test
fun aReqThatEndsOnEoseIsComplete() {
val reader = read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q"]""")
assertTrue(reader.complete)
assertIs<EoseMessage>(reader.last)
}
@Test
fun aReqWithItsTailMissingIsIncomplete() {
val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200)
val message = reader.read("""["EVENT","q",$event]""")
assertIs<EventMessage>(message)
assertEquals("hi", message.event.content)
assertEquals("q", message.subId)
assertFalse(reader.complete)
assertFalse(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).complete, "an empty body is no answer")
}
@Test
fun aClosedEndsAReqAndACountButNotAnEvent() {
assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["CLOSED","q","error: the answer ran past 30s"]""").complete)
assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","q","error: x"]""").complete)
assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","q","error: x"]""").complete)
}
@Test
fun aCountAndAnOkAreWholeAnswers() {
val count = read(HttpRelayCommand.COUNT, 200, """["COUNT","c",{"count":7}]""")
assertTrue(count.complete)
assertEquals(7, assertIs<CountMessage>(count.last).result.count)
val ok = read(HttpRelayCommand.EVENT, 200, """["OK","abc",true,""]""")
assertTrue(ok.complete)
assertTrue(assertIs<OkMessage>(ok.last).success)
}
@Test
fun aRefusalIsOneLineWhateverItsFrame() {
val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","q","auth-required: sign in"]""")
assertTrue(refused.complete)
assertEquals("auth-required: sign in", assertIs<ClosedMessage>(refused.last).message)
assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","q","blocked: no"]""", """["EOSE","q"]""").complete)
}
@Test
fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() {
assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", """["EVENT","q",$event]""").complete)
assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q""").complete)
assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", "", " ").complete, "blank lines are not frames")
}
@Test
fun blankLinesAreSkipped() {
assertNull(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).read(""))
}
}
@@ -0,0 +1,92 @@
/*
* 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.quartz.nipFERelayOverHttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import okio.IOException
/**
* [HttpRelayTransport] over OkHttp. [httpClient] picks the client per relay, as
* [com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket.Builder] does for
* websockets, so a .onion relay can go through Tor and a clearnet one directly. OkHttp asks for
* gzip and inflates it as the body streams, so a relay's sync-flushed gzip arrives line by line.
*/
class OkHttpRelayTransport(
private val httpClient: (NormalizedRelayUrl) -> OkHttpClient,
) : HttpRelayTransport {
override suspend fun post(
relay: NormalizedRelayUrl,
url: String,
body: ByteArray,
authorization: String?,
onStatus: (status: Int, retryAfter: String?) -> Unit,
onLine: (String) -> Unit,
) = coroutineScope {
val request =
Request
.Builder()
.url(url)
.post(body.toRequestBody(JSON))
.header("Accept", NDJSON)
.apply { authorization?.let { header("Authorization", it) } }
.build()
val call = httpClient(relay).newCall(request)
// A blocking read does not see coroutine cancellation; cancelling the call unblocks it.
val watcher =
launch {
try {
awaitCancellation()
} finally {
call.cancel()
}
}
try {
call.executeAsync().use { response ->
onStatus(response.code, response.header("Retry-After"))
withContext(Dispatchers.IO) {
val source = response.body.source()
try {
while (true) onLine(source.readUtf8Line() ?: break)
} catch (_: IOException) {
// The connection dropped mid-answer: what came is what the reader says it is.
}
}
}
} finally {
watcher.cancel()
}
}
companion object {
const val NDJSON = "application/x-ndjson"
private val JSON = "application/json".toMediaType()
}
}
@@ -160,10 +160,15 @@ class HttpRelayHandlerTest {
) = HttpRelayHandler(MemoryRelay(backend, signedInOnly), origins = { listOf(origin) }, deadline = deadline)
private fun HttpRelayHandler.ask(
command: HttpRelayCommand,
body: String,
frame: String,
authorization: String? = null,
) = Recorded().also { runBlocking { handle(HttpRelayRequest(command, authorization, body.encodeToByteArray()), it) } }
) = Recorded().also { runBlocking { handle(HttpRelayRequest(authorization, frame.encodeToByteArray()), it) } }
private fun req(filter: String) = """["REQ","q",$filter]"""
private fun count(filter: String) = """["COUNT","c",$filter]"""
private fun publish(event: Event) = """["EVENT",${event.toJson()}]"""
private fun note(
content: String,
@@ -171,19 +176,19 @@ class HttpRelayHandlerTest {
) = alice.sign<Event>(at, 1, emptyArray(), content)
private fun token(
command: HttpRelayCommand,
body: String,
) = alice.sign(HTTPAuthorizationEvent.build(origin + command.path, "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken()
frame: String,
url: String = origin,
) = alice.sign(HTTPAuthorizationEvent.build(url, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken()
@Test
fun aReqStreamsItsEventsAndEndsOnEose() {
val a = note("a")
val b = note("b")
backend.events += listOf(a, b)
val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""")
val answer = handler().ask(req("""{"kinds":[1]}"""))
assertEquals(200, answer.status)
assertTrue(answer.streamed)
assertEquals("""["EOSE"]""", answer.lines.last())
assertEquals("""["EOSE","q"]""", answer.lines.last())
assertEquals(
setOf(a.id, b.id),
answer.lines
@@ -193,23 +198,31 @@ class HttpRelayHandlerTest {
)
}
@Test
fun framesGoOutAsTheSocketSendsThemWithTheClientsSubscriptionId() {
val found = note("found")
backend.events += found
val answer = handler().ask("""["REQ","mine",{"kinds":[1]}]""")
assertEquals(listOf("""["EVENT","mine",${found.toJson()}]""", """["EOSE","mine"]"""), answer.lines)
}
@Test
fun anEmptyReqIsOneEoseLine() {
val answer = handler().ask(HttpRelayCommand.REQ, """[{"kinds":[30000]}]""")
val answer = handler().ask(req("""{"kinds":[30000]}"""))
assertEquals(200, answer.status)
assertEquals(listOf("""["EOSE"]"""), answer.lines)
assertEquals(listOf("""["EOSE","q"]"""), answer.lines)
}
@Test
fun anEventIsAnsweredByItsOkAndAForgeryIsRefused() {
val posted = note("posted")
val ok = handler().ask(HttpRelayCommand.EVENT, posted.toJson())
val ok = handler().ask(publish(posted))
assertEquals(200, ok.status)
assertEquals(listOf("""["OK","${posted.id}",true,""]"""), ok.lines)
assertTrue(backend.events.any { it.id == posted.id })
val forged = Event(posted.id, posted.pubKey, posted.createdAt, posted.kind, posted.tags, "tampered", posted.sig)
val refused = handler().ask(HttpRelayCommand.EVENT, forged.toJson())
val refused = handler().ask(publish(forged))
assertEquals(400, refused.status, refused.lines.toString())
assertTrue(refused.lines.single().startsWith("""["OK","${forged.id}",false,"""), refused.lines.toString())
}
@@ -217,76 +230,89 @@ class HttpRelayHandlerTest {
@Test
fun aCountIsOneCountLine() {
backend.events += listOf(note("a"), note("b"), note("c"))
val answer = handler().ask(HttpRelayCommand.COUNT, """{"kinds":[1]}""")
val answer = handler().ask(count("""{"kinds":[1]}"""))
assertEquals(200, answer.status)
assertTrue(answer.lines.single().startsWith("""["COUNT",{"count":3"""), answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["COUNT","c",{"count":3"""), answer.lines.toString())
}
@Test
fun aBodyThatIsNotTheCommandsArgumentsIsA400AndOneOverTheLimitA413() {
// Not the command's shape: refused before any session opens.
for ((command, body) in listOf(
HttpRelayCommand.REQ to "[]",
HttpRelayCommand.EVENT to """[{"id":"x"}]""",
HttpRelayCommand.COUNT to "not json",
)) {
val answer = handler().ask(command, body)
assertEquals(400, answer.status, "$command '$body'")
assertTrue(answer.lines.single().startsWith("""["CLOSED","invalid:"""), answer.lines.toString())
fun aBodyThatIsNotAReqCountOrEventIsA400AndOneOverTheLimitA413() {
for (body in listOf("[]", """{"kinds":[1]}""", "not json", """["CLOSE","q"]""", """["AUTH",{"id":"x"}]""", """["NEG-CLOSE","n"]""")) {
val answer = handler().ask(body)
assertEquals(400, answer.status, body)
assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid:"""), answer.lines.toString())
}
// The right shape with an inside the engine cannot read: its own NOTICE, the command never ran.
val unreadable = handler().ask(HttpRelayCommand.EVENT, """{"id":"not an event"}""")
assertEquals(400, unreadable.status)
assertTrue(unreadable.lines.single().startsWith("""["NOTICE","""), unreadable.lines.toString())
assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(5_000)}"}""").status)
// Under the byte cap, over it once wrapped in its frame: the engine measures the frame.
assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(4_096 - 20)}"}""").status)
// A REQ the engine refuses as a command: its own NOTICE, the command never ran.
val empty = handler().ask("""["REQ","",{"kinds":[1]}]""")
assertEquals(400, empty.status)
assertTrue(empty.lines.single().startsWith("""["NOTICE","""), empty.lines.toString())
// Over the relay's message length, in characters, as the socket measures it.
val big = handler().ask(req("""{"search":"${"x".repeat(4_096)}"}"""))
assertEquals(413, big.status)
assertTrue(big.lines.single().startsWith("""["NOTICE","invalid:"""), big.lines.toString())
}
@Test
fun aNip98SignatureSignsTheSessionInAndAnotherSchemeDoesNot() {
backend.events += note("gated")
val gated = handler(signedInOnly = true)
val body = """{"kinds":[1]}"""
val frame = req("""{"kinds":[1]}""")
val anonymous = gated.ask(HttpRelayCommand.REQ, body)
val anonymous = gated.ask(frame)
assertEquals(401, anonymous.status)
assertTrue(anonymous.lines.single().startsWith("""["CLOSED","auth-required:"""))
assertTrue(anonymous.lines.single().startsWith("""["CLOSED","q","auth-required:"""))
assertEquals(401, gated.ask(HttpRelayCommand.REQ, body, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay")
assertEquals(401, gated.ask(frame, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay")
val signed = gated.ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body))
val signed = gated.ask(frame, token(frame))
assertEquals(200, signed.status, signed.lines.toString())
assertEquals("""["EOSE"]""", signed.lines.last())
assertEquals("""["EOSE","q"]""", signed.lines.last())
}
@Test
fun aTokenSignsOnlyItsBodyAndIsNotSingleUse() {
fun aTokenSignsOnlyItsBodyAndIsGoodAgainWithinItsWindow() {
val h = handler()
val body = """{"kinds":[1]}"""
val signed = token(HttpRelayCommand.REQ, body)
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status)
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status, "any instance may answer it, so none remembers it")
val other = h.ask(HttpRelayCommand.REQ, """{"kinds":[0]}""", token(HttpRelayCommand.REQ, body))
val frame = req("""{"kinds":[1]}""")
val signed = token(frame)
assertEquals(200, h.ask(frame, signed).status)
assertEquals(200, h.ask(frame, signed).status, "the same body again only repeats the read")
val other = h.ask(req("""{"kinds":[0]}"""), signed)
assertEquals(401, other.status)
assertTrue("payload" in other.lines.single(), other.lines.toString())
assertTrue(other.lines.single().startsWith("""["CLOSED","q","auth-required:"""), other.lines.toString())
}
@Test
fun aTokenOutsideItsSixtySecondsIsRefused() {
val frame = req("""{"kinds":[1]}""")
val stale = alice.sign(HTTPAuthorizationEvent.build(origin, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000 - 120) {}).toAuthToken()
assertEquals(401, handler().ask(frame, stale).status)
}
@Test
fun anEventRefusedForItsTokenIsAnOkFalse() {
val posted = publish(note("unsigned"))
val answer = handler().ask(posted, token(req("{}")))
assertEquals(401, answer.status)
assertTrue(answer.lines.single().startsWith("""["OK","""), answer.lines.toString())
assertTrue(""",false,"auth-required:""" in answer.lines.single(), answer.lines.toString())
}
@Test
fun noFirstFrameWithinTheDeadlineIsA503() {
val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[$STALLED_KIND]}""")
val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[$STALLED_KIND]}"""))
assertEquals(503, answer.status)
assertTrue(answer.lines.single().startsWith("""["CLOSED","error: no answer"""))
assertTrue(answer.lines.single().startsWith("""["NOTICE","error: no answer"""))
}
@Test
fun aDeadlineMidAnswerEndsOnAClosedLine() {
val found = note("found")
backend.events += found
val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[1,$TRICKLE_KIND]}""")
val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[1,$TRICKLE_KIND]}"""))
assertEquals(200, answer.status)
assertTrue(found.id in answer.lines.first())
assertTrue(answer.lines.last().startsWith("""["CLOSED","error: the answer ran past"""), answer.lines.toString())
assertTrue(answer.lines.last().startsWith("""["CLOSED","q","error: the answer ran past"""), answer.lines.toString())
}
@Test
@@ -308,7 +334,7 @@ class HttpRelayHandlerTest {
}.lines()
}
assertFailsWith<HttpRelayReaderStalled> {
runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.REQ, null, """{"kinds":[1,$TRICKLE_KIND]}""".encodeToByteArray()), stalled) }
runBlocking { h.handle(HttpRelayRequest(null, req("""{"kinds":[1,$TRICKLE_KIND]}""").encodeToByteArray()), stalled) }
}
}
@@ -322,26 +348,17 @@ class HttpRelayHandlerTest {
session.close()
}
@Test
fun framesCarryNoSubscriptionId() {
val found = note("found")
backend.events += found
val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""")
assertTrue(answer.lines.first().startsWith("""["EVENT",{"""), answer.lines.toString())
assertEquals("""["EOSE"]""", answer.lines.last())
}
@Test
fun aDeeplyNestedBodyIsA400NotAStackOverflow() {
for (body in listOf("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000), "[".repeat(20_000) + "]".repeat(20_000))) {
val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body)
for (body in listOf(req("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000)), "[".repeat(20_000) + "]".repeat(20_000))) {
val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(body)
assertEquals(400, answer.status, body.take(20))
}
}
@Test
fun aFullAuthPolicyRefusesTransportSignInUntilItOptsIn() {
val body = """{"kinds":[1]}"""
val frame = req("""{"kinds":[1]}""")
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.example")
val refusing =
MemoryRelay(backend, {
@@ -349,9 +366,9 @@ class HttpRelayHandlerTest {
override suspend fun authorize(event: RelayAuthEvent): Unit = error("backend rejected user")
}
})
val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body))
val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(frame, token(frame))
assertEquals(403, refused.status, refused.lines.toString())
assertTrue(refused.lines.single().startsWith("""["CLOSED","restricted:"""), refused.lines.toString())
assertTrue(refused.lines.single().startsWith("""["CLOSED","q","restricted:"""), refused.lines.toString())
val optingIn =
MemoryRelay(backend, {
@@ -359,14 +376,14 @@ class HttpRelayHandlerTest {
override suspend fun authorizeTransport(pubkey: HexKey): String? = null
}
})
val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body))
val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(frame, token(frame))
assertEquals(200, signed.status, signed.lines.toString())
}
@Test
fun aMessageLimitInThePolicyChainStillRuns() {
val limited = MemoryRelay(backend, { LimitsPolicy(RelayLimits(maxMessageLength = 4096)) + VerifyPolicy }, limits = null)
val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(20_000)}"}""")
val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(req("""{"search":"${"x".repeat(20_000)}"}"""))
assertEquals(400, answer.status, answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid: message too large"""), answer.lines.toString())
}
@@ -374,13 +391,12 @@ class HttpRelayHandlerTest {
@Test
fun aMultiByteEventUnderTheCharacterLimitIsAccepted() {
// 1,500 CJK characters: about 4,500 UTF-8 bytes, well under 4,096 characters as the engine counts.
val posted = note("\u4E2D".repeat(1_500))
val answer = handler().ask(HttpRelayCommand.EVENT, posted.toJson())
val answer = handler().ask(publish(note("中".repeat(1_500))))
assertEquals(200, answer.status, answer.lines.toString())
}
@Test
fun aBackendFailureIsA500Line() {
fun aBackendFailureIsA500OkFalse() {
val failing =
object : SessionBackend by backend {
override suspend fun submit(
@@ -388,17 +404,18 @@ class HttpRelayHandlerTest {
onComplete: (IEventStore.InsertOutcome) -> Unit,
): Unit = error("db is down")
}
val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(HttpRelayCommand.EVENT, note("lost").toJson())
val lost = note("lost")
val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(publish(lost))
assertEquals(500, answer.status, answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["CLOSED","error:"""), answer.lines.toString())
assertTrue(answer.lines.single().startsWith("""["OK","${lost.id}",false,"error:"""), answer.lines.toString())
}
@Test
fun anInfiniteDeadlineStillStreams() {
backend.events += note("forever")
val answer = handler(deadline = Duration.INFINITE).ask(HttpRelayCommand.REQ, """{"kinds":[1]}""")
val answer = handler(deadline = Duration.INFINITE).ask(req("""{"kinds":[1]}"""))
assertEquals(200, answer.status)
assertEquals("""["EOSE"]""", answer.lines.last())
assertEquals("""["EOSE","q"]""", answer.lines.last())
}
@Test
@@ -414,37 +431,27 @@ class HttpRelayHandlerTest {
override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = error("single")
}
assertFailsWith<HttpRelayReaderStalled> {
runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.COUNT, null, """{"kinds":[1]}""".encodeToByteArray()), stalled) }
runBlocking { h.handle(HttpRelayRequest(null, count("""{"kinds":[1]}""").encodeToByteArray()), stalled) }
}
}
@Test
fun aBodyIsSplicedIntoItsFrameAsSent() {
assertEquals("""["REQ","http",{"kinds":[1]}]""", HttpRelayCommand.REQ.frameOf(""" {"kinds":[1]} """))
assertEquals("""["COUNT","http",{"a":"]"},{"b":"\"["}]""", HttpRelayCommand.COUNT.frameOf("""[{"a":"]"},{"b":"\"["}]"""))
assertEquals("""["EVENT",{"id":"x"}]""", HttpRelayCommand.EVENT.frameOf("""{"id":"x"}"""))
for (bad in listOf("", "[]", "[ ]", "[{}", "1", "null", "\"x\"")) assertEquals(null, HttpRelayCommand.REQ.frameOf(bad), "REQ '$bad'")
for (bad in listOf("""[{"id":"x"}]""", "1")) assertEquals(null, HttpRelayCommand.EVENT.frameOf(bad), "EVENT '$bad'")
}
@Test
fun aBodyCannotCarryASecondCommand() {
val smuggled = note("smuggled")
val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}],["EVENT",${smuggled.toJson()}""")
assertTrue(answer.lines.last().let { it == """["EOSE"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString())
val answer = handler().ask(req("""{"kinds":[1]}""") + publish(smuggled))
assertTrue(answer.lines.last().let { it == """["EOSE","q"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString())
assertTrue(backend.events.none { it.id == smuggled.id }, "only the REQ ran")
}
@Test
fun aTokenSignedAtAnyOfTheRelaysAddressesVerifies() {
val onion = "http://relayxyz.onion"
val onion = "http://relayxyz.onion/"
val h = HttpRelayHandler(MemoryRelay(backend, true), origins = { listOf(origin, onion) })
val body = """{"kinds":[1]}"""
fun at(base: String) = alice.sign(HTTPAuthorizationEvent.build(base + "/req", "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken()
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(onion)).status)
assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(origin)).status)
assertEquals(401, h.ask(HttpRelayCommand.REQ, body, at("https://elsewhere.example")).status)
val frame = req("""{"kinds":[1]}""")
assertEquals(200, h.ask(frame, token(frame, onion)).status)
assertEquals(200, h.ask(frame, token(frame, "http://relayxyz.onion")).status, "with or without the trailing slash")
assertEquals(200, h.ask(frame, token(frame, "$origin/")).status)
assertEquals(401, h.ask(frame, token(frame, "https://elsewhere.example")).status)
}
private companion object {