initial commit
This commit is contained in:
commit
24a1c0dfc2
15 changed files with 509 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
target/
|
||||
.direnv/
|
||||
.buildcmd
|
||||
.build-result
|
||||
shell.nix
|
||||
.envrc
|
||||
tags
|
||||
19
COPYING
Normal file
19
COPYING
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Copyright 2024 Damus Nostr, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the “Software”), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
143
Cargo.lock
generated
Normal file
143
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||
|
||||
[[package]]
|
||||
name = "noteguard"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.204"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.204"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "201fcda3845c23e8212cd466bfebf0bd20694490fc0356ae8e428e0824a915a6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.5.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
||||
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "noteguard"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.5"
|
||||
tracing = "0.1.40"
|
||||
|
||||
|
||||
|
||||
2
Makefile
Normal file
2
Makefile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
tags:
|
||||
find src -name '*.rs' | xargs ctags
|
||||
23
README.md
Normal file
23
README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
# noteguard
|
||||
|
||||
A high performance note filter plugin system for [strfry]
|
||||
|
||||
## Usage
|
||||
|
||||
Filters are registered and loaded from the [noteguard.toml](noteguard.toml) config.
|
||||
|
||||
You can add any new filter you want by implementing the `NoteFilter` trait and registering it with noteguard via the `register_filter` method.
|
||||
|
||||
The `pipeline` config specifies the order in which filters are run. When the first `reject` or `shadowReject` action is hit, then the pipeline stops and returns the rejection error.
|
||||
|
||||
```toml
|
||||
|
||||
pipeline = ["ratelimit"]
|
||||
|
||||
[filters.ratelimit]
|
||||
notes_per_second = 1
|
||||
whitelist = ["127.0.0.1"]
|
||||
```
|
||||
|
||||
[strfry]: https://github.com/hoytech/strfry
|
||||
6
noteguard.toml
Normal file
6
noteguard.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
pipeline = ["ratelimit"]
|
||||
|
||||
[filters.ratelimit]
|
||||
notes_per_second = 1
|
||||
whitelist = ["127.0.0.1"]
|
||||
5
src/filters/mod.rs
Normal file
5
src/filters/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod rate_limit;
|
||||
mod whitelist;
|
||||
|
||||
pub use rate_limit::RateLimit;
|
||||
pub use whitelist::Whitelist;
|
||||
54
src/filters/rate_limit.rs
Normal file
54
src/filters/rate_limit.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use crate::{Action, InputMessage, NoteFilter, OutputMessage};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct RateInfo {
|
||||
pub last_note: Instant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct RateLimit {
|
||||
pub notes_per_second: u64,
|
||||
pub whitelist: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub sources: HashMap<String, RateInfo>,
|
||||
}
|
||||
|
||||
impl NoteFilter for RateLimit {
|
||||
fn filter_note(&mut self, msg: &InputMessage) -> OutputMessage {
|
||||
if let Some(whitelist) = &self.whitelist {
|
||||
if whitelist.contains(&msg.source_info) {
|
||||
return OutputMessage::new(msg.event.id.clone(), Action::Accept, None);
|
||||
}
|
||||
}
|
||||
|
||||
if self.sources.contains_key(&msg.source_info) {
|
||||
let now = Instant::now();
|
||||
let entry = self.sources.get_mut(&msg.source_info).expect("impossiburu");
|
||||
if now - entry.last_note < Duration::from_secs(self.notes_per_second) {
|
||||
return OutputMessage::new(
|
||||
msg.event.id.clone(),
|
||||
Action::Reject,
|
||||
Some("rate-limited: you are noting too fast".to_string()),
|
||||
);
|
||||
} else {
|
||||
entry.last_note = Instant::now();
|
||||
return OutputMessage::new(msg.event.id.clone(), Action::Accept, None);
|
||||
}
|
||||
} else {
|
||||
self.sources.insert(
|
||||
msg.source_info.to_owned(),
|
||||
RateInfo {
|
||||
last_note: Instant::now(),
|
||||
},
|
||||
);
|
||||
return OutputMessage::new(msg.event.id.clone(), Action::Accept, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ratelimit"
|
||||
}
|
||||
}
|
||||
26
src/filters/whitelist.rs
Normal file
26
src/filters/whitelist.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use crate::{Action, InputMessage, NoteFilter, OutputMessage};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Whitelist {
|
||||
pub pubkeys: Vec<String>,
|
||||
pub ips: Vec<String>,
|
||||
}
|
||||
|
||||
impl NoteFilter for Whitelist {
|
||||
fn filter_note(&mut self, msg: &InputMessage) -> OutputMessage {
|
||||
if self.pubkeys.contains(&msg.event.pubkey) || self.ips.contains(&msg.source_info) {
|
||||
OutputMessage::new(msg.event.id.clone(), Action::Accept, None)
|
||||
} else {
|
||||
OutputMessage::new(
|
||||
msg.event.id.clone(),
|
||||
Action::Reject,
|
||||
Some("blocked: pubkey not on the whitelist".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"whitelist"
|
||||
}
|
||||
}
|
||||
6
src/lib.rs
Normal file
6
src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod filters;
|
||||
mod messages;
|
||||
mod note_filter;
|
||||
|
||||
pub use messages::{Action, InputMessage, OutputMessage};
|
||||
pub use note_filter::{Note, NoteFilter};
|
||||
143
src/main.rs
Normal file
143
src/main.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use noteguard::filters::RateLimit;
|
||||
use noteguard::{Action, InputMessage, NoteFilter, OutputMessage};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, BufRead, Read, Write};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Config {
|
||||
pipeline: Vec<String>,
|
||||
filters: HashMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
type ConstructFilter = Box<fn(toml::Value) -> Result<Box<dyn NoteFilter>, toml::de::Error>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Noteguard {
|
||||
registered_filters: HashMap<String, ConstructFilter>,
|
||||
loaded_filters: Vec<Box<dyn NoteFilter>>,
|
||||
}
|
||||
|
||||
impl Noteguard {
|
||||
pub fn new() -> Self {
|
||||
let mut noteguard = Noteguard::default();
|
||||
noteguard.register_builtin_filters();
|
||||
noteguard
|
||||
}
|
||||
|
||||
pub fn register_filter<F: NoteFilter + 'static + Default + DeserializeOwned>(&mut self) {
|
||||
self.registered_filters.insert(
|
||||
F::name(&F::default()).to_string(),
|
||||
Box::new(|filter_config| {
|
||||
filter_config
|
||||
.try_into()
|
||||
.map(|filter: F| Box::new(filter) as Box<dyn NoteFilter>)
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// All builtin filters are registered here, and are made available with
|
||||
/// every new instance of [`Noteguard`]
|
||||
fn register_builtin_filters(&mut self) {
|
||||
self.register_filter::<RateLimit>();
|
||||
}
|
||||
|
||||
/// Run the loaded filters. You must call `load_config` before calling this, otherwise
|
||||
/// not filters will be run.
|
||||
fn run(&mut self, input: InputMessage) -> OutputMessage {
|
||||
let mut mout: Option<OutputMessage> = None;
|
||||
|
||||
let id = input.event.id.clone();
|
||||
for filter in &mut self.loaded_filters {
|
||||
let out = filter.filter_note(&input);
|
||||
match out.action {
|
||||
Action::Accept => {
|
||||
mout = Some(out);
|
||||
continue;
|
||||
}
|
||||
Action::Reject => {
|
||||
return out;
|
||||
}
|
||||
Action::ShadowReject => {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mout.unwrap_or_else(|| OutputMessage::new(id, Action::Accept, None))
|
||||
}
|
||||
|
||||
/// Initializes a noteguard config. If it finds any filter configurations
|
||||
/// matching the registered filters, it loads those into our filter pipeline.
|
||||
fn load_config(&mut self, config: &Config) -> Result<(), toml::de::Error> {
|
||||
self.loaded_filters.clear();
|
||||
|
||||
for (name, config_value) in &config.filters {
|
||||
if let Some(constructor) = self.registered_filters.get(name.as_str()) {
|
||||
let filter = constructor(config_value.clone())?;
|
||||
self.loaded_filters.push(filter);
|
||||
} else {
|
||||
panic!("Found config settings with no matching filter: {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let config_path = "noteguard.toml";
|
||||
let mut noteguard = Noteguard::new();
|
||||
|
||||
let config: Config = {
|
||||
let mut file = std::fs::File::open(config_path).expect("Failed to open config file");
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)
|
||||
.expect("Failed to read config file");
|
||||
toml::from_str(&contents).expect("Failed to parse config file")
|
||||
};
|
||||
|
||||
noteguard
|
||||
.load_config(&config)
|
||||
.expect("Expected filter config to be loaded ok");
|
||||
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let handle = stdout.lock();
|
||||
let mut writer = io::BufWriter::new(handle);
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
match line {
|
||||
Ok(input) => {
|
||||
let input_message: InputMessage = match serde_json::from_str(&input) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse input: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if input_message.message_type != "new" {
|
||||
eprintln!("Unexpected request type");
|
||||
continue;
|
||||
}
|
||||
|
||||
let output_message = noteguard.run(input_message);
|
||||
|
||||
match serde_json::to_string(&output_message) {
|
||||
Ok(json) => {
|
||||
writeln!(writer, "{}", json).unwrap();
|
||||
writer.flush().unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to serialize output: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read line: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/messages.rs
Normal file
37
src/messages.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use crate::Note;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct InputMessage {
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: String,
|
||||
pub event: Note,
|
||||
#[serde(rename = "receivedAt")]
|
||||
pub received_at: u64,
|
||||
#[serde(rename = "sourceType")]
|
||||
pub source_type: String,
|
||||
#[serde(rename = "sourceInfo")]
|
||||
pub source_info: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Action {
|
||||
Accept,
|
||||
Reject,
|
||||
ShadowReject,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OutputMessage {
|
||||
pub id: String,
|
||||
pub action: Action,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub msg: Option<String>,
|
||||
}
|
||||
|
||||
impl OutputMessage {
|
||||
pub fn new(id: String, action: Action, msg: Option<String>) -> Self {
|
||||
OutputMessage { id, action, msg }
|
||||
}
|
||||
}
|
||||
20
src/note_filter.rs
Normal file
20
src/note_filter.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use crate::{InputMessage, OutputMessage};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub pubkey: String,
|
||||
pub content: String,
|
||||
pub created_at: i64,
|
||||
pub kind: i64,
|
||||
pub tags: Vec<Vec<String>>,
|
||||
pub sig: String,
|
||||
}
|
||||
|
||||
pub trait NoteFilter {
|
||||
fn filter_note(&mut self, msg: &InputMessage) -> OutputMessage;
|
||||
|
||||
/// A key corresponding to an entry in the noteguard.toml file.
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
5
test/test-inputs
Normal file
5
test/test-inputs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.1","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}}
|
||||
{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.1","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}}
|
||||
{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.1","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}}
|
||||
{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.2","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}}
|
||||
{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.2","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue