Skip to content

Commit 400df8b

Browse files
committed
fix(relay): address code review findings on bridge custom filters
Return 400 when before_id is sent without until instead of silently dropping it. Add handled-index guard to depth_limit loop to prevent double-processing. Extract event_in_accessible_channel helper to replace three inline copies. Normalize agent_activity to activity to avoid duplicate DB queries, enforce aggregate feed limit across types, replace magic numbers with named constants, and add unit tests for all extractor functions.
1 parent f87aa39 commit 400df8b

1 file changed

Lines changed: 207 additions & 21 deletions

File tree

crates/sprout-relay/src/api/bridge.rs

Lines changed: 207 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ fn extract_channel_from_filter(filter: &nostr::Filter) -> Option<uuid::Uuid> {
124124
// Nostr filter JSON. nostr::Filter silently drops unknown fields during
125125
// deserialization, so we extract them from the raw JSON Value first.
126126

127+
const BRIDGE_FEED_MAX_LIMIT: i64 = 100;
128+
const BRIDGE_THREAD_MAX_LIMIT: u32 = 500;
129+
127130
fn extract_before_id(raw: &Value) -> Option<Vec<u8>> {
128131
let hex_str = raw.get("before_id")?.as_str()?;
129132
if hex_str.len() == 64 {
@@ -152,6 +155,13 @@ fn extract_feed_types(raw: &Value) -> Option<Vec<String>> {
152155
}
153156
}
154157

158+
fn event_in_accessible_channel(se: &sprout_core::StoredEvent, accessible: &[uuid::Uuid]) -> bool {
159+
match se.channel_id {
160+
Some(ch_id) => accessible.contains(&ch_id),
161+
None => true,
162+
}
163+
}
164+
155165
// ── POST /events ─────────────────────────────────────────────────────────────
156166

157167
/// Submit a signed Nostr event via HTTP bridge (NIP-98 auth).
@@ -274,27 +284,44 @@ pub async fn query_events(
274284
None => continue,
275285
};
276286

277-
let limit = filter.limit.map(|l| (l as i64).min(100)).unwrap_or(20);
287+
let limit = filter
288+
.limit
289+
.map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT))
290+
.unwrap_or(20);
278291
let since = filter
279292
.since
280293
.and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0));
281294

