1use crate::server::nostr::wire::{self, Event, Filter};
3use anyhow::{ensure, Context, Result};
4use serde_json::{json, Map, Value};
5pub const MAX_DEPTH: usize = 8;
6pub const MAX_NODES: usize = 25000;
7pub const MAX_RETAINED_BYTES: usize = 2 * 1024 * 1024;
8pub const MAX_LIST: usize = 500;
9pub const MAX_REASON: usize = 4096;
10pub const MAX_TAG_ITEMS: usize = 32;
11pub const MAX_TEXT: usize = 64 * 1024;
12
13pub fn within_budget(value: &Value) -> bool {
14 crate::utils::json_budget::within_budget(value, MAX_RETAINED_BYTES, MAX_NODES, MAX_DEPTH)
15}
16fn fields(value: &Value, names: &[&str]) -> Result<()> {
17 ensure!(
18 value
19 .as_object()
20 .context("Nostr action must be object")?
21 .keys()
22 .all(|key| names.contains(&key.as_str())),
23 "unsupported Nostr action field"
24 );
25 Ok(())
26}
27pub fn subid(value: &Value) -> Result<String> {
28 let id = value
29 .as_str()
30 .context("Nostr subscription id must be string")?;
31 ensure!(
32 !id.is_empty() && id.chars().count() <= wire::MAX_SUBSCRIPTION_ID_CHARS,
33 "Nostr subscription id must be1..64 characters"
34 );
35 Ok(id.into())
36}
37fn lower_hex(value: &str, length: usize) -> bool {
38 value.len() == length
39 && value
40 .bytes()
41 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
42}
43pub enum Action {
44 Publish {
45 kind: u64,
46 content: String,
47 tags: Vec<Vec<String>>,
48 created_at: u64,
49 },
50 Subscribe {
51 id: String,
52 filters: Vec<Filter>,
53 },
54 Close(String),
55 RelayInfo,
56 Disconnect,
57}
58pub fn action(value: &Value) -> Result<Action> {
59 ensure!(
60 within_budget(value),
61 "Nostr action depth/node/retained-content limit"
62 );
63 match value["type"]
64 .as_str()
65 .context("Nostr action type required")?
66 {
67 "nostr_publish" => {
68 fields(value, &["type", "kind", "content", "tags", "created_at"])?;
69 let kind = value["kind"]
70 .as_u64()
71 .context("Nostr kind must be integer")?;
72 ensure!(kind <= wire::MAX_KIND, "Nostr kind must be0..65535");
73 let content = value["content"]
74 .as_str()
75 .context("Nostr content must be string")?;
76 ensure!(content.len() <= MAX_TEXT, "Nostr content limit");
77 let created_at = value
78 .get("created_at")
79 .map(|v| {
80 v.as_u64()
81 .context("Nostr created_at must be nonnegative integer")
82 })
83 .transpose()?
84 .unwrap_or_else(wire::now_secs);
85 let mut tags = Vec::new();
86 if let Some(value) = value.get("tags") {
87 let array = value.as_array().context("Nostr tags must be array")?;
88 ensure!(array.len() <= wire::MAX_TAGS, "Nostr tag count limit");
89 for tag in array {
90 let tag = tag.as_array().context("Nostr tag must be string array")?;
91 ensure!(
92 !tag.is_empty() && tag.len() <= MAX_TAG_ITEMS,
93 "Nostr tag item limit"
94 );
95 tags.push(
96 tag.iter()
97 .map(|item| {
98 let text =
99 item.as_str().context("Nostr tag item must be string")?;
100 ensure!(text.len() <= MAX_REASON, "Nostr tag text limit");
101 Ok(text.into())
102 })
103 .collect::<Result<Vec<String>>>()?,
104 );
105 }
106 }
107 Ok(Action::Publish {
108 kind,
109 content: content.into(),
110 tags,
111 created_at,
112 })
113 }
114 "nostr_subscribe" => {
115 fields(value, &["type", "subscription_id", "filters"])?;
116 let id = subid(&value["subscription_id"])?;
117 let values = value["filters"]
118 .as_array()
119 .context("Nostr filters must be array")?;
120 ensure!(
121 !values.is_empty() && values.len() <= wire::MAX_FILTERS,
122 "Nostr filter count must be1..10"
123 );
124 let filters = values.iter().map(filter).collect::<Result<Vec<_>>>()?;
125 let mut frame = vec![json!("REQ"), json!(id)];
126 frame.extend(filters.iter().map(|f| f.raw.clone()));
127 frame_text(Value::Array(frame))?;
128 Ok(Action::Subscribe { id, filters })
129 }
130 "nostr_close" => {
131 fields(value, &["type", "subscription_id"])?;
132 Ok(Action::Close(subid(&value["subscription_id"])?))
133 }
134 "nostr_relay_info" => {
135 fields(value, &["type"])?;
136 Ok(Action::RelayInfo)
137 }
138 "disconnect" => {
139 fields(value, &["type"])?;
140 Ok(Action::Disconnect)
141 }
142 _ => anyhow::bail!("unsupported selected Nostr action"),
143 }
144}
145pub fn filter(value: &Value) -> Result<Filter> {
146 ensure!(within_budget(value), "Nostr filter budget");
147 let fields = value.as_object().context("Nostr filter must be object")?;
148 ensure!(fields.len() <= 64, "Nostr filter field limit");
149 for (name, value) in fields {
150 if matches!(name.as_str(), "since" | "until" | "limit") {
151 let n = value
152 .as_u64()
153 .context("Nostr filter scalar must be nonnegative integer")?;
154 ensure!(
155 name != "limit" || n <= MAX_LIST as u64,
156 "Nostr initial limit must be0..500"
157 );
158 } else {
159 ensure!(
160 matches!(name.as_str(), "ids" | "authors" | "kinds")
161 || name.len() == 2
162 && name.starts_with('#')
163 && name.as_bytes()[1].is_ascii_alphabetic(),
164 "unsupported Nostr filter extension"
165 );
166 let list = value
167 .as_array()
168 .context("Nostr filter list must be array")?;
169 ensure!(
170 !list.is_empty() && list.len() <= MAX_LIST,
171 "Nostr filter list must have1..500 values"
172 );
173 if name != "kinds" {
174 for v in list {
175 let text = v.as_str().context("Nostr filter value must be string")?;
176 ensure!(text.len() <= MAX_REASON, "Nostr filter text limit");
177 if matches!(name.as_str(), "#e" | "#p") {
178 ensure!(
179 lower_hex(text, 64),
180 "Nostr reference filter requires full lowercase hex"
181 );
182 }
183 }
184 }
185 }
186 }
187 wire::parse_filter(value).map_err(|_| anyhow::anyhow!("invalid NIP-01 filter"))
188}
189pub fn frame_text(frame: Value) -> Result<String> {
190 if !within_budget(&frame) {
191 crate::utils::json_budget::drop_iteratively(frame);
192 anyhow::bail!("Nostr frame depth/node/retained-content limit");
193 }
194 let text = serde_json::to_string(&frame)?;
195 ensure!(
196 text.len() <= wire::MAX_MESSAGE_BYTES,
197 "Nostr message byte limit"
198 );
199 Ok(text)
200}
201pub fn json(text: &[u8]) -> Result<Value> {
202 ensure!(
203 text.len() <= wire::MAX_MESSAGE_BYTES,
204 "Nostr message byte limit"
205 );
206 let value: Value = serde_json::from_slice(text).context("invalid Nostr JSON")?;
207 if !within_budget(&value) {
208 crate::utils::json_budget::drop_iteratively(value);
209 anyhow::bail!("Nostr JSON depth/node/retained-content limit");
210 }
211 Ok(value)
212}
213pub enum RelayMessage {
214 Event {
215 id: String,
216 event: Event,
217 },
218 Ok {
219 id: String,
220 accepted: bool,
221 message: String,
222 },
223 Eose(String),
224 Closed {
225 id: String,
226 message: String,
227 },
228 Notice(String),
229 Unsupported(&'static str),
230}
231fn reason(value: &Value) -> Result<String> {
232 let text = value
233 .as_str()
234 .context("Nostr relay reason must be string")?;
235 ensure!(text.len() <= MAX_REASON, "Nostr relay reason limit");
236 Ok(text.into())
237}
238pub fn relay(text: &str) -> Result<RelayMessage> {
239 let value = json(text.as_bytes())?;
240 let array = value
241 .as_array()
242 .context("Nostr relay message must be array")?;
243 match array
244 .first()
245 .and_then(Value::as_str)
246 .context("Nostr relay verb required")?
247 {
248 "EVENT" => {
249 ensure!(array.len() == 3, "Nostr EVENT arity");
250 let id = subid(&array[1])?;
251 let event = wire::verify_event(&array[2]).map_err(|_| {
252 anyhow::anyhow!("Nostr event id/signature/schema validation failed")
253 })?;
254 ensure!(
255 event.content.len() <= MAX_TEXT
256 && event.tags.iter().all(|t| !t.is_empty()
257 && t.len() <= MAX_TAG_ITEMS
258 && t.iter().all(|s| s.len() <= MAX_REASON)),
259 "Nostr incoming event content/tag limit"
260 );
261 Ok(RelayMessage::Event { id, event })
262 }
263 "OK" => {
264 ensure!(array.len() == 4, "Nostr OK arity");
265 let id = array[1].as_str().context("Nostr OK event id required")?;
266 ensure!(
267 lower_hex(id, 64),
268 "Nostr OK event id must be full lowercase hex"
269 );
270 let accepted = array[2]
271 .as_bool()
272 .context("Nostr OK acceptance must be boolean")?;
273 let message = reason(&array[3])?;
274 if !accepted {
275 ensure!(
276 reason_prefix(&message).is_some(),
277 "Nostr rejection requires machine-readable prefix"
278 );
279 }
280 Ok(RelayMessage::Ok {
281 id: id.into(),
282 accepted,
283 message,
284 })
285 }
286 "EOSE" => {
287 ensure!(array.len() == 2, "Nostr EOSE arity");
288 Ok(RelayMessage::Eose(subid(&array[1])?))
289 }
290 "CLOSED" => {
291 ensure!(array.len() == 3, "Nostr CLOSED arity");
292 let message = reason(&array[2])?;
293 ensure!(
294 reason_prefix(&message).is_some(),
295 "Nostr CLOSED requires machine-readable prefix"
296 );
297 Ok(RelayMessage::Closed {
298 id: subid(&array[1])?,
299 message,
300 })
301 }
302 "NOTICE" => {
303 ensure!(array.len() == 2, "Nostr NOTICE arity");
304 Ok(RelayMessage::Notice(reason(&array[1])?))
305 }
306 "AUTH" => Ok(RelayMessage::Unsupported("AUTH")),
308 "COUNT" => Ok(RelayMessage::Unsupported("COUNT")),
309 _ => Ok(RelayMessage::Unsupported("unknown")),
310 }
311}
312pub fn reason_prefix(text: &str) -> Option<&str> {
313 let (prefix, _) = text.split_once(':')?;
314 (!prefix.is_empty()
315 && prefix.len() <= 64
316 && prefix.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'))
317 .then_some(prefix)
318}
319pub fn relay_info(value: &Value) -> Result<Value> {
320 ensure!(within_budget(value), "Nostr information budget");
321 let input = value
322 .as_object()
323 .context("NIP-11 document must be object")?;
324 let mut output = Map::new();
325 for name in [
326 "name",
327 "description",
328 "banner",
329 "icon",
330 "contact",
331 "software",
332 "version",
333 "terms_of_service",
334 "pubkey",
335 "self",
336 ] {
337 if let Some(value) = input.get(name) {
338 if value.is_null() {
339 continue;
340 }
341 let text = value
342 .as_str()
343 .context("NIP-11 selected text field must be string")?;
344 ensure!(text.len() <= MAX_TEXT, "NIP-11 text limit");
345 if matches!(name, "pubkey" | "self") {
346 ensure!(
347 lower_hex(text, 64),
348 "NIP-11 public key must be full lowercase hex"
349 );
350 }
351 output.insert(name.into(), value.clone());
352 }
353 }
354 if let Some(value) = input.get("supported_nips").filter(|v| !v.is_null()) {
355 let list = value
356 .as_array()
357 .context("NIP-11 supported_nips must be array")?;
358 ensure!(
359 list.len() <= MAX_LIST && list.iter().all(|v| v.as_u64().is_some()),
360 "NIP-11 supported_nips integer list limit"
361 );
362 output.insert("supported_nips".into(), value.clone());
363 }
364 if let Some(value) = input.get("limitation").filter(|v| !v.is_null()) {
365 let input = value
366 .as_object()
367 .context("NIP-11 limitation must be object")?;
368 let mut selected = Map::new();
369 for name in [
370 "max_message_length",
371 "max_subscriptions",
372 "max_filters",
373 "max_limit",
374 "max_subid_length",
375 "max_event_tags",
376 "max_content_length",
377 "min_pow_difficulty",
378 "created_at_lower_limit",
379 "created_at_upper_limit",
380 "default_limit",
381 ] {
382 if let Some(value) = input.get(name).filter(|v| !v.is_null()) {
383 ensure!(
384 value.as_u64().is_some(),
385 "NIP-11 limitation integer required"
386 );
387 selected.insert(name.into(), value.clone());
388 }
389 }
390 for name in ["auth_required", "payment_required", "restricted_writes"] {
391 if let Some(value) = input.get(name).filter(|v| !v.is_null()) {
392 ensure!(
393 value.as_bool().is_some(),
394 "NIP-11 limitation boolean required"
395 );
396 selected.insert(name.into(), value.clone());
397 }
398 }
399 output.insert("limitation".into(), Value::Object(selected));
400 }
401 Ok(Value::Object(output))
402}