nvpn 4.1.14

CLI and daemon for Nostr VPN private mesh networks
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PaidExitRatingScore {
    score: i64,
    created_at: u64,
}

fn load_paid_exit_rating_scores(
    path: &Path,
    scope: &str,
    trusted_authors: &HashSet<String>,
) -> Result<HashMap<String, PaidExitRatingScore>> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read paid exit ratings {}", path.display()))?;
    let value: serde_json::Value = serde_json::from_str(&content)
        .with_context(|| format!("failed to parse paid exit ratings {}", path.display()))?;
    paid_exit_rating_scores_from_value(&value, scope, trusted_authors)
}

fn paid_exit_rating_scores_from_value(
    value: &serde_json::Value,
    scope: &str,
    trusted_authors: &HashSet<String>,
) -> Result<HashMap<String, PaidExitRatingScore>> {
    let mut scores: HashMap<String, PaidExitRatingScore> = HashMap::new();
    for rating in paid_exit_rating_records(value, trusted_authors)? {
        if !paid_exit_rating_matches_scope(&rating, scope) {
            continue;
        }
        let subject = paid_exit_rating_string_field(&rating, "subject")?;
        let rating_value = paid_exit_rating_i64_field(&rating, "rating")?;
        let min_rating = paid_exit_rating_i64_field(&rating, "min_rating")?;
        let max_rating = paid_exit_rating_i64_field(&rating, "max_rating")?;
        let score = paid_exit_normalized_rating_score(rating_value, min_rating, max_rating)?;
        let created_at = paid_exit_rating_u64_field(&rating, "created_at").unwrap_or_default();
        let incoming = PaidExitRatingScore { score, created_at };
        scores
            .entry(subject)
            .and_modify(|existing| {
                if incoming.created_at >= existing.created_at {
                    *existing = incoming;
                }
            })
            .or_insert(incoming);
    }
    Ok(scores)
}

fn merge_paid_exit_rating_scores(
    target: &mut Option<HashMap<String, PaidExitRatingScore>>,
    incoming: HashMap<String, PaidExitRatingScore>,
) {
    if incoming.is_empty() {
        return;
    }
    let target = target.get_or_insert_with(HashMap::new);
    for (subject, incoming_score) in incoming {
        target
            .entry(subject)
            .and_modify(|existing| {
                if incoming_score.created_at >= existing.created_at {
                    *existing = incoming_score;
                }
            })
            .or_insert(incoming_score);
    }
}

fn paid_exit_rating_records(
    value: &serde_json::Value,
    trusted_authors: &HashSet<String>,
) -> Result<Vec<serde_json::Value>> {
    if let Some(records) = value.as_array() {
        return Ok(records
            .iter()
            .filter(|record| paid_exit_rating_record_author_is_trusted(record, trusted_authors))
            .cloned()
            .collect());
    }

    if let Some(records) = value.get("ratings").and_then(|ratings| ratings.as_array()) {
        return Ok(records
            .iter()
            .filter(|record| paid_exit_rating_record_author_is_trusted(record, trusted_authors))
            .cloned()
            .collect());
    }

    if let Some(events) = value.get("events").and_then(|events| events.as_array()) {
        return paid_exit_rating_records_from_events(events, trusted_authors);
    }

    Err(anyhow!(
        "ratings JSON must be an array, an object with a ratings array, or an object with an events array"
    ))
}

fn paid_exit_rating_records_from_events(
    events: &[serde_json::Value],
    trusted_authors: &HashSet<String>,
) -> Result<Vec<serde_json::Value>> {
    let mut records = Vec::new();
    for event_value in events {
        let Ok(event) = paid_exit_verified_rating_fact_event(event_value) else {
            continue;
        };
        if !paid_exit_rating_event_author_is_trusted(&event, trusted_authors) {
            continue;
        }
        if let Ok(record) = paid_exit_rating_record_from_verified_fact_event(&event) {
            records.push(record);
        }
    }
    Ok(records)
}