295+
let mut seen_types = std::collections::HashSet::new();
282296
let mut seen = std::collections::HashSet::new();
297+
let mut feed_count = 0i64;
283298
for feed_type in &feed_types {
284-
let type_events = match feed_type.as_str() {
299+
let canonical = if feed_type == "agent_activity" {
300+
"activity"
301+
} else {
302+
feed_type.as_str()
303+
};
304+
if !seen_types.insert(canonical) {
305+
continue;
306+
}
307+
if feed_count >= limit {
308+
break;
309+
}
310+
let remaining = limit - feed_count;
311+
let type_events = match canonical {
285312
"mentions" => state
286313
.db
287-
.query_feed_mentions(&pubkey_bytes, &accessible_channels, since, limit)
314+
.query_feed_mentions(&pubkey_bytes, &accessible_channels, since, remaining)
288315
.await
289316
.map_err(|e| internal_error(&format!("feed mentions error: {e}")))?,
290317
"needs_action" => state
291318
.db
292-
.query_feed_needs_action(&pubkey_bytes, &accessible_channels, since, limit)
319+
.query_feed_needs_action(&pubkey_bytes, &accessible_channels, since, remaining)
293320
.await
294321
.map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?,
295-
"activity" | "agent_activity" => state
322+
"activity" => state
296323
.db
297-
.query_feed_activity(&accessible_channels, since, limit)
324+
.query_feed_activity(&accessible_channels, since, remaining)
298325
.await
299326
.map_err(|e| internal_error(&format!("feed activity error: {e}")))?,
300327
_ => continue,
@@ -303,13 +330,12 @@ pub async fn query_events(
303330
if !seen.insert(se.event.id) {
304331
continue;
305332
}
306-
if let Some(ch_id) = se.channel_id {
307-
if !accessible_channels.contains(&ch_id) {
308-
continue;
309-
}
333+
if !event_in_accessible_channel(&se, &accessible_channels) {
334+
continue;
310335
}
311336
if let Ok(v) = serde_json::to_value(&se.event) {
312337
events.push(v);
338+
feed_count += 1;
313339
}
314340
}
315341
}
@@ -319,6 +345,9 @@ pub async fn query_events(
319345
// ── depth_limit: route thread queries to get_thread_replies ──
320346
let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E);
321347
for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() {
348+
if handled.contains(&idx) {
349+
continue;
350+
}
322351
let depth = match extract_depth_limit(raw) {
323352
Some(d) => d,
324353
None => continue,
@@ -343,7 +372,10 @@ pub async fn query_events(
343372
}
344373
}
345374

346-
let limit = filter.limit.unwrap_or(100).min(500) as u32;
375+
let limit = filter
376+
.limit
377+
.unwrap_or(100)
378+
.min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32;
347379
let thread_replies = state
348380
.db
349381
.get_thread_replies(&root_bytes, Some(depth), limit, None)
@@ -360,10 +392,8 @@ pub async fn query_events(
360392
.await
361393
.map_err(|e| internal_error(&format!("thread fetch error: {e}")))?;
362394
for se in stored {
363-
if let Some(ch_id) = se.channel_id {
364-
if !accessible_channels.contains(&ch_id) {
365-
continue;
366-
}
395+
if !event_in_accessible_channel(&se, &accessible_channels) {
396+
continue;
367397
}
368398
if let Ok(v) = serde_json::to_value(&se.event) {
369399
events.push(v);
@@ -390,18 +420,20 @@ pub async fn query_events(
390420
.await;
391421

392422
if let Some(bid) = extract_before_id(raw) {
393-
if query.until.is_some() {
394-
query.before_id = Some(bid);
423+
if query.until.is_none() {
424+
return Err(api_error(
425+
StatusCode::BAD_REQUEST,
426+
"before_id requires until to be set",
427+
));
395428
}
429+
query.before_id = Some(bid);
396430
}
397431

398432
match state.db.query_events(&query).await {
399433
Ok(stored_events) => {
400434
for se in stored_events {
401-
if let Some(ch_id) = se.channel_id {
402-
if !accessible_channels.contains(&ch_id) {
403-
continue;
404-
}
435+
if !event_in_accessible_channel(&se, &accessible_channels) {
436+
continue;
405437
}
406438
if !sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) {
407439
continue;
@@ -1030,4 +1062,158 @@ mod tests {
10301062
"channel-scoped hit must be accepted when caller has access to that channel"
10311063
);
10321064
}
1065+
1066+
// ── Custom filter field extractor tests ──
1067+
1068+
#[test]
1069+
fn extract_before_id_valid_hex() {
1070+
let hex = "a".repeat(64);
1071+
let raw = serde_json::json!({ "before_id": hex });
1072+
let result = extract_before_id(&raw);
1073+
assert!(result.is_some());
1074+
assert_eq!(result.unwrap().len(), 32);
1075+
}
1076+
1077+
#[test]
1078+
fn extract_before_id_short_hex() {
1079+
let raw = serde_json::json!({ "before_id": "a".repeat(63) });
1080+
assert!(extract_before_id(&raw).is_none());
1081+
}
1082+
1083+
#[test]
1084+
fn extract_before_id_long_hex() {
1085+
let raw = serde_json::json!({ "before_id": "a".repeat(65) });
1086+
assert!(extract_before_id(&raw).is_none());
1087+
}
1088+
1089+
#[test]
1090+
fn extract_before_id_invalid_hex_chars() {
1091+
let raw = serde_json::json!({ "before_id": "z".repeat(64) });
1092+
assert!(extract_before_id(&raw).is_none());
1093+
}
1094+
1095+
#[test]
1096+
fn extract_before_id_absent() {
1097+
let raw = serde_json::json!({});
1098+
assert!(extract_before_id(&raw).is_none());
1099+
}
1100+
1101+
#[test]
1102+
fn extract_before_id_non_string() {
1103+
let raw = serde_json::json!({ "before_id": 12345 });
1104+
assert!(extract_before_id(&raw).is_none());
1105+
}
1106+
1107+
#[test]
1108+
fn extract_depth_limit_valid() {
1109+
let raw = serde_json::json!({ "depth_limit": 3 });
1110+
assert_eq!(extract_depth_limit(&raw), Some(3));
1111+
}
1112+
1113+
#[test]
1114+
fn extract_depth_limit_zero() {
1115+
let raw = serde_json::json!({ "depth_limit": 0 });
1116+
assert_eq!(extract_depth_limit(&raw), Some(0));
1117+
}
1118+
1119+
#[test]
1120+
fn extract_depth_limit_u32_max() {
1121+
let raw = serde_json::json!({ "depth_limit": u32::MAX });
1122+
assert_eq!(extract_depth_limit(&raw), Some(u32::MAX));
1123+
}
1124+
1125+
#[test]
1126+
fn extract_depth_limit_overflow() {
1127+
let raw = serde_json::json!({ "depth_limit": (u32::MAX as u64) + 1 });
1128+
assert!(extract_depth_limit(&raw).is_none());
1129+
}
1130+
1131+
#[test]
1132+
fn extract_depth_limit_negative() {
1133+
let raw = serde_json::json!({ "depth_limit": -1 });
1134+
assert!(extract_depth_limit(&raw).is_none());
1135+
}
1136+
1137+
#[test]
1138+
fn extract_depth_limit_absent() {
1139+
let raw = serde_json::json!({});
1140+
assert!(extract_depth_limit(&raw).is_none());
1141+
}
1142+
1143+
#[test]
1144+
fn extract_depth_limit_float() {
1145+
let raw = serde_json::json!({ "depth_limit": 3.5 });
1146+
assert!(extract_depth_limit(&raw).is_none());
1147+
}
1148+
1149+
#[test]
1150+
fn extract_feed_types_valid() {
1151+
let raw = serde_json::json!({ "feed_types": ["mentions", "activity"] });
1152+
assert_eq!(
1153+
extract_feed_types(&raw),
1154+
Some(vec!["mentions".to_string(), "activity".to_string()])
1155+
);
1156+
}
1157+
1158+
#[test]
1159+
fn extract_feed_types_empty_array() {
1160+
let raw = serde_json::json!({ "feed_types": [] });
1161+
assert!(extract_feed_types(&raw).is_none());
1162+
}
1163+
1164+
#[test]
1165+
fn extract_feed_types_mixed_types() {
1166+
let raw = serde_json::json!({ "feed_types": ["mentions", 42, "activity"] });
1167+
assert_eq!(
1168+
extract_feed_types(&raw),
1169+
Some(vec!["mentions".to_string(), "activity".to_string()])
1170+
);
1171+
}
1172+
1173+
#[test]
1174+
fn extract_feed_types_absent() {
1175+
let raw = serde_json::json!({});
1176+
assert!(extract_feed_types(&raw).is_none());
1177+
}
1178+
1179+
#[test]
1180+
fn extract_feed_types_non_array() {
1181+
let raw = serde_json::json!({ "feed_types": "mentions" });
1182+
assert!(extract_feed_types(&raw).is_none());
1183+
}
1184+
1185+
#[test]
1186+
fn event_accessible_no_channel() {
1187+
let keys = Keys::generate();
1188+
let ev = EventBuilder::new(Kind::Custom(1), "test")
1189+
.sign_with_keys(&keys)
1190+
.unwrap();
1191+
let se = sprout_core::StoredEvent::new(ev, None);
1192+
assert!(event_in_accessible_channel(&se, &[]));
1193+
}
1194+
1195+
#[test]
1196+
fn event_accessible_matching_channel() {
1197+
let keys = Keys::generate();
1198+
let ev = EventBuilder::new(Kind::Custom(1), "test")
1199+
.sign_with_keys(&keys)
1200+
.unwrap();
1201+
let ch = uuid::Uuid::new_v4();
1202+
let mut se = sprout_core::StoredEvent::new(ev, None);
1203+
se.channel_id = Some(ch);
1204+
assert!(event_in_accessible_channel(&se, &[ch]));
1205+
}
1206+
1207+
#[test]
1208+
fn event_inaccessible_channel() {
1209+
let keys = Keys::generate();
1210+
let ev = EventBuilder::new(Kind::Custom(1), "test")
1211+
.sign_with_keys(&keys)
1212+
.unwrap();
1213+
let ch = uuid::Uuid::new_v4();
1214+
let other = uuid::Uuid::new_v4();
1215+
let mut se = sprout_core::StoredEvent::new(ev, None);
1216+
se.channel_id = Some(ch);
1217+
assert!(!event_in_accessible_channel(&se, &[other]));
1218+
}
10331219
}

0 commit comments

Comments
 (0)