Compare commits

...

6 Commits

6 changed files with 117 additions and 59 deletions

View File

@ -64,6 +64,15 @@ announce upgrade instructions until release.
## Change Log
### Version 2.0
- IMPORTANT: You need to manually [Migrate](docs/MIGRATION.md) your users and moderators.
- Management commands have been added: listeventsneedingmoderation, listadmins, listmoderators,
grantmoderator, revokemoderator, listusers, grantuser, revokeuser, stats
- fix: subscriptions will be CLOSED: auth-required if any matching event requires auth. Chorus
will serve the redacted results first, but will not EOSE.
- fix: subscriptions will be CLOSED when completed, without EOSE, if the filter has any ids set.
### Version 1.7.2
- Support for NIP-62 (PR #1256) Right to Vanish

25
docs/MIGRATION.md Normal file
View File

@ -0,0 +1,25 @@
# Migration
## From 1.0 to 2.0
1) Add to your config file `admin_hex_keys` to include the nostr hex keys of administrators.
These will (eventually) be allowed to manage users and moderators remotely via the management
interface. Note that being an admin does NOT automatically grant user or moderator rights,
it ONLY grants the right to administer users.
2) Users and moderators are now dynamically configured in the database. Use `chorus_cmd` to
manage them from the command line:
* Adding a user: `chorus_cmd add_user <pubkey> 0`
* Adding a moderator: `chorus_cmd add_user <pubkey> 1`
* Removing a user or moderator: `chorus_cmd rm_user <pubkey>`
* Listing users and moderators: `chorus_cmd dump_users`
3) Remove the following from your config file as these are no longer used:
* `user_hex_keys` - users are now dynamically configured and configuration is in the database.
* `moderator_hex_keys` - moderators are now dynamically configured and configuration is in the database.
4) Configuration setting `public_key_kex` has been renamed `contact_public_key_hex` and is
used only for the NIP-11 data.

View File