fn paid_exit_trusted_rating_author_set(authors: &[String]) -> Result<HashSet<String>> {
    let mut normalized = HashSet::new();
    for author in authors.iter().flat_map(|value| value.split(',')) {
        let author = author.trim();
        if author.is_empty() {
            continue;
        }
        normalized.insert(paid_exit_normalize_rating_author(author)?);
    }
    Ok(normalized)
}

fn paid_exit_normalize_rating_author(author: &str) -> Result<String> {
    PublicKey::parse(author.trim())
        .map(|public_key| public_key.to_hex())
        .map_err(|error| anyhow!("invalid trusted rating author {author}: {error}"))
}

fn paid_exit_rating_event_author_is_trusted(
    event: &Event,
    trusted_authors: &HashSet<String>,
) -> bool {
    if trusted_authors.is_empty() {
        return true;
    }
    trusted_authors.contains(&event.pubkey.to_hex())
}

fn paid_exit_rating_record_author_is_trusted(
    record: &serde_json::Value,
    trusted_authors: &HashSet<String>,
) -> bool {
    if trusted_authors.is_empty() {
        return true;
    }
    record
        .get("rater")
        .and_then(|value| value.as_str())
        .and_then(|rater| paid_exit_normalize_rating_author(rater).ok())
        .is_some_and(|author| trusted_authors.contains(&author))
}

fn paid_exit_verified_rating_fact_event(event_value: &serde_json::Value) -> Result<Event> {
    let event: Event = serde_json::from_value(event_value.clone())
        .context("rating fact event is not valid Nostr event JSON")?;
    event
        .verify()
        .map_err(|error| anyhow!("rating fact event verification failed: {error}"))?;
    if event.kind != Kind::Custom(RATING_FACT_KIND as u16) {
        return Err(anyhow!(
            "rating fact event kind must be {RATING_FACT_KIND}, got {:?}",
            event.kind
        ));
    }
    Ok(event)
}

fn paid_exit_rating_record_from_verified_fact_event(event: &Event) -> Result<serde_json::Value> {
    let rating = nostr_social_memory::rating_from_event(event)?;
    if rating.scope.as_deref() == Some("vpn.exit")
        && PublicKey::parse(&rating.rater)? != event.pubkey
    {
        return Err(anyhow!("exit rating author does not match its signer"));
    }
    Ok(serde_json::to_value(rating)?)
}

#[cfg(test)]
fn paid_exit_fact_optional_scalar(event_value: &serde_json::Value, key: &str) -> Option<String> {
    paid_exit_fact_values(event_value, key).into_iter().next()
}

#[cfg(test)]
fn paid_exit_fact_values(event_value: &serde_json::Value, key: &str) -> Vec<String> {
    paid_exit_fact_tags(event_value)
        .into_iter()
        .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()
}

#[cfg(test)]
fn paid_exit_fact_tags(event_value: &serde_json::Value) -> Vec<&serde_json::Value> {
    event_value
        .get("tags")
        .and_then(|tags| tags.as_array())
        .map(|tags| tags.iter().collect())
        .unwrap_or_default()
}

fn paid_exit_rating_matches_scope(rating: &serde_json::Value, expected_scope: &str) -> bool {
    let expected_scope = expected_scope.trim();
    expected_scope.is_empty()
        || rating
            .get("scope")
            .and_then(|value| value.as_str())
            .is_some_and(|scope| scope.trim() == expected_scope)
}

#[cfg(test)]
fn paid_exit_rating_fact_matches_scope(
    event_value: &serde_json::Value,
    expected_scope: &str,
) -> bool {
    let expected_scope = expected_scope.trim();
    expected_scope.is_empty()
        || paid_exit_fact_optional_scalar(event_value, "scope")
            .is_some_and(|scope| scope.trim() == expected_scope)
}

