fips-core 0.3.79

Reusable FIPS mesh, endpoint, transport, and protocol library
Documentation
use super::*;

pub(in crate::discovery::nostr) const RATING_FACT_KIND: u16 = 7368;

const RATING_FACT_TYPE: &str = "rating";
const RATING_FACT_SCHEMA: &str = "1";
const RATING_FACT_LOOKUP_LIMIT: usize = 500;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct RatingFactFileLoadReport {
    pub(crate) files: usize,
    pub(crate) events: usize,
    pub(crate) accepted: usize,
}

impl NostrDiscovery {
    pub(super) fn should_subscribe_rating_facts(&self) -> bool {
        self.config.open_discovery_trust_ratings_enabled
    }

    pub(crate) async fn load_rating_fact_events_from_files(&self) -> RatingFactFileLoadReport {
        let mut report = RatingFactFileLoadReport::default();
        if !self.config.open_discovery_trust_ratings_enabled {
            return report;
        }

        for path in &self.config.open_discovery_rating_event_files {
            report.files += 1;
            let bytes = match std::fs::read(path) {
                Ok(bytes) => bytes,
                Err(error) => {
                    warn!(
                        path = %path.display(),
                        error = %error,
                        "failed to read local Nostr rating fact event file"
                    );
                    continue;
                }
            };
            let value: serde_json::Value = match serde_json::from_slice(&bytes) {
                Ok(value) => value,
                Err(error) => {
                    warn!(
                        path = %path.display(),
                        error = %error,
                        "failed to parse local Nostr rating fact event file"
                    );
                    continue;
                }
            };
            let events = match rating_fact_events_from_json(&value) {
                Ok(events) => events,
                Err(error) => {
                    warn!(
                        path = %path.display(),
                        error = %error,
                        "local Nostr rating fact event file has unsupported shape"
                    );
                    continue;
                }
            };

            for event in events {
                report.events += 1;
                if self.process_rating_fact_event(&event).await {
                    report.accepted += 1;
                }
            }
        }

        if report.files > 0 {
            info!(
                files = report.files,
                events = report.events,
                accepted = report.accepted,
                "loaded local Nostr rating fact events"
            );
        }
        report
    }

    pub(super) fn rating_fact_filter(&self) -> Filter {
        let mut filter = Filter::new()
            .kind(Kind::Custom(RATING_FACT_KIND))
            .limit(RATING_FACT_LOOKUP_LIMIT);
        let scope = self.config.open_discovery_rating_scope.trim();
        if !scope.is_empty() {
            filter = filter.custom_tag(
                SingleLetterTag::lowercase(Alphabet::I),
                scope.to_lowercase(),
            );
        }
        let lookback_secs = self.config.open_discovery_rating_lookback_secs;
        if lookback_secs > 0 {
            let since = now_ms().saturating_div(1_000).saturating_sub(lookback_secs);
            filter = filter.since(Timestamp::from(since));
        }
        filter
    }

    pub(crate) async fn process_rating_fact_event(&self, event: &Event) -> bool {
        if !self.config.open_discovery_trust_ratings_enabled
            || event.kind != Kind::Custom(RATING_FACT_KIND)
        {
            return false;
        }

        let Ok(verified_event) = VerifiedEvent::try_from(event) else {
            return false;
        };
        if !self.rating_fact_author_is_trusted(verified_event.pubkey()) {
            return false;
        }

        let Some(record) = self.rating_record_from_event(verified_event.as_event()) else {
            return false;
        };
        self.record_peer_trust_score(&record.subject, record.score, record.created_at)
            .await
            .is_ok()
    }

    fn rating_fact_author_is_trusted(&self, author: &PublicKey) -> bool {
        let author_key = NostrPeerKey::from_public_key_ref(author);
        if author_key == self.self_peer_key() {
            return true;
        }
        self.config
            .open_discovery_trusted_rating_authors
            .iter()
            .filter_map(|author| NostrPeerKey::parse(author).ok())
            .any(|trusted| trusted == author_key)
    }

    fn rating_record_from_event(&self, event: &Event) -> Option<RatingFactRecord> {
        let value = serde_json::to_value(event).ok()?;
        if rating_fact_scalar(&value, "type").as_deref() != Some(RATING_FACT_TYPE) {
            return None;
        }
        if rating_fact_scalar(&value, "schema").as_deref() != Some(RATING_FACT_SCHEMA) {
            return None;
        }
        let expected_scope = self.config.open_discovery_rating_scope.trim();
        if expected_scope.is_empty()
            || rating_fact_scalar(&value, "scope")
                .as_deref()
                .is_none_or(|scope| scope.trim() != expected_scope)
        {
            return None;
        }

        let subject = rating_fact_scalar(&value, "subject")?;
        let rating = rating_fact_scalar(&value, "rating")?.parse::<i64>().ok()?;
        let min_rating = rating_fact_scalar(&value, "min_rating")?
            .parse::<i64>()
            .ok()?;
        let max_rating = rating_fact_scalar(&value, "max_rating")?
            .parse::<i64>()
            .ok()?;
        let score = normalize_rating_score(rating, min_rating, max_rating)?;
        let created_at = rating_fact_scalar(&value, "created_at")
            .and_then(|created_at| created_at.parse::<u64>().ok())
            .or_else(|| value.get("created_at").and_then(serde_json::Value::as_u64))
            .unwrap_or_else(|| event.created_at.as_secs());
        Some(RatingFactRecord {
            subject,
            score,
            created_at,
        })
    }
}

struct RatingFactRecord {
    subject: String,
    score: i64,
    created_at: u64,
}

fn normalize_rating_score(rating: i64, min_rating: i64, max_rating: i64) -> Option<i64> {
    if min_rating >= max_rating || rating < min_rating || rating > max_rating {
        return None;
    }
    let rating = i128::from(rating);
    let min = i128::from(min_rating);
    let max = i128::from(max_rating);
    let centered = rating.saturating_mul(2) - min - max;
    Some(((centered.saturating_mul(100)) / (max - min)) as i64)
}

fn rating_fact_scalar(event_value: &serde_json::Value, key: &str) -> Option<String> {
    rating_fact_values(event_value, key).into_iter().next()
}

fn rating_fact_values(event_value: &serde_json::Value, key: &str) -> Vec<String> {
    event_value
        .get("tags")
        .and_then(|tags| tags.as_array())
        .into_iter()
        .flatten()
        .filter_map(|tag| {
            let parts = tag.as_array()?;
            if parts.first().and_then(|value| value.as_str()) != Some(key) {
                return None;
            }
            parts.get(1).and_then(|value| value.as_str()).map(str::trim)
        })
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

fn rating_fact_events_from_json(value: &serde_json::Value) -> Result<Vec<Event>, String> {
    let events = if let Some(events) = value.get("events").and_then(serde_json::Value::as_array) {
        events
    } else if let Some(data) = value.get("data") {
        data.get("events")
            .and_then(serde_json::Value::as_array)
            .ok_or_else(|| "expected an events array".to_string())?
    } else {
        value
            .as_array()
            .ok_or_else(|| "expected an event array or object with events array".to_string())?
    };

    events
        .iter()
        .map(|value| {
            serde_json::from_value(value.clone())
                .map_err(|error| format!("failed to decode Nostr event: {error}"))
        })
        .collect()
}