mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
feat(mobile): identify clients to Buzz servers
Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@buzz.block.builderlab.xyz> Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@buzz.block.builderlab.xyz>
This commit is contained in:
parent
acfbb1bb6a
commit
454719ff5b
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -1164,6 +1164,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sfv",
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
@@ -2169,7 +2170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7965,6 +7966,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sfv"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "890ef0e5532daf21fdaa3dbaf991417187f3a87ab69231fe69be942495746480"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"indexmap",
|
||||
"ref-cast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
|
||||
@@ -78,6 +78,7 @@ metrics = { workspace = true }
|
||||
metrics-exporter-prometheus = { workspace = true }
|
||||
metrics-util = { workspace = true }
|
||||
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
|
||||
sfv = "0.15.0"
|
||||
|
||||
[features]
|
||||
dev = ["buzz-auth/dev"]
|
||||
|
||||
238
crates/buzz-relay/src/client_info.rs
Normal file
238
crates/buzz-relay/src/client_info.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! Advisory parsing for the mobile `Buzz-Client` structured field.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
extract::OptionalFromRequestParts,
|
||||
http::{request::Parts, HeaderMap},
|
||||
};
|
||||
use sfv::{BareItem, Dictionary, ListEntry, Parser};
|
||||
|
||||
/// Parsed, untrusted metadata supplied by a Buzz client.
|
||||
///
|
||||
/// This data is for observability only. It must never participate in
|
||||
/// authentication, authorization, or tenant selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ClientInfo {
|
||||
/// Structured header format version.
|
||||
pub format_version: i64,
|
||||
/// Logical application identifier.
|
||||
pub app: String,
|
||||
/// Client platform (`ios` or `android`).
|
||||
pub platform: String,
|
||||
/// User-visible application version.
|
||||
pub app_version: String,
|
||||
/// Platform build identifier, constrained to decimal digits.
|
||||
pub app_build: String,
|
||||
/// Coarse public operating-system version.
|
||||
pub os_version: String,
|
||||
/// Android API level, present only on Android.
|
||||
pub os_api: Option<i64>,
|
||||
}
|
||||
|
||||
impl ClientInfo {
|
||||
/// Parse `Buzz-Client` from a request header map.
|
||||
///
|
||||
/// A missing header is a supported state and returns `None` without a
|
||||
/// metric. A present but invalid header increments the parse-failure
|
||||
/// counter and also returns `None`, so it can never reject a request.
|
||||
#[must_use]
|
||||
pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
|
||||
let value = headers.get("buzz-client")?;
|
||||
let parsed = value.to_str().ok().and_then(|raw| Self::parse(raw).ok());
|
||||
if parsed.is_none() {
|
||||
metrics::counter!("buzz_client_header_parse_failures_total").increment(1);
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn parse(raw: &str) -> Result<Self, ()> {
|
||||
let dictionary: Dictionary = Parser::new(raw).parse_dictionary().map_err(|_| ())?;
|
||||
|
||||
let format_version = integer(&dictionary, "v")?;
|
||||
if format_version != 1 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let app = token(&dictionary, "app")?;
|
||||
if app != "buzz-mobile" {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let platform = token(&dictionary, "platform")?;
|
||||
if platform != "ios" && platform != "android" {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let app_version = string(&dictionary, "app-version")?;
|
||||
let app_build = string(&dictionary, "app-build")?;
|
||||
let os_version = string(&dictionary, "os-version")?;
|
||||
if app_version.is_empty()
|
||||
|| app_build.is_empty()
|
||||
|| !app_build.bytes().all(|byte| byte.is_ascii_digit())
|
||||
|| os_version.is_empty()
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let os_api = optional_integer(&dictionary, "os-api")?;
|
||||
match platform.as_str() {
|
||||
"android" if !matches!(os_api, Some(api) if api > 0) => return Err(()),
|
||||
"ios" if os_api.is_some() => return Err(()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
format_version,
|
||||
app,
|
||||
platform,
|
||||
app_version,
|
||||
app_build,
|
||||
os_version,
|
||||
os_api,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a low-cardinality observation for a parsed client.
|
||||
pub fn record_observation(&self) {
|
||||
metrics::counter!(
|
||||
"buzz_client_requests_total",
|
||||
"app" => self.app.clone(),
|
||||
"platform" => self.platform.clone(),
|
||||
"app_version" => self.app_version.clone(),
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> OptionalFromRequestParts<S> for ClientInfo
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
_state: &S,
|
||||
) -> Result<Option<Self>, Self::Rejection> {
|
||||
Ok(Self::from_headers(&parts.headers))
|
||||
}
|
||||
}
|
||||
|
||||
fn bare_item<'a>(dictionary: &'a Dictionary, key: &str) -> Result<&'a BareItem, ()> {
|
||||
let Some(ListEntry::Item(item)) = dictionary.get(key) else {
|
||||
return Err(());
|
||||
};
|
||||
if !item.params.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(&item.bare_item)
|
||||
}
|
||||
|
||||
fn token(dictionary: &Dictionary, key: &str) -> Result<String, ()> {
|
||||
bare_item(dictionary, key)?
|
||||
.as_token()
|
||||
.map(|value| value.as_str().to_owned())
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn string(dictionary: &Dictionary, key: &str) -> Result<String, ()> {
|
||||
bare_item(dictionary, key)?
|
||||
.as_string()
|
||||
.map(|value| value.as_str().to_owned())
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn integer(dictionary: &Dictionary, key: &str) -> Result<i64, ()> {
|
||||
bare_item(dictionary, key)?
|
||||
.as_integer()
|
||||
.map(Into::into)
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn optional_integer(dictionary: &Dictionary, key: &str) -> Result<Option<i64>, ()> {
|
||||
if !dictionary.contains_key(key) {
|
||||
return Ok(None);
|
||||
}
|
||||
integer(dictionary, key).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn parse_failures(recorder: &DebuggingRecorder) -> u64 {
|
||||
recorder
|
||||
.snapshotter()
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.find_map(|(key, _, _, value)| {
|
||||
(key.key().name() == "buzz_client_header_parse_failures_total").then_some(value)
|
||||
})
|
||||
.map(|value| match value {
|
||||
DebugValue::Counter(value) => value,
|
||||
_ => panic!("parse failures must be a counter"),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_valid_ios_and_android_headers() {
|
||||
let ios = ClientInfo::parse(
|
||||
r#"v=1, app=buzz-mobile, platform=ios, app-version="0.4.5", app-build="6", os-version="18.5""#,
|
||||
)
|
||||
.expect("valid iOS header");
|
||||
assert_eq!(
|
||||
ios,
|
||||
ClientInfo {
|
||||
format_version: 1,
|
||||
app: "buzz-mobile".to_owned(),
|
||||
platform: "ios".to_owned(),
|
||||
app_version: "0.4.5".to_owned(),
|
||||
app_build: "6".to_owned(),
|
||||
os_version: "18.5".to_owned(),
|
||||
os_api: None,
|
||||
}
|
||||
);
|
||||
|
||||
let android = ClientInfo::parse(
|
||||
r#"v=1, app=buzz-mobile, platform=android, app-version="0.4.5", app-build="7", os-version="15", os-api=35, future-key=ignored"#,
|
||||
)
|
||||
.expect("valid Android header");
|
||||
assert_eq!(android.os_api, Some(35));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_header_is_absent_without_parse_failure() {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
assert_eq!(ClientInfo::from_headers(&HeaderMap::new()), None);
|
||||
});
|
||||
assert_eq!(parse_failures(&recorder), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_semantically_invalid_header_is_absent_and_counted() {
|
||||
for raw in [
|
||||
"not a dictionary",
|
||||
r#"v=2, app=buzz-mobile, platform=ios, app-version="1", app-build="1", os-version="18""#,
|
||||
r#"v=1, app=buzz-mobile, platform=android, app-version="1", app-build="1", os-version="15""#,
|
||||
r#"v=1, app=buzz-mobile, platform=ios, app-version="1", app-build="1.beta", os-version="18""#,
|
||||
] {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"buzz-client",
|
||||
HeaderValue::from_str(raw).expect("test header value"),
|
||||
);
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
assert_eq!(ClientInfo::from_headers(&headers), None, "{raw}");
|
||||
});
|
||||
assert_eq!(parse_failures(&recorder), 1, "{raw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use buzz_auth::{generate_challenge, AuthContext, LimitType};
|
||||
use buzz_core::tenant::TenantContext;
|
||||
use nostr::Filter;
|
||||
|
||||
use crate::client_info::ClientInfo;
|
||||
use crate::handlers;
|
||||
use crate::protocol::{ClientMessage, RelayMessage};
|
||||
use crate::state::{run_registered_community_connection, AppState};
|
||||
@@ -120,6 +121,7 @@ pub async fn handle_connection(
|
||||
state: Arc<AppState>,
|
||||
addr: SocketAddr,
|
||||
tenant: TenantContext,
|
||||
client_info: Option<ClientInfo>,
|
||||
) {
|
||||
let conn_id = Uuid::new_v4();
|
||||
let cancel = CancellationToken::new();
|
||||
@@ -133,7 +135,17 @@ pub async fn handle_connection(
|
||||
community_id,
|
||||
cancel.clone(),
|
||||
move || async move { check_state.db.is_community_active(community_id).await },
|
||||
move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel),
|
||||
move || {
|
||||
handle_active_connection(
|
||||
socket,
|
||||
run_state,
|
||||
addr,
|
||||
tenant,
|
||||
conn_id,
|
||||
cancel,
|
||||
client_info,
|
||||
)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -145,6 +157,7 @@ async fn handle_active_connection(
|
||||
tenant: TenantContext,
|
||||
conn_id: Uuid,
|
||||
cancel: CancellationToken,
|
||||
client_info: Option<ClientInfo>,
|
||||
) {
|
||||
let permit = match state.conn_semaphore.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
@@ -179,7 +192,20 @@ async fn handle_active_connection(
|
||||
grace_limit: state.config.slow_client_grace_limit,
|
||||
});
|
||||
|
||||
info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established");
|
||||
if let Some(client) = &client_info {
|
||||
client.record_observation();
|
||||
}
|
||||
info!(
|
||||
conn_id = %conn_id,
|
||||
addr = %addr,
|
||||
client.app = client_info.as_ref().map(|client| client.app.as_str()),
|
||||
client.platform = client_info.as_ref().map(|client| client.platform.as_str()),
|
||||
client.app_version = client_info.as_ref().map(|client| client.app_version.as_str()),
|
||||
client.app_build = client_info.as_ref().map(|client| client.app_build.as_str()),
|
||||
client.os_version = client_info.as_ref().map(|client| client.os_version.as_str()),
|
||||
client.os_api = client_info.as_ref().and_then(|client| client.os_api),
|
||||
"WebSocket connection established"
|
||||
);
|
||||
metrics::counter!(
|
||||
"buzz_ws_connections_total",
|
||||
"community" => conn.tenant.host().to_owned()
|
||||
|
||||
@@ -8,6 +8,8 @@ mod admission;
|
||||
pub mod api;
|
||||
/// WebSocket audio relay for huddle voice channels.
|
||||
pub mod audio;
|
||||
/// Advisory Buzz client-identification parsing.
|
||||
pub mod client_info;
|
||||
/// Relay configuration from environment variables.
|
||||
pub mod config;
|
||||
/// Runtime conformance harness — abstract trace emission at the
|
||||
|
||||
@@ -17,9 +17,11 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::Instrument as _;
|
||||
|
||||
use crate::api;
|
||||
use crate::audio;
|
||||
use crate::client_info::ClientInfo;
|
||||
use crate::connection::handle_connection;
|
||||
use crate::metrics::track_metrics;
|
||||
use crate::nip11::{nip11_document, relay_info_handler};
|
||||
@@ -298,10 +300,29 @@ async fn nip11_or_ws_handler(
|
||||
};
|
||||
|
||||
let max_frame_bytes = state.config.max_frame_bytes;
|
||||
let client_info = ClientInfo::from_headers(&headers);
|
||||
match WebSocketUpgrade::from_request(req, &state).await {
|
||||
Ok(ws) => limit_relay_websocket(ws, max_frame_bytes)
|
||||
.on_upgrade(move |socket| handle_connection(socket, state, addr, tenant))
|
||||
.into_response(),
|
||||
Ok(ws) => {
|
||||
let connection_span = tracing::info_span!(
|
||||
"ws.connection",
|
||||
client.app = client_info.as_ref().map(|client| client.app.as_str()),
|
||||
client.platform = client_info.as_ref().map(|client| client.platform.as_str()),
|
||||
client.app_version = client_info
|
||||
.as_ref()
|
||||
.map(|client| client.app_version.as_str()),
|
||||
client.app_build = client_info.as_ref().map(|client| client.app_build.as_str()),
|
||||
client.os_version = client_info
|
||||
.as_ref()
|
||||
.map(|client| client.os_version.as_str()),
|
||||
client.os_api = client_info.as_ref().and_then(|client| client.os_api),
|
||||
);
|
||||
limit_relay_websocket(ws, max_frame_bytes)
|
||||
.on_upgrade(move |socket| {
|
||||
handle_connection(socket, state, addr, tenant, client_info)
|
||||
.instrument(connection_span)
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
// Browser requesting HTML and Git web GUI is enabled → serve SPA.
|
||||
if state.config.serve_git_web_gui {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/client/client_headers.dart';
|
||||
import '../../shared/deeplink/deep_link.dart';
|
||||
import '../../shared/relay/relay_session.dart';
|
||||
|
||||
@@ -127,6 +128,11 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
...clientHeadersForUrl(
|
||||
headers: ref.read(clientHeadersProvider),
|
||||
targetUrl: url,
|
||||
relayUrl: invite.relayUrl,
|
||||
),
|
||||
'Authorization': buildNip98AuthHeader(
|
||||
method: 'POST',
|
||||
url: url,
|
||||
|
||||
@@ -61,6 +61,7 @@ typedef PairingSocketFactory =
|
||||
PairingSocket Function({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required Map<String, String> headers,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
});
|
||||
@@ -76,11 +77,13 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
static PairingSocket _createPairingSocket({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required Map<String, String> headers,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) => PairingSocket(
|
||||
wsUrl: wsUrl,
|
||||
ephemeralPrivkey: ephemeralPrivkey,
|
||||
headers: headers,
|
||||
onMessage: onMessage,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
@@ -198,6 +201,10 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
final socket = _socketFactory(
|
||||
wsUrl: relayWsUrl,
|
||||
ephemeralPrivkey: _ephemeralPrivkey!,
|
||||
headers: clientHeadersForUrl(
|
||||
headers: ref.read(clientHeadersProvider),
|
||||
targetUrl: relayWsUrl,
|
||||
),
|
||||
onMessage: _handleRelayMessage,
|
||||
onDisconnected: _handleDisconnected,
|
||||
);
|
||||
@@ -611,6 +618,10 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
final socket = RelaySocket(
|
||||
wsUrl: wsUrl,
|
||||
nsec: nsec,
|
||||
headers: clientHeadersForUrl(
|
||||
headers: ref.read(clientHeadersProvider),
|
||||
targetUrl: wsUrl,
|
||||
),
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (_) {},
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../shared/relay/nostr_models.dart';
|
||||
@@ -9,6 +10,9 @@ import '../../shared/relay/nostr_models.dart';
|
||||
const _desktopPairingAuthChallengeGrace = Duration(seconds: 3);
|
||||
const _pairingAuthOkTimeout = Duration(seconds: 8);
|
||||
|
||||
typedef PairingChannelFactory =
|
||||
WebSocketChannel Function(Uri uri, Map<String, String> headers);
|
||||
|
||||
/// Ephemeral WebSocket connection for NIP-AB pairing.
|
||||
///
|
||||
/// Uses ephemeral keys for NIP-42 auth (not the stored user keys).
|
||||
@@ -29,7 +33,8 @@ class PairingSocket {
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
final Duration _authChallengeTimeout;
|
||||
final Duration _authResponseTimeout;
|
||||
final WebSocketChannel Function(Uri uri) _channelFactory;
|
||||
final Map<String, String> _headers;
|
||||
final PairingChannelFactory _channelFactory;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
@@ -46,21 +51,27 @@ class PairingSocket {
|
||||
required void Function(Object? error) onDisconnected,
|
||||
Duration authChallengeTimeout = _desktopPairingAuthChallengeGrace,
|
||||
Duration authResponseTimeout = _pairingAuthOkTimeout,
|
||||
WebSocketChannel Function(Uri uri) channelFactory =
|
||||
WebSocketChannel.connect,
|
||||
Map<String, String> headers = const {},
|
||||
PairingChannelFactory channelFactory = _defaultChannelFactory,
|
||||
}) : _wsUrl = wsUrl,
|
||||
_ephemeralPrivkey = ephemeralPrivkey,
|
||||
_onMessage = onMessage,
|
||||
_onDisconnected = onDisconnected,
|
||||
_authChallengeTimeout = authChallengeTimeout,
|
||||
_authResponseTimeout = authResponseTimeout,
|
||||
_headers = Map.unmodifiable(headers),
|
||||
_channelFactory = channelFactory;
|
||||
|
||||
static WebSocketChannel _defaultChannelFactory(
|
||||
Uri uri,
|
||||
Map<String, String> headers,
|
||||
) => IOWebSocketChannel.connect(uri, headers: headers);
|
||||
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Connect and answer a NIP-42 challenge when the relay requires one.
|
||||
Future<void> connect() async {
|
||||
_channel = _channelFactory(Uri.parse(_wsUrl));
|
||||
_channel = _channelFactory(Uri.parse(_wsUrl), _headers);
|
||||
await _channel!.ready;
|
||||
|
||||
_authCompleter = Completer<void>();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
@@ -18,7 +16,7 @@ import '../custom_emoji/custom_emoji_provider.dart';
|
||||
import '../custom_emoji/custom_emoji_render.dart';
|
||||
import 'theme_picker_page.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -27,8 +25,8 @@ class SettingsPage extends HookConsumerWidget {
|
||||
final selectedAccent = ref.watch(accentProvider);
|
||||
final selectedScheme = ref.watch(schemeProvider);
|
||||
final colorScheme = context.colors;
|
||||
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
|
||||
final packageInfo = useFuture(packageInfoFuture);
|
||||
final clientHeaders = ref.watch(clientHeadersProvider);
|
||||
final appVersion = clientHeaders.appVersion;
|
||||
|
||||
return FrostedScaffold(
|
||||
appBar: const FrostedAppBar(title: Text('Settings')),
|
||||
@@ -166,14 +164,14 @@ class SettingsPage extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (packageInfo.hasData)
|
||||
if (appVersion.isNotEmpty)
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'v${packageInfo.data!.version}',
|
||||
'v$appVersion',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant.withValues(
|
||||
alpha: 0.6,
|
||||
|
||||
@@ -3,17 +3,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'shared/client/client_headers.dart';
|
||||
import 'shared/theme/theme_provider.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Pre-load preferences so the first frame uses the saved theme/accent.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final (prefs, clientHeaders) = await (
|
||||
SharedPreferences.getInstance(),
|
||||
loadClientHeaders(),
|
||||
).wait;
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
clientHeadersProvider.overrideWithValue(clientHeaders),
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
|
||||
275
mobile/lib/shared/client/client_headers.dart
Normal file
275
mobile/lib/shared/client/client_headers.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
const _buzzClientHeaderName = 'Buzz-Client';
|
||||
const _userAgentHeaderName = 'User-Agent';
|
||||
|
||||
/// Immutable HTTP identification headers for the Buzz mobile client.
|
||||
@immutable
|
||||
class ClientHeaders {
|
||||
final String appVersion;
|
||||
final String buzzClient;
|
||||
final String userAgent;
|
||||
|
||||
const ClientHeaders({
|
||||
required this.appVersion,
|
||||
required this.buzzClient,
|
||||
required this.userAgent,
|
||||
});
|
||||
|
||||
Map<String, String> get values {
|
||||
if (buzzClient.isEmpty && userAgent.isEmpty) return const {};
|
||||
if (buzzClient.isEmpty || userAgent.isEmpty) {
|
||||
throw StateError('Buzz client headers must be initialized together');
|
||||
}
|
||||
return Map.unmodifiable({
|
||||
_buzzClientHeaderName: buzzClient,
|
||||
_userAgentHeaderName: userAgent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata used to construct [ClientHeaders].
|
||||
@immutable
|
||||
class ClientHeaderMetadata {
|
||||
final String platform;
|
||||
final String appVersion;
|
||||
final String appBuild;
|
||||
final String osVersion;
|
||||
final int? osApi;
|
||||
|
||||
const ClientHeaderMetadata({
|
||||
required this.platform,
|
||||
required this.appVersion,
|
||||
required this.appBuild,
|
||||
required this.osVersion,
|
||||
this.osApi,
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the canonical structured `Buzz-Client` and display-only `User-Agent`.
|
||||
ClientHeaders buildClientHeaders(ClientHeaderMetadata metadata) {
|
||||
if (metadata.platform != 'ios' && metadata.platform != 'android') {
|
||||
throw ArgumentError.value(
|
||||
metadata.platform,
|
||||
'platform',
|
||||
'must be ios or android',
|
||||
);
|
||||
}
|
||||
if (metadata.platform == 'android' &&
|
||||
(metadata.osApi == null || metadata.osApi! <= 0)) {
|
||||
throw ArgumentError('Android client headers require a positive osApi');
|
||||
}
|
||||
if (metadata.platform == 'ios' && metadata.osApi != null) {
|
||||
throw ArgumentError('iOS client headers must not include osApi');
|
||||
}
|
||||
if (metadata.appBuild.isEmpty ||
|
||||
!metadata.appBuild.codeUnits.every(
|
||||
(codeUnit) => codeUnit >= 0x30 && codeUnit <= 0x39,
|
||||
)) {
|
||||
throw FormatException('App build must contain only decimal digits');
|
||||
}
|
||||
|
||||
final buzzClient = StringBuffer(
|
||||
'v=1, app=buzz-mobile, platform=${metadata.platform}, '
|
||||
'app-version=${_structuredString(metadata.appVersion)}, '
|
||||
'app-build=${_structuredString(metadata.appBuild)}, '
|
||||
'os-version=${_structuredString(metadata.osVersion)}',
|
||||
);
|
||||
if (metadata.osApi case final osApi?) {
|
||||
buzzClient.write(', os-api=$osApi');
|
||||
}
|
||||
|
||||
return ClientHeaders(
|
||||
appVersion: metadata.appVersion,
|
||||
buzzClient: buzzClient.toString(),
|
||||
userAgent:
|
||||
'Buzz/${_userAgentProduct(metadata.appVersion)} '
|
||||
'(${metadata.platform}; build ${_userAgentComment(metadata.appBuild)})',
|
||||
);
|
||||
}
|
||||
|
||||
String _structuredString(String value) {
|
||||
final escaped = StringBuffer('"');
|
||||
for (final codeUnit in value.codeUnits) {
|
||||
if (codeUnit < 0x20 || codeUnit > 0x7e) {
|
||||
throw FormatException(
|
||||
'Buzz-Client structured strings must contain visible ASCII',
|
||||
);
|
||||
}
|
||||
if (codeUnit == 0x22 || codeUnit == 0x5c) {
|
||||
escaped.writeCharCode(0x5c);
|
||||
}
|
||||
escaped.writeCharCode(codeUnit);
|
||||
}
|
||||
escaped.write('"');
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
String _userAgentProduct(String value) {
|
||||
if (value.isEmpty || !_isHttpToken(value)) {
|
||||
throw FormatException(
|
||||
'App version must be a valid User-Agent product token',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _userAgentComment(String value) {
|
||||
if (value.isEmpty ||
|
||||
value.codeUnits.any(
|
||||
(codeUnit) => codeUnit < 0x20 || codeUnit > 0x7e || codeUnit == 0x29,
|
||||
)) {
|
||||
throw FormatException('App build must be valid in a User-Agent comment');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool _isHttpToken(String value) {
|
||||
const separators = '()<>@,;:\\"/[]?={} \t';
|
||||
return value.codeUnits.every(
|
||||
(codeUnit) =>
|
||||
codeUnit >= 0x21 &&
|
||||
codeUnit <= 0x7e &&
|
||||
!separators.contains(String.fromCharCode(codeUnit)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads immutable mobile identification headers before the app starts.
|
||||
Future<ClientHeaders> loadClientHeaders() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final ios = await deviceInfo.iosInfo;
|
||||
return buildClientHeaders(
|
||||
ClientHeaderMetadata(
|
||||
platform: 'ios',
|
||||
appVersion: packageInfo.version,
|
||||
appBuild: packageInfo.buildNumber,
|
||||
osVersion: _coarseOsVersion(ios.systemVersion),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
final android = await deviceInfo.androidInfo;
|
||||
return buildClientHeaders(
|
||||
ClientHeaderMetadata(
|
||||
platform: 'android',
|
||||
appVersion: packageInfo.version,
|
||||
appBuild: packageInfo.buildNumber,
|
||||
osVersion: _coarseOsVersion(android.version.release),
|
||||
osApi: android.version.sdkInt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
throw UnsupportedError('Buzz mobile supports only iOS and Android');
|
||||
}
|
||||
|
||||
/// Process-cached identification headers, overridden with platform data in main.
|
||||
///
|
||||
/// Tests and component previews do not execute [main], so the provider has an
|
||||
/// inert value until the app-level override installs real platform metadata.
|
||||
const _uninitializedClientHeaders = ClientHeaders(
|
||||
appVersion: '',
|
||||
buzzClient: '',
|
||||
userAgent: '',
|
||||
);
|
||||
|
||||
final clientHeadersProvider = Provider<ClientHeaders>(
|
||||
(ref) => _uninitializedClientHeaders,
|
||||
);
|
||||
|
||||
String _coarseOsVersion(String version) {
|
||||
final match = RegExp(r'^(\d+)(?:\.(\d+))?').firstMatch(version.trim());
|
||||
if (match == null) {
|
||||
throw FormatException('OS version does not start with a numeric release');
|
||||
}
|
||||
final minor = match.group(2);
|
||||
return minor == null ? match.group(1)! : '${match.group(1)}.$minor';
|
||||
}
|
||||
|
||||
/// Returns true when [targetUrl] is a Buzz-owned first-party origin.
|
||||
///
|
||||
/// The active relay origin is trusted explicitly. Hosted Buzz community and
|
||||
/// pairing domains are trusted by suffix/exact match. Local origins are only
|
||||
/// trusted in debug builds.
|
||||
bool isFirstPartyBuzzUrl(
|
||||
String targetUrl, {
|
||||
String? relayUrl,
|
||||
bool allowLocalDevelopment = kDebugMode,
|
||||
}) {
|
||||
final target = Uri.tryParse(targetUrl);
|
||||
if (target == null || target.host.isEmpty || !_isHttpOrWebSocket(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (relayUrl case final configuredRelay?) {
|
||||
final relay = Uri.tryParse(configuredRelay);
|
||||
if (relay != null && _sameOrigin(target, relay)) return true;
|
||||
}
|
||||
|
||||
final host = target.host.toLowerCase();
|
||||
if (host == 'pairing.buzz.xyz' || host.endsWith('.communities.buzz.xyz')) {
|
||||
return target.scheme == 'https' || target.scheme == 'wss';
|
||||
}
|
||||
|
||||
if (allowLocalDevelopment && _isLocalHost(host)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, String> clientHeadersForUrl({
|
||||
required ClientHeaders headers,
|
||||
required String targetUrl,
|
||||
String? relayUrl,
|
||||
bool allowLocalDevelopment = kDebugMode,
|
||||
}) {
|
||||
if (!isFirstPartyBuzzUrl(
|
||||
targetUrl,
|
||||
relayUrl: relayUrl,
|
||||
allowLocalDevelopment: allowLocalDevelopment,
|
||||
)) {
|
||||
return const {};
|
||||
}
|
||||
return headers.values;
|
||||
}
|
||||
|
||||
bool _isHttpOrWebSocket(Uri uri) =>
|
||||
uri.scheme == 'http' ||
|
||||
uri.scheme == 'https' ||
|
||||
uri.scheme == 'ws' ||
|
||||
uri.scheme == 'wss';
|
||||
|
||||
bool _sameOrigin(Uri a, Uri b) {
|
||||
final aSecure = _isSecureScheme(a.scheme);
|
||||
final bSecure = _isSecureScheme(b.scheme);
|
||||
return aSecure != null &&
|
||||
aSecure == bSecure &&
|
||||
a.host.toLowerCase() == b.host.toLowerCase() &&
|
||||
_effectivePort(a) == _effectivePort(b);
|
||||
}
|
||||
|
||||
bool? _isSecureScheme(String scheme) => switch (scheme) {
|
||||
'https' || 'wss' => true,
|
||||
'http' || 'ws' => false,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
int? _effectivePort(Uri uri) {
|
||||
if (uri.hasPort) return uri.port;
|
||||
return switch (uri.scheme) {
|
||||
'https' || 'wss' => 443,
|
||||
'http' || 'ws' => 80,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
bool _isLocalHost(String host) =>
|
||||
host == 'localhost' || host == '127.0.0.1' || host == '::1';
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../client/client_headers.dart';
|
||||
import 'relay_provider.dart';
|
||||
|
||||
const _mediaGetAuthKind = 24242;
|
||||
@@ -15,9 +16,10 @@ const _mediaGetAuthRefreshMarginSeconds = 60;
|
||||
|
||||
/// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs.
|
||||
///
|
||||
/// Returns an empty map for non-relay URLs or when no signing key is available,
|
||||
/// so callers can safely use this on arbitrary profile/custom-emoji URLs without
|
||||
/// leaking Buzz credentials to third-party hosts.
|
||||
/// Returns only first-party client-identification headers when no signing key
|
||||
/// is available. Returns an empty map for non-relay URLs, so callers can safely
|
||||
/// use this on arbitrary profile/custom-emoji URLs without leaking Buzz
|
||||
/// metadata or credentials to third-party hosts.
|
||||
///
|
||||
/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds]
|
||||
/// before expiry: repeated calls return the byte-identical map instead of
|
||||
@@ -27,6 +29,7 @@ const _mediaGetAuthRefreshMarginSeconds = 60;
|
||||
class MediaGetAuthService {
|
||||
final String _baseUrl;
|
||||
final String? _nsec;
|
||||
final ClientHeaders? _clientHeaders;
|
||||
final DateTime Function() _now;
|
||||
|
||||
Map<String, String>? _cachedHeaders;
|
||||
@@ -35,19 +38,30 @@ class MediaGetAuthService {
|
||||
MediaGetAuthService({
|
||||
required String baseUrl,
|
||||
required String? nsec,
|
||||
ClientHeaders? clientHeaders,
|
||||
DateTime Function()? now,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_nsec = nsec,
|
||||
_clientHeaders = clientHeaders,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
Map<String, String> headersFor(String url) {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) return const {};
|
||||
final uri = Uri.tryParse(url);
|
||||
final relayUri = Uri.tryParse(_baseUrl);
|
||||
if (uri == null || relayUri == null) return const {};
|
||||
if (!_isRelayMediaUrl(uri, relayUri)) return const {};
|
||||
|
||||
final clientHeaders = _clientHeaders;
|
||||
final identificationHeaders = clientHeaders == null
|
||||
? const <String, String>{}
|
||||
: clientHeadersForUrl(
|
||||
headers: clientHeaders,
|
||||
targetUrl: url,
|
||||
relayUrl: _baseUrl,
|
||||
);
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) return identificationHeaders;
|
||||
|
||||
final cached = _cachedHeaders;
|
||||
final refreshAt = _refreshAt;
|
||||
if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) {
|
||||
@@ -61,6 +75,7 @@ class MediaGetAuthService {
|
||||
.encode(utf8.encode(authEvent.toJson()))
|
||||
.replaceAll('=', '');
|
||||
final headers = Map<String, String>.unmodifiable({
|
||||
...identificationHeaders,
|
||||
'Authorization': 'Nostr $encoded',
|
||||
});
|
||||
_cachedHeaders = headers;
|
||||
@@ -72,25 +87,16 @@ class MediaGetAuthService {
|
||||
);
|
||||
return headers;
|
||||
} catch (_) {
|
||||
// Read auth is best-effort: while the relay rollout flag is off, an
|
||||
// unsigned fetch still works. Once the flag is on, this request will 403
|
||||
// instead of crashing the widget tree because local key material is bad.
|
||||
return const {};
|
||||
// Read auth is best-effort: preserve first-party identification headers
|
||||
// even when local signing material is invalid.
|
||||
return identificationHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRelayMediaUrl(Uri uri, Uri relayUri) {
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
|
||||
if (uri.host.isEmpty || relayUri.host.isEmpty) return false;
|
||||
// Extract the URL's origin and path. Query strings are ignored for media
|
||||
// host/path detection, matching the fetch target shape used by descriptors.
|
||||
final base = '${uri.scheme}://${uri.authority}';
|
||||
final mediaAuthority = extractServerAuthority(base);
|
||||
final relayAuthority = extractServerAuthority(_baseUrl);
|
||||
if (mediaAuthority == null || relayAuthority == null) return false;
|
||||
if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
if (!_sameHttpOrigin(uri, relayUri)) return false;
|
||||
return uri.path.startsWith('/media/');
|
||||
}
|
||||
|
||||
@@ -121,7 +127,11 @@ class MediaGetAuthService {
|
||||
|
||||
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec);
|
||||
return MediaGetAuthService(
|
||||
baseUrl: config.baseUrl,
|
||||
nsec: config.nsec,
|
||||
clientHeaders: ref.watch(clientHeadersProvider),
|
||||
);
|
||||
});
|
||||
|
||||
Map<String, String> mediaGetHeadersFor(WidgetRef ref, String url) {
|
||||
@@ -145,6 +155,20 @@ String? extractServerAuthority(String baseUrl) {
|
||||
return _normalizeAuthority(authority);
|
||||
}
|
||||
|
||||
bool _sameHttpOrigin(Uri a, Uri b) =>
|
||||
a.scheme.toLowerCase() == b.scheme.toLowerCase() &&
|
||||
a.host.toLowerCase() == b.host.toLowerCase() &&
|
||||
_effectiveHttpPort(a) == _effectiveHttpPort(b);
|
||||
|
||||
int? _effectiveHttpPort(Uri uri) {
|
||||
if (uri.hasPort) return uri.port;
|
||||
return switch (uri.scheme.toLowerCase()) {
|
||||
'https' => 443,
|
||||
'http' => 80,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String _normalizeAuthority(String authority) {
|
||||
var normalized = authority.trim().toLowerCase();
|
||||
if (normalized.endsWith('.')) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
import '../client/client_headers.dart';
|
||||
import 'media_auth.dart';
|
||||
import 'mp4_fast_start.dart';
|
||||
import 'relay_provider.dart';
|
||||
@@ -138,6 +139,7 @@ class MediaUploadService {
|
||||
final TranscodeVideoToMp4 _transcodeVideoToMp4;
|
||||
final ReadClipboardImage _readClipboardImage;
|
||||
final DateTime Function() _now;
|
||||
final ClientHeaders? _clientHeaders;
|
||||
final http.Client _http;
|
||||
final bool _ownsHttpClient;
|
||||
|
||||
@@ -151,6 +153,7 @@ class MediaUploadService {
|
||||
TranscodeVideoToMp4? transcodeVideoToMp4,
|
||||
ReadClipboardImage? readClipboardImage,
|
||||
DateTime Function()? now,
|
||||
ClientHeaders? clientHeaders,
|
||||
http.Client? httpClient,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_nsec = nsec,
|
||||
@@ -162,6 +165,7 @@ class MediaUploadService {
|
||||
_transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4,
|
||||
_readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage,
|
||||
_now = now ?? DateTime.now,
|
||||
_clientHeaders = clientHeaders,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_ownsHttpClient = httpClient == null;
|
||||
|
||||
@@ -297,7 +301,14 @@ class MediaUploadService {
|
||||
required String mimeType,
|
||||
required String sha256,
|
||||
}) {
|
||||
final clientHeaders = _clientHeaders;
|
||||
final headers = <String, String>{
|
||||
if (clientHeaders != null)
|
||||
...clientHeadersForUrl(
|
||||
headers: clientHeaders,
|
||||
targetUrl: _baseUrl,
|
||||
relayUrl: _baseUrl,
|
||||
),
|
||||
'Authorization': _buildUploadAuthHeader(sha256),
|
||||
'Content-Type': mimeType,
|
||||
'X-SHA-256': sha256,
|
||||
@@ -668,6 +679,7 @@ final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: config.baseUrl,
|
||||
nsec: config.nsec,
|
||||
clientHeaders: ref.watch(clientHeadersProvider),
|
||||
pickGalleryImage: () => picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
requestFullMetadata: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export '../client/client_headers.dart';
|
||||
export 'app_lifecycle_provider.dart';
|
||||
export 'media_auth.dart';
|
||||
export 'media_image.dart';
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../auth/auth.dart';
|
||||
import '../client/client_headers.dart';
|
||||
import 'nostr_models.dart';
|
||||
import 'relay_client.dart';
|
||||
import 'relay_provider.dart';
|
||||
@@ -69,15 +70,32 @@ typedef RelaySocketFactory =
|
||||
RelaySocket Function({
|
||||
required String wsUrl,
|
||||
required String? nsec,
|
||||
required Map<String, String> headers,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function() onConnected,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
});
|
||||
|
||||
class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
static RelaySocket _defaultSocketFactory({
|
||||
required String wsUrl,
|
||||
required String? nsec,
|
||||
required Map<String, String> headers,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function() onConnected,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) => RelaySocket(
|
||||
wsUrl: wsUrl,
|
||||
nsec: nsec,
|
||||
headers: headers,
|
||||
onMessage: onMessage,
|
||||
onConnected: onConnected,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
|
||||
RelaySessionNotifier({
|
||||
http.Client? httpClient,
|
||||
RelaySocketFactory socketFactory = RelaySocket.new,
|
||||
RelaySocketFactory socketFactory = _defaultSocketFactory,
|
||||
}) : _httpClient = httpClient,
|
||||
_socketFactory = socketFactory;
|
||||
|
||||
@@ -143,6 +161,11 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
...clientHeadersForUrl(
|
||||
headers: ref.read(clientHeadersProvider),
|
||||
targetUrl: url,
|
||||
relayUrl: config.baseUrl,
|
||||
),
|
||||
'Authorization': buildNip98AuthHeader(
|
||||
method: 'POST',
|
||||
url: url,
|
||||
@@ -361,6 +384,11 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
final socket = _socketFactory(
|
||||
wsUrl: config.wsUrl,
|
||||
nsec: config.nsec,
|
||||
headers: clientHeadersForUrl(
|
||||
headers: ref.read(clientHeadersProvider),
|
||||
targetUrl: config.wsUrl,
|
||||
relayUrl: config.baseUrl,
|
||||
),
|
||||
onMessage: (message) {
|
||||
if (generation == _connectionGeneration) _handleMessage(message);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'nostr_models.dart';
|
||||
@@ -32,6 +33,7 @@ Exception classifyRelayAuthFailure(String message) {
|
||||
class RelaySocket {
|
||||
final String _wsUrl;
|
||||
final String? _nsec;
|
||||
final Map<String, String> _headers;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
final void Function() _onConnected;
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
@@ -48,11 +50,13 @@ class RelaySocket {
|
||||
RelaySocket({
|
||||
required String wsUrl,
|
||||
required String? nsec,
|
||||
Map<String, String> headers = const {},
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function() onConnected,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) : _wsUrl = wsUrl,
|
||||
_nsec = nsec,
|
||||
_headers = Map.unmodifiable(headers),
|
||||
_onMessage = onMessage,
|
||||
_onConnected = onConnected,
|
||||
_onDisconnected = onDisconnected;
|
||||
@@ -63,7 +67,10 @@ class RelaySocket {
|
||||
_state = SocketState.connecting;
|
||||
|
||||
try {
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
Uri.parse(_wsUrl),
|
||||
headers: _headers,
|
||||
);
|
||||
await _channel!.ready;
|
||||
} catch (e) {
|
||||
_state = SocketState.disconnected;
|
||||
|
||||
@@ -289,6 +289,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.2.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
elliptic:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1421,6 +1437,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -32,6 +32,7 @@ dependencies:
|
||||
app_badge_plus: ^1.2.10
|
||||
app_links: ^6.4.0
|
||||
scrollable_positioned_list: ^0.3.8
|
||||
device_info_plus: ^13.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -8,10 +8,17 @@ import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import 'package:buzz/features/invites/invite_join_provider.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/client/client_headers.dart';
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
|
||||
import '../../shared/community/community_storage_test.dart';
|
||||
|
||||
const _clientHeaders = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'Buzz/1.0 (ios; build 1)',
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final existingRelayUrl in [
|
||||
'wss://relay.example.com',
|
||||
@@ -35,6 +42,7 @@ void main() {
|
||||
final auth = _RecordingAuthNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
authProvider.overrideWith(() => auth),
|
||||
inviteKeyGeneratorProvider.overrideWithValue(() {
|
||||
@@ -84,6 +92,7 @@ void main() {
|
||||
final auth = _RecordingAuthNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
authProvider.overrideWith(() => auth),
|
||||
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
|
||||
@@ -129,6 +138,11 @@ void main() {
|
||||
);
|
||||
expect(capturedRequest!.body, jsonEncode({'code': 'code'}));
|
||||
expect(capturedRequest!.headers['Authorization'], startsWith('Nostr '));
|
||||
expect(
|
||||
capturedRequest!.headers['Buzz-Client'],
|
||||
_clientHeaders.buzzClient,
|
||||
);
|
||||
expect(capturedRequest!.headers['User-Agent'], _clientHeaders.userAgent);
|
||||
expect(auth.authenticatedCommunities, hasLength(1));
|
||||
expect(
|
||||
auth.authenticatedCommunities.single.relayUrl,
|
||||
@@ -145,6 +159,7 @@ void main() {
|
||||
final storage = CommunityStorage(secure: FakeSecureStorage());
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
|
||||
inviteJoinHttpClientProvider.overrideWithValue(
|
||||
@@ -191,6 +206,7 @@ void main() {
|
||||
final auth = _RecordingAuthNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
authProvider.overrideWith(() => auth),
|
||||
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||||
import 'package:buzz/features/pairing/pairing_socket.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/client/client_headers.dart';
|
||||
|
||||
/// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and
|
||||
/// SSRF-prevention validation.
|
||||
@@ -23,6 +24,12 @@ import 'package:buzz/shared/auth/auth.dart';
|
||||
/// wrong shape (non-object, missing fields, missing nsec), and SSRF
|
||||
/// guards (private IPs, non-http schemes).
|
||||
/// - `reset()` returning to idle from an error state.
|
||||
const _clientHeaders = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'Buzz/1.0 (ios; build 1)',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('PairingNotifier', () {
|
||||
late ProviderContainer container;
|
||||
@@ -31,7 +38,10 @@ void main() {
|
||||
ProviderContainer createContainer() {
|
||||
fakeAuth = FakeAuthNotifier();
|
||||
return ProviderContainer(
|
||||
overrides: [authProvider.overrideWith(() => fakeAuth)],
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
authProvider.overrideWith(() => fakeAuth),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,12 +62,16 @@ void main() {
|
||||
({
|
||||
required wsUrl,
|
||||
required ephemeralPrivkey,
|
||||
required headers,
|
||||
required onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) => _DisconnectingSocket(disconnectCallback: onDisconnected),
|
||||
);
|
||||
container = ProviderContainer(
|
||||
overrides: [pairingProvider.overrideWith(() => notifier)],
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
pairingProvider.overrideWith(() => notifier),
|
||||
],
|
||||
);
|
||||
const code =
|
||||
'nostrpair://62287897da61e3fa294b4570575f7db8bea147d6631150f2e4656714c645fb1e'
|
||||
|
||||
@@ -110,13 +110,35 @@ void main() {
|
||||
expect(socket.isConnected, isFalse);
|
||||
});
|
||||
|
||||
test('passes identification headers to the WebSocket handshake', () async {
|
||||
Map<String, String>? capturedHeaders;
|
||||
final channel = _ControlledWebSocketChannel();
|
||||
final socket = PairingSocket(
|
||||
wsUrl: 'ws://unused',
|
||||
ephemeralPrivkey: _privateKey,
|
||||
headers: const {'Buzz-Client': 'client', 'User-Agent': 'agent'},
|
||||
onMessage: (_) {},
|
||||
onDisconnected: (_) {},
|
||||
authChallengeTimeout: Duration.zero,
|
||||
channelFactory: (_, headers) {
|
||||
capturedHeaders = headers;
|
||||
return channel;
|
||||
},
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await socket.connect();
|
||||
|
||||
expect(capturedHeaders, {'Buzz-Client': 'client', 'User-Agent': 'agent'});
|
||||
});
|
||||
|
||||
test('notifies once when a connected stream emits an error', () async {
|
||||
var disconnectCount = 0;
|
||||
final channel = _ControlledWebSocketChannel();
|
||||
final socket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => channel,
|
||||
channelFactory: (_, _) => channel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
@@ -134,7 +156,7 @@ void main() {
|
||||
final socket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => channel,
|
||||
channelFactory: (_, _) => channel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
@@ -153,7 +175,7 @@ void main() {
|
||||
final disconnectingSocket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => disconnectChannel,
|
||||
channelFactory: (_, _) => disconnectChannel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
await disconnectingSocket.connect();
|
||||
@@ -164,7 +186,7 @@ void main() {
|
||||
final disposingSocket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => disposeChannel,
|
||||
channelFactory: (_, _) => disposeChannel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
await disposingSocket.connect();
|
||||
@@ -183,7 +205,7 @@ PairingSocket _socket(
|
||||
Duration authChallengeTimeout = const Duration(milliseconds: 500),
|
||||
Duration authResponseTimeout = const Duration(seconds: 10),
|
||||
void Function(Object? error)? onDisconnected,
|
||||
WebSocketChannel Function(Uri uri)? channelFactory,
|
||||
PairingChannelFactory? channelFactory,
|
||||
}) => PairingSocket(
|
||||
wsUrl: url,
|
||||
ephemeralPrivkey: _privateKey,
|
||||
@@ -191,7 +213,8 @@ PairingSocket _socket(
|
||||
onDisconnected: onDisconnected ?? (_) {},
|
||||
authChallengeTimeout: authChallengeTimeout,
|
||||
authResponseTimeout: authResponseTimeout,
|
||||
channelFactory: channelFactory ?? WebSocketChannel.connect,
|
||||
channelFactory:
|
||||
channelFactory ?? (uri, headers) => WebSocketChannel.connect(uri),
|
||||
);
|
||||
|
||||
class _ControlledWebSocketChannel implements WebSocketChannel {
|
||||
|
||||
164
mobile/test/shared/client/client_headers_test.dart
Normal file
164
mobile/test/shared/client/client_headers_test.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:buzz/shared/client/client_headers.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('buildClientHeaders', () {
|
||||
test('builds canonical iOS headers and escapes structured strings', () {
|
||||
final headers = buildClientHeaders(
|
||||
const ClientHeaderMetadata(
|
||||
platform: 'ios',
|
||||
appVersion: '0.4.5',
|
||||
appBuild: '6',
|
||||
osVersion: '18.5"preview',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
headers.buzzClient,
|
||||
r'v=1, app=buzz-mobile, platform=ios, app-version="0.4.5", '
|
||||
r'app-build="6", os-version="18.5\"preview"',
|
||||
);
|
||||
expect(headers.appVersion, '0.4.5');
|
||||
expect(headers.userAgent, 'Buzz/0.4.5 (ios; build 6)');
|
||||
});
|
||||
|
||||
test('builds canonical Android headers with the API level', () {
|
||||
final headers = buildClientHeaders(
|
||||
const ClientHeaderMetadata(
|
||||
platform: 'android',
|
||||
appVersion: '0.4.5',
|
||||
appBuild: '6',
|
||||
osVersion: '15',
|
||||
osApi: 35,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
headers.buzzClient,
|
||||
'v=1, app=buzz-mobile, platform=android, '
|
||||
'app-version="0.4.5", app-build="6", os-version="15", os-api=35',
|
||||
);
|
||||
expect(headers.userAgent, 'Buzz/0.4.5 (android; build 6)');
|
||||
});
|
||||
|
||||
test('does not emit partially initialized header pairs', () {
|
||||
expect(
|
||||
const ClientHeaders(
|
||||
appVersion: '',
|
||||
buzzClient: '',
|
||||
userAgent: '',
|
||||
).values,
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
() => const ClientHeaders(
|
||||
appVersion: '1',
|
||||
buzzClient: 'client',
|
||||
userAgent: '',
|
||||
).values,
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-decimal app builds', () {
|
||||
for (final build in ['', '1.beta', r'6\beta']) {
|
||||
expect(
|
||||
() => buildClientHeaders(
|
||||
ClientHeaderMetadata(
|
||||
platform: 'ios',
|
||||
appVersion: '1.0',
|
||||
appBuild: build,
|
||||
osVersion: '18.0',
|
||||
),
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: build,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid platform-specific metadata', () {
|
||||
for (final osApi in [null, 0, -1]) {
|
||||
expect(
|
||||
() => buildClientHeaders(
|
||||
ClientHeaderMetadata(
|
||||
platform: 'android',
|
||||
appVersion: '1.0',
|
||||
appBuild: '1',
|
||||
osVersion: '15',
|
||||
osApi: osApi,
|
||||
),
|
||||
),
|
||||
throwsArgumentError,
|
||||
reason: '$osApi',
|
||||
);
|
||||
}
|
||||
expect(
|
||||
() => buildClientHeaders(
|
||||
const ClientHeaderMetadata(
|
||||
platform: 'ios',
|
||||
appVersion: '1.0',
|
||||
appBuild: '1',
|
||||
osVersion: '18.0',
|
||||
osApi: 35,
|
||||
),
|
||||
),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('first-party origin gating', () {
|
||||
const headers = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'client',
|
||||
userAgent: 'agent',
|
||||
);
|
||||
|
||||
test('allows the configured relay across HTTP and WebSocket schemes', () {
|
||||
expect(
|
||||
clientHeadersForUrl(
|
||||
headers: headers,
|
||||
targetUrl: 'wss://relay.example:443/socket',
|
||||
relayUrl: 'https://Relay.Example/base',
|
||||
allowLocalDevelopment: false,
|
||||
),
|
||||
{'Buzz-Client': 'client', 'User-Agent': 'agent'},
|
||||
);
|
||||
});
|
||||
|
||||
test('allows hosted communities and the dedicated pairing relay', () {
|
||||
for (final url in [
|
||||
'wss://acme.communities.buzz.xyz',
|
||||
'https://PAIRING.BUZZ.XYZ/path',
|
||||
]) {
|
||||
expect(
|
||||
isFirstPartyBuzzUrl(url, allowLocalDevelopment: false),
|
||||
isTrue,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects lookalikes, third-party hosts, and insecure hosted URLs', () {
|
||||
for (final url in [
|
||||
'wss://communities.buzz.xyz.evil.example',
|
||||
'wss://evil.example',
|
||||
'ws://pairing.buzz.xyz',
|
||||
'https://acme.communities.buzz.xyz.evil.example',
|
||||
'wss://relay.example:444/socket',
|
||||
]) {
|
||||
expect(
|
||||
clientHeadersForUrl(
|
||||
headers: headers,
|
||||
targetUrl: url,
|
||||
relayUrl: 'https://relay.example',
|
||||
allowLocalDevelopment: false,
|
||||
),
|
||||
isEmpty,
|
||||
reason: url,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/shared/client/client_headers.dart';
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
@@ -18,8 +19,16 @@ final _pngBytes = base64Decode(
|
||||
const _relayBase = 'https://relay.example.com';
|
||||
const _mediaUrl = '$_relayBase/media/abc123.png';
|
||||
|
||||
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
|
||||
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
|
||||
MediaGetAuthService _auth({
|
||||
String? nsec,
|
||||
DateTime Function()? now,
|
||||
ClientHeaders? clientHeaders,
|
||||
}) => MediaGetAuthService(
|
||||
baseUrl: _relayBase,
|
||||
nsec: nsec,
|
||||
now: now,
|
||||
clientHeaders: clientHeaders,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -57,10 +66,43 @@ void main() {
|
||||
expect(refreshed['Authorization'], isNot(first['Authorization']));
|
||||
});
|
||||
|
||||
test('sends identification only for relay media URLs', () {
|
||||
const clientHeaders = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'test-agent',
|
||||
);
|
||||
final auth = _auth(
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
clientHeaders: clientHeaders,
|
||||
);
|
||||
|
||||
expect(
|
||||
auth.headersFor(_mediaUrl),
|
||||
containsPair('Buzz-Client', 'test-client'),
|
||||
);
|
||||
expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty);
|
||||
});
|
||||
|
||||
test('sends identification without a signing key', () {
|
||||
const clientHeaders = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'test-agent',
|
||||
);
|
||||
final auth = _auth(clientHeaders: clientHeaders);
|
||||
|
||||
expect(auth.headersFor(_mediaUrl), clientHeaders.values);
|
||||
});
|
||||
|
||||
test('non-relay URLs get no headers even with a key', () {
|
||||
final nsec = nostr.Keys.generate().nsec;
|
||||
final auth = _auth(nsec: nsec);
|
||||
expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty);
|
||||
expect(
|
||||
auth.headersFor('http://relay.example.com/media/abc.png'),
|
||||
isEmpty,
|
||||
);
|
||||
expect(auth.headersFor('$_relayBase/not-media/abc.png'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:buzz/shared/client/client_headers.dart';
|
||||
import 'package:buzz/shared/relay/media_auth.dart';
|
||||
import 'package:buzz/shared/relay/media_upload.dart';
|
||||
|
||||
@@ -346,6 +347,11 @@ void main() {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example:8443',
|
||||
nsec: nsec,
|
||||
clientHeaders: const ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'test-agent',
|
||||
),
|
||||
httpClient: client,
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async =>
|
||||
@@ -364,6 +370,8 @@ void main() {
|
||||
);
|
||||
expect(capturedRequest!.headers['Content-Type'], 'image/png');
|
||||
expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty);
|
||||
expect(capturedRequest!.headers['Buzz-Client'], 'test-client');
|
||||
expect(capturedRequest!.headers['User-Agent'], 'test-agent');
|
||||
expect(capturedRequest!.bodyBytes, _pngBytes);
|
||||
|
||||
final authHeader = capturedRequest!.headers['Authorization'];
|
||||
|
||||
@@ -10,6 +10,12 @@ import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:buzz/shared/auth/auth_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
const _clientHeaders = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'Buzz/1.0 (ios; build 1)',
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('queryRelay sends NIP-98 auth over POST /query', () async {
|
||||
final keychain = nostr.Keys.generate();
|
||||
@@ -22,6 +28,7 @@ void main() {
|
||||
final session = RelaySessionNotifier(httpClient: client);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
@@ -52,6 +59,8 @@ void main() {
|
||||
expect(capturedRequest!.method, 'POST');
|
||||
expect(capturedRequest!.url.toString(), 'https://relay.example/query');
|
||||
expect(capturedRequest!.headers['Content-Type'], 'application/json');
|
||||
expect(capturedRequest!.headers['Buzz-Client'], _clientHeaders.buzzClient);
|
||||
expect(capturedRequest!.headers['User-Agent'], _clientHeaders.userAgent);
|
||||
expect(jsonDecode(capturedRequest!.body), [filter.toJson()]);
|
||||
|
||||
final authHeader = capturedRequest!.headers['Authorization'];
|
||||
@@ -88,6 +97,7 @@ void main() {
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
@@ -174,6 +184,7 @@ void main() {
|
||||
final auth = _FakeAuthNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
authProvider.overrideWith(() => auth),
|
||||
],
|
||||
@@ -199,6 +210,7 @@ void main() {
|
||||
({
|
||||
required wsUrl,
|
||||
required nsec,
|
||||
required headers,
|
||||
required onMessage,
|
||||
required onConnected,
|
||||
required onDisconnected,
|
||||
@@ -206,6 +218,7 @@ void main() {
|
||||
final socket = _ControlledRelaySocket(
|
||||
wsUrl: wsUrl,
|
||||
nsec: nsec,
|
||||
headers: headers,
|
||||
onMessage: onMessage,
|
||||
onConnected: onConnected,
|
||||
onDisconnected: onDisconnected,
|
||||
@@ -220,6 +233,7 @@ void main() {
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(() => config),
|
||||
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
|
||||
@@ -230,6 +244,11 @@ void main() {
|
||||
final subscription = container.listen(relaySessionProvider, (_, _) {});
|
||||
addTearDown(subscription.close);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sockets, hasLength(1));
|
||||
expect(sockets.single.capturedHeaders, {
|
||||
'Buzz-Client': _clientHeaders.buzzClient,
|
||||
'User-Agent': _clientHeaders.userAgent,
|
||||
});
|
||||
|
||||
config.update(baseUrl: 'https://new.example', nsec: keychain.nsec);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
@@ -372,14 +391,17 @@ class _AuthenticatedAuthNotifier extends AuthNotifier {
|
||||
class _ControlledRelaySocket extends RelaySocket {
|
||||
final void Function() _connected;
|
||||
final void Function(Object? error) _disconnected;
|
||||
final Map<String, String> capturedHeaders;
|
||||
|
||||
_ControlledRelaySocket({
|
||||
required super.wsUrl,
|
||||
required super.nsec,
|
||||
required super.headers,
|
||||
required super.onMessage,
|
||||
required super.onConnected,
|
||||
required super.onDisconnected,
|
||||
}) : _connected = onConnected,
|
||||
}) : capturedHeaders = headers,
|
||||
_connected = onConnected,
|
||||
_disconnected = onDisconnected;
|
||||
|
||||
@override
|
||||
|
||||
@@ -3,8 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:buzz/app.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/client/client_headers.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
|
||||
const _clientHeaders = ClientHeaders(
|
||||
appVersion: '1.0',
|
||||
buzzClient: 'test-client',
|
||||
userAgent: 'Buzz/1.0 (ios; build 1)',
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('App renders pairing page when unauthenticated', (
|
||||
WidgetTester tester,
|
||||
@@ -16,6 +23,7 @@ void main() {
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _FakeAuthNotifier()),
|
||||
clientHeadersProvider.overrideWithValue(_clientHeaders),
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: const App(),
|
||||
|
||||
Reference in New Issue
Block a user