261 lines
6.7 KiB
Rust
261 lines
6.7 KiB
Rust
use std::ops::Range;
|
|
|
|
use nostr::prelude::*;
|
|
|
|
const BECH32_SEPARATOR: u8 = b'1';
|
|
const SCHEME_WITH_COLON: &str = "nostr:";
|
|
const NEVENT_PREFIX: &str = "nevent1";
|
|
|
|
/// A `nevent` reference found in a content string.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct NeventRef {
|
|
/// Range of the reference, including the optional `nostr:` scheme
|
|
pub range: Range<usize>,
|
|
/// Compact display label, like `https://njump.me/nevent1...d9sp`
|
|
pub label: String,
|
|
}
|
|
|
|
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
|
|
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
|
|
let mut references = Vec::new();
|
|
|
|
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
|
|
// Only lowercase references are matched, matching NIP-19 encodings in practice
|
|
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
|
|
match_start - SCHEME_WITH_COLON.len()
|
|
} else {
|
|
match_start
|
|
};
|
|
|
|
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
|
|
if content[..start].ends_with(char::is_alphanumeric) {
|
|
continue;
|
|
}
|
|
|
|
let end = match_start
|
|
+ content[match_start..]
|
|
.bytes()
|
|
.take_while(u8::is_ascii_alphanumeric)
|
|
.count();
|
|
|
|
// The bech32 token excludes the optional `nostr:` scheme
|
|
let bech32 = &content[match_start..end];
|
|
if Nip19Event::from_bech32(bech32).is_err() {
|
|
continue;
|
|
}
|
|
|
|
references.push(NeventRef {
|
|
range: start..end,
|
|
label: nevent_label(bech32),
|
|
});
|
|
}
|
|
|
|
references
|
|
}
|
|
|
|
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
|
|
fn nevent_label(bech32: &str) -> String {
|
|
match bech32.len() {
|
|
0..=4 => format!("https://njump.me/{bech32}"),
|
|
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
|
|
}
|
|
}
|
|
|
|
/// Nostr parsed token with its range in the original text
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Token {
|
|
/// The parsed NIP-21 URI
|
|
///
|
|
/// <https://github.com/nostr-protocol/nips/blob/master/21.md>
|
|
pub value: Nip21,
|
|
/// The range of this token in the original text
|
|
pub range: Range<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct Match {
|
|
start: usize,
|
|
end: usize,
|
|
}
|
|
|
|
/// Nostr parser
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub struct NostrParser;
|
|
|
|
impl NostrParser {
|
|
/// Create new parser
|
|
pub const fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
/// Parse text
|
|
pub fn parse<'a>(&self, text: &'a str) -> NostrParserIter<'a> {
|
|
NostrParserIter::new(text)
|
|
}
|
|
}
|
|
|
|
impl Default for NostrParser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
struct FindMatches<'a> {
|
|
bytes: &'a [u8],
|
|
pos: usize,
|
|
}
|
|
|
|
impl<'a> FindMatches<'a> {
|
|
fn new(text: &'a str) -> Self {
|
|
Self {
|
|
bytes: text.as_bytes(),
|
|
pos: 0,
|
|
}
|
|
}
|
|
|
|
fn try_parse_nostr_uri(&mut self) -> Option<Match> {
|
|
let start = self.pos;
|
|
let bytes = self.bytes;
|
|
let len = bytes.len();
|
|
|
|
// Check if we have "nostr:" prefix
|
|
if len - start < SCHEME_WITH_COLON.len() {
|
|
return None;
|
|
}
|
|
|
|
// Check for "nostr:" prefix (case-insensitive)
|
|
let scheme_prefix = &bytes[start..start + SCHEME_WITH_COLON.len()];
|
|
if !scheme_prefix.eq_ignore_ascii_case(SCHEME_WITH_COLON.as_bytes()) {
|
|
return None;
|
|
}
|
|
|
|
// Skip the scheme
|
|
let pos = start + SCHEME_WITH_COLON.len();
|
|
|
|
// Parse bech32 entity
|
|
let mut has_separator = false;
|
|
let mut end = pos;
|
|
|
|
while end < len {
|
|
let byte = bytes[end];
|
|
|
|
// Check for bech32 separator
|
|
if byte == BECH32_SEPARATOR && !has_separator {
|
|
has_separator = true;
|
|
end += 1;
|
|
continue;
|
|
}
|
|
|
|
// Check if character is valid for bech32
|
|
if !byte.is_ascii_alphanumeric() {
|
|
break;
|
|
}
|
|
|
|
end += 1;
|
|
}
|
|
|
|
// Must have at least one character after separator
|
|
if !has_separator || end <= pos + 1 {
|
|
return None;
|
|
}
|
|
|
|
// Update position
|
|
self.pos = end;
|
|
|
|
Some(Match { start, end })
|
|
}
|
|
}
|
|
|
|
impl Iterator for FindMatches<'_> {
|
|
type Item = Match;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
while self.pos < self.bytes.len() {
|
|
// Try to parse nostr URI
|
|
if let Some(mat) = self.try_parse_nostr_uri() {
|
|
return Some(mat);
|
|
}
|
|
|
|
// Skip one character if no match found
|
|
self.pos += 1;
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
pub struct NostrParserIter<'a> {
|
|
/// The original text
|
|
text: &'a str,
|
|
/// Matches found
|
|
matches: FindMatches<'a>,
|
|
}
|
|
|
|
impl<'a> NostrParserIter<'a> {
|
|
fn new(text: &'a str) -> Self {
|
|
Self {
|
|
text,
|
|
matches: FindMatches::new(text),
|
|
}
|
|
}
|
|
|
|
fn handle_match(&self, mat: Match) -> Option<Token> {
|
|
// Extract the matched string
|
|
let data: &str = &self.text[mat.start..mat.end];
|
|
|
|
// Parse NIP-21 URI
|
|
match Nip21::parse(data) {
|
|
Ok(uri) => Some(Token {
|
|
value: uri,
|
|
range: mat.start..mat.end,
|
|
}),
|
|
// If the nostr URI parsing is invalid, skip it
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Iterator for NostrParserIter<'_> {
|
|
type Item = Token;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
// Iterative, not recursive: message text controls the match count
|
|
loop {
|
|
let mat = self.matches.next()?;
|
|
|
|
if let Some(token) = self.handle_match(mat) {
|
|
return Some(token);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// A long run of scanner matches that fail `Nip21::parse`: the count is
|
|
// attacker-controlled, so it must be consumed iteratively.
|
|
#[test]
|
|
fn skips_long_run_of_invalid_uris() {
|
|
let content = "nostr:1q".repeat(50_000);
|
|
|
|
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
|
|
|
|
assert!(tokens.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn yields_valid_uri_after_invalid_run() {
|
|
let public_key = Keys::new(SecretKey::generate()).public_key();
|
|
let npub = public_key.to_bech32().unwrap();
|
|
let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000));
|
|
|
|
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
|
|
|
|
assert_eq!(tokens.len(), 1);
|
|
assert_eq!(tokens[0].range.start, 400_000);
|
|
assert_eq!(tokens[0].value, Nip21::Pubkey(public_key));
|
|
}
|
|
}
|