fn paid_exit_rating_string_field(rating: &serde_json::Value, key: &str) -> Result<String> {
    rating
        .get(key)
        .and_then(|value| value.as_str())
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .ok_or_else(|| anyhow!("rating record is missing string field {key}"))
}

fn paid_exit_rating_i64_field(rating: &serde_json::Value, key: &str) -> Result<i64> {
    rating
        .get(key)
        .and_then(|value| value.as_i64())
        .ok_or_else(|| anyhow!("rating record is missing integer field {key}"))
}

fn paid_exit_rating_u64_field(rating: &serde_json::Value, key: &str) -> Option<u64> {
    rating.get(key).and_then(|value| value.as_u64())
}

fn paid_exit_normalized_rating_score(rating: i64, min_rating: i64, max_rating: i64) -> Result<i64> {
    if min_rating >= max_rating {
        return Err(anyhow!("invalid rating range {min_rating}..{max_rating}"));
    }
    if rating < min_rating || rating > max_rating {
        return Err(anyhow!(
            "rating {rating} outside range {min_rating}..{max_rating}"
        ));
    }
    let rating = i128::from(rating);
    let min = i128::from(min_rating);
    let max = i128::from(max_rating);
    let width = max - min;
    let centered = rating.saturating_mul(2) - min - max;
    Ok(((centered.saturating_mul(100)) / width) as i64)
}

fn paid_exit_sort_offers_by_rating(
    offers: &mut [SignedPaidRouteOffer],
    rating_scores: &HashMap<String, PaidExitRatingScore>,
) {
    offers.sort_by(|left, right| {
        let left_score =
            paid_exit_signed_offer_rating_score(left, rating_scores).map_or(0, |score| score.score);
        let right_score = paid_exit_signed_offer_rating_score(right, rating_scores)
            .map_or(0, |score| score.score);
        right_score
            .cmp(&left_score)
            .then_with(|| {
                right
                    .event
                    .created_at
                    .as_secs()
                    .cmp(&left.event.created_at.as_secs())
            })
            .then_with(|| left.event.id.to_string().cmp(&right.event.id.to_string()))
    });
}

fn paid_exit_signed_offer_rating_score(
    signed: &SignedPaidRouteOffer,
    rating_scores: &HashMap<String, PaidExitRatingScore>,
) -> Option<PaidExitRatingScore> {
    signed
        .offer()
        .ok()
        .and_then(|offer| rating_scores.get(&offer.seller_npub).copied())
}

#[cfg(test)]
fn paid_exit_rating_fact_filter(limit: usize, since_unix: Option<u64>, scope: &str) -> Filter {
    let mut filter = Filter::new().kind(Kind::Custom(RATING_FACT_KIND as u16));
    if limit > 0 {
        filter = filter.limit(limit);
    }
    if let Some(since_unix) = since_unix {
        filter = filter.since(Timestamp::from(since_unix));
    }
    let scope = scope.trim();
    if !scope.is_empty() {
        filter = filter.custom_tag(
            SingleLetterTag::lowercase(Alphabet::I),
            scope.to_lowercase(),
        );
    }
    filter
}

fn paid_exit_offer_results_json(
    offers: &[SignedPaidRouteOffer],
    rating_scores: Option<&HashMap<String, PaidExitRatingScore>>,
) -> Result<Vec<serde_json::Value>> {
    offers
        .iter()
        .map(|signed| {
            let offer: PaidRouteOffer = signed.offer()?;
            let rating_score = rating_scores
                .and_then(|scores| scores.get(&offer.seller_npub))
                .map(|score| score.score);
            let mut value = json!({
                "event_id": signed.event.id.to_string(),
                "created_at": signed.event.created_at.as_secs(),
                "offer": offer,
            });
            if rating_scores.is_some() {
                value["rating_score"] = rating_score
                    .map(|score| json!(score))
                    .unwrap_or(serde_json::Value::Null);
            }
            Ok(value)
        })
        .collect()
}