@ -374,7 +374,7 @@ impl WebSocketService {
if m.len() > self.burst_tokens {
log::info!(target: "Client", "{}: Rate limited exceeded", self.peer);
let reply = NostrReply::Notice("Rate limit exceeded.".into());
self.websocket.send(Message::text(reply.as_json())).await?;
self.websocket.send(Message::text(reply.as_json()?)).await?;
let error = ChorusError::RateLimitExceeded;
self.error_punishment += error.punishment();
return Err(error.into());
@ -419,7 +419,7 @@ impl WebSocketService {
// Offer AUTH to clients right off the bat
let reply = NostrReply::Auth(self.challenge.clone());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
let mut last_message_at = Instant::now();
@ -493,7 +493,7 @@ impl WebSocketService {
let message = NostrReply::Event(subid, event);
// note, this is not currently counted in throttling
self.websocket
.send(Message::text(message.as_json()))
.send(Message::text(message.as_json()?))
.await?;
continue 'subs;
}
@ -529,7 +529,7 @@ impl WebSocketService {
if message.len() > self.burst_tokens {
log::info!(target: "Client", "{}: Rate limited exceeded", self.peer);
let reply = NostrReply::Notice("Rate limit exceeded.".into());
self.websocket.send(Message::text(reply.as_json())).await?;
self.websocket.send(Message::text(reply.as_json()?)).await?;
let error = ChorusError::RateLimitExceeded;
self.error_punishment += error.punishment();
return Err(error.into());
@ -556,15 +556,15 @@ impl WebSocketService {
if !self.replied {
if let Some(subid) = &self.negentropy_sub {
let reply = NostrReply::NegErr(subid, format!("error: {e}"));
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
} else {
let reply = NostrReply::Notice(format!("error: {}", e.inner));
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
}
if self.error_punishment >= 1.0 {
let reply = NostrReply::Notice("Closing due to error(s)".into());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Err(ChorusError::ErrorClose.into());
}
}
@ -573,7 +573,7 @@ impl WebSocketService {
let reply = NostrReply::Notice(
"binary messages are not processed by this relay".to_owned(),
);
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
log::info!(target: "Client",
"{}: Received unhandled binary message: {:02X?}",
self.peer,

View File

@ -46,7 +46,7 @@ impl WebSocketService {
} else {
log::warn!(target: "Client", "{}: Received unhandled text message: {}", self.peer, msg);
let reply = NostrReply::Notice("Command unrecognized".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
Ok(())
@ -106,7 +106,7 @@ impl WebSocketService {
}
_ => NostrReply::Closed(&subid, NostrReplyPrefix::Error, format!("{}", e.inner)),
};
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
Err(e)
} else {
Ok(())
@ -141,7 +141,7 @@ impl WebSocketService {
NostrReplyPrefix::AuthRequired,
"DM kinds were included in the filters".to_owned(),
);
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
}
@ -199,29 +199,35 @@ impl WebSocketService {
}
}
let reply = NostrReply::Count(subid, events.len(), opthll);
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
} else {
for event in events.drain(..) {
let reply = NostrReply::Event(subid, event);
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
// New policy Feb 2025: Redactions trigger a "CLOSED: auth-required" because
// some clients will not AUTH otherwise.
// (But we also already sent partial results, which I think is good)
if redacted {
// They need to AUTH first
let reply = NostrReply::Closed(
subid,
NostrReplyPrefix::AuthRequired,
"At least one matching event requires AUTH".to_owned(),
);
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
if completes {
// Closed
let reply = if redacted {
NostrReply::Closed(subid, NostrReplyPrefix::None, "".to_owned())
} else {
NostrReply::Closed(
subid,
NostrReplyPrefix::Redacted,
"Some matching events could not be served to you.".to_owned(),
)
};
self.send(Message::text(reply.as_json())).await?;
let reply = NostrReply::Closed(subid, NostrReplyPrefix::None, "".to_owned());
self.send(Message::text(reply.as_json()?)).await?;
} else {
// EOSE
let reply = NostrReply::Eose(subid);
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
}
}
@ -301,11 +307,11 @@ impl WebSocketService {
},
_ => NostrReply::Ok(id, false, NostrReplyPrefix::Error, format!("{}", e.inner)),
};
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
Err(e)
} else {
let reply = NostrReply::Ok(id, true, NostrReplyPrefix::None, "".to_string());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
Ok(())
}
}
@ -382,7 +388,7 @@ impl WebSocketService {
// message, and clients just presume it was closed.
/*
let reply = NostrReply::Closed(subid, NostrReplyPrefix::None, "".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
*/
Ok(())
@ -410,11 +416,11 @@ impl WebSocketService {
}
_ => NostrReply::Ok(id, false, NostrReplyPrefix::Error, format!("{}", e.inner)),
};
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
Err(e)
} else {
let reply = NostrReply::Ok(id, true, NostrReplyPrefix::None, "".to_string());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
Ok(())
}
}
@ -493,7 +499,7 @@ impl WebSocketService {
if !GLOBALS.config.read().enable_negentropy {
let reply =
NostrReply::NegErr(&subid, "blocked: Negentropy sync is disabled".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
@ -527,14 +533,14 @@ impl WebSocketService {
// NEG-ERR if the message was empty
if incoming_msg.is_empty() {
let reply = NostrReply::NegErr(&subid, "error: Empty negentropy message".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
// If the version is too high, respond with our version number
if incoming_msg[0] != 0x61 {
let reply = NostrReply::NegMsg(&subid, vec![0x61]);
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
@ -588,11 +594,11 @@ impl WebSocketService {
match neg.reconcile(&Bytes::from(incoming_msg)) {
Ok(response) => {
let reply = NostrReply::NegMsg(&subid, response.as_bytes().to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
Err(e) => {
let reply = NostrReply::NegErr(&subid, format!("{e}"));
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
}
@ -628,7 +634,7 @@ impl WebSocketService {
if !GLOBALS.config.read().enable_negentropy {
let reply =
NostrReply::NegErr(&subid, "blocked: Negentropy sync is disabled".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
@ -650,7 +656,7 @@ impl WebSocketService {
// NEG-ERR if the message was empty
if incoming_msg.is_empty() {
let reply = NostrReply::NegErr(&subid, "error: Empty negentropy message".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
@ -658,14 +664,14 @@ impl WebSocketService {
// have already happened in NEG-OPEN)
if incoming_msg[0] != 0x61 {
let reply = NostrReply::NegErr(&subid, "Version mismatch".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
}
// Look up the events we have
let Some(nsv) = self.neg_subscriptions.get(&subid) else {
let reply = NostrReply::NegErr(&subid, "Subscription not found".to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
return Ok(());
};
@ -673,11 +679,11 @@ impl WebSocketService {
match neg.reconcile(&Bytes::from(incoming_msg)) {
Ok(response) => {
let reply = NostrReply::NegMsg(&subid, response.as_bytes().to_owned());
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
Err(e) => {
let reply = NostrReply::NegErr(&subid, format!("{e}"));
self.send(Message::text(reply.as_json())).await?;
self.send(Message::text(reply.as_json()?)).await?;
}
}

View File

@ -1,3 +1,4 @@
use crate::Error;
use pocket_types::{write_hex, Event, Hll8, Id};
use std::fmt;
@ -9,7 +10,6 @@ pub enum NostrReplyPrefix {
Duplicate,
Blocked,
RateLimited,
Redacted,
Restricted,
Invalid,
Error,
@ -24,7 +24,6 @@ impl fmt::Display for NostrReplyPrefix {
NostrReplyPrefix::Duplicate => write!(f, "duplicate: "),
NostrReplyPrefix::Blocked => write!(f, "blocked: "),
NostrReplyPrefix::RateLimited => write!(f, "rate-limited: "),
NostrReplyPrefix::Redacted => write!(f, "redacted: "),
NostrReplyPrefix::Restricted => write!(f, "restricted: "),
NostrReplyPrefix::Invalid => write!(f, "invalid: "),
NostrReplyPrefix::Error => write!(f, "error: "),
@ -47,40 +46,59 @@ pub enum NostrReply<'a> {
}
impl NostrReply<'_> {
pub fn as_json(&self) -> String {
match self {
NostrReply::Auth(challenge) => format!(r#"["AUTH","{challenge}"]"#),
NostrReply::Event(subid, event) => format!(r#"["EVENT","{subid}",{}]"#, event),
NostrReply::Ok(id, ok, prefix, msg) => format!(r#"["OK","{id}",{ok},"{prefix}{msg}"]"#),
NostrReply::Eose(subid) => format!(r#"["EOSE","{subid}"]"#),
pub fn as_json(&self) -> Result<String, Error> {
Ok(match self {
NostrReply::Auth(challenge) => {
let esc_challenge = escape(challenge)?;
format!(r#"["AUTH","{esc_challenge}"]"#)
}
NostrReply::Event(subid, event) => {
let esc_subid = escape(subid)?;
format!(r#"["EVENT","{esc_subid}",{event}]"#)
}
NostrReply::Ok(id, ok, prefix, msg) => {
let esc_msg = escape(msg)?;
format!(r#"["OK","{id}",{ok},"{prefix}{esc_msg}"]"#)
}
NostrReply::Eose(subid) => {
let esc_subid = escape(subid)?;
format!(r#"["EOSE","{esc_subid}"]"#)
}
NostrReply::Closed(subid, prefix, msg) => {
format!(r#"["CLOSED","{subid}","{prefix}{msg}"]"#)
}
NostrReply::Notice(msg) => format!(r#"["NOTICE","{msg}"]"#),
NostrReply::Notice(msg) => {
let esc_msg = escape(msg)?;
format!(r#"["NOTICE","{esc_msg}"]"#)
}
NostrReply::Count(subid, c, opthll) => {
let esc_subid = escape(subid)?;
if let Some(hll) = opthll {
let hll = hll.to_hex_string();
format!(r#"["COUNT","{subid}",{{"count":{c}, "hll":"{hll}"}}]"#)
format!(r#"["COUNT","{esc_subid}",{{"count":{c}, "hll":"{hll}"}}]"#)
} else {
format!(r#"["COUNT","{subid}",{{"count":{c}}}]"#)
format!(r#"["COUNT","{esc_subid}",{{"count":{c}}}]"#)
}
}
NostrReply::NegErr(subid, reason) => {
format!(r#"["NEG-ERR","{subid}","{reason}"]"#)
let esc_subid = escape(subid)?;
let esc_reason = escape(reason)?;
format!(r#"["NEG-ERR","{esc_subid}","{esc_reason}"]"#)
}
NostrReply::NegMsg(subid, msg) => {
let esc_subid = escape(subid)?;
// write msg as hex
let mut buf: Vec<u8> = vec![0; msg.len() * 2];
write_hex!(msg, &mut buf, msg.len()).unwrap();
let msg_hex = unsafe { std::str::from_utf8_unchecked(&buf) };
format!(r#"["NEG-MSG","{subid}","{}"]"#, msg_hex)
format!(r#"["NEG-MSG","{esc_subid}","{}"]"#, msg_hex)
}
}
})
}
}
impl fmt::Display for NostrReply<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_json())
}
fn escape(s: &str) -> Result<String, Error> {
let v: Vec<u8> = Vec::with_capacity(256);
let e = pocket_types::json::json_escape(s.as_bytes(), v)?;
Ok(unsafe { String::from_utf8_unchecked(e) })
}

View File

@ -24,7 +24,7 @@ fn respond(
let s: String = serde_json::to_string(&json)?;
let response = Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Headers", "*")
.header("Access-Control-Allow-Headers", "Authorization, *")
.header("Access-Control-Allow-Methods", "*")
.header("Content-Type", "application/nostr+json+rpc")
.status(status)