mirror of https://github.com/mastodon/flodgatt
Handle non conforment events (#117)
* Initial implementation of DynamicEvent * Restore early Event parsing
This commit is contained in:
parent
d5f079a864
commit
19792d9484
|
@ -111,7 +111,7 @@ dependencies = [
|
|||
"lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"regex-automata 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -235,9 +235,9 @@ dependencies = [
|
|||
"rand_os 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rand_xoshiro 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"rayon 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_derive 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.39 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_derive 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.50 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tinytemplate 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"walkdir 2.2.9 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
@ -308,7 +308,7 @@ dependencies = [
|
|||
"csv-core 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"ryu 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -453,9 +453,9 @@ dependencies = [
|
|||
"r2d2 0.8.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"r2d2_postgres 0.16.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"regex 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_derive 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.39 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_derive 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.50 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"strum 0.16.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"strum_macros 0.16.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tokio 0.1.19 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
|
@ -1597,11 +1597,6 @@ dependencies = [
|
|||
"semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.0"
|
||||
|
@ -1658,30 +1653,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.91"
|
||||
version = "1.0.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"serde_derive 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_derive 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.91"
|
||||
version = "1.0.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"quote 0.6.12 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"syn 0.15.34 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"proc-macro2 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"syn 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.39"
|
||||
version = "1.0.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"ryu 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"ryu 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1691,7 +1686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
dependencies = [
|
||||
"dtoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"url 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
|
@ -1886,8 +1881,8 @@ name = "tinytemplate"
|
|||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
dependencies = [
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.39 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.50 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2310,8 +2305,8 @@ dependencies = [
|
|||
"mime_guess 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"multipart 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"scoped-tls 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.39 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_json 1.0.50 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"serde_urlencoded 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tokio 0.1.19 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"tokio-io 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
|
@ -2562,7 +2557,6 @@ dependencies = [
|
|||
"checksum rent_to_own 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "05a51ad2b1c5c710fa89e6b1631068dab84ed687bc6a5fe061ad65da3d0c25b2"
|
||||
"checksum rustc-demangle 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)" = "ccc78bfd5acd7bf3e89cffcf899e5cb1a52d6fafa8dec2739ad70c9577a57288"
|
||||
"checksum rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
|
||||
"checksum ryu 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "b96a9549dc8d48f2c283938303c4b5a77aa29bfbc5b54b084fb1630408899a8f"
|
||||
"checksum ryu 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c92464b447c0ee8c4fb3824ecc8383b81717b9f1e74ba2e72540aef7b9f82997"
|
||||
"checksum safemem 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d2b08423011dae9a5ca23f07cf57dac3857f5c885d352b76f6d95f4aea9434d0"
|
||||
"checksum same-file 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "585e8ddcedc187886a30fa705c47985c3fa88d06624095856b36ca0b82ff4421"
|
||||
|
@ -2572,9 +2566,9 @@ dependencies = [
|
|||
"checksum scopeguard 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b42e15e59b18a828bbf5c58ea01debb36b9b096346de35d941dcb89009f24a0d"
|
||||
"checksum semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
|
||||
"checksum semver-parser 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
||||
"checksum serde 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)" = "a72e9b96fa45ce22a4bc23da3858dfccfd60acd28a25bcd328a98fdd6bea43fd"
|
||||
"checksum serde_derive 1.0.91 (registry+https://github.com/rust-lang/crates.io-index)" = "101b495b109a3e3ca8c4cbe44cf62391527cdfb6ba15821c5ce80bcd5ea23f9f"
|
||||
"checksum serde_json 1.0.39 (registry+https://github.com/rust-lang/crates.io-index)" = "5a23aa71d4a4d43fdbfaac00eff68ba8a06a51759a89ac3304323e800c4dd40d"
|
||||
"checksum serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)" = "e707fbbf255b8fc8c3b99abb91e7257a622caeb20a9818cbadbeeede4e0932ff"
|
||||
"checksum serde_derive 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)" = "ac5d00fc561ba2724df6758a17de23df5914f20e41cb00f94d5b7ae42fffaff8"
|
||||
"checksum serde_json 1.0.50 (registry+https://github.com/rust-lang/crates.io-index)" = "78a7a12c167809363ec3bd7329fc0a3369056996de43c4b37ef3cd54a6ce4867"
|
||||
"checksum serde_urlencoded 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)" = "9ec5d77e2d4c73717816afac02670d5c4f534ea95ed430442cad02e7a6e32c97"
|
||||
"checksum sha-1 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "23962131a91661d643c98940b20fcaffe62d776a823247be80a48fcb8b6fce68"
|
||||
"checksum sha2 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7b4d8bfd0e469f417657573d8451fb33d16cfe0989359b93baf3a1ffc639543d"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "flodgatt"
|
||||
description = "A blazingly fast drop-in replacement for the Mastodon streaming api server"
|
||||
version = "0.6.7"
|
||||
version = "0.6.8"
|
||||
authors = ["Daniel Long Sockwell <daniel@codesections.com", "Julian Laubstein <contact@julianlaubstein.de>"]
|
||||
edition = "2018"
|
||||
|
||||
|
@ -10,8 +10,8 @@ log = { version = "0.4.6", features = ["release_max_level_info"] }
|
|||
futures = "0.1.26"
|
||||
tokio = "0.1.19"
|
||||
warp = { git = "https://github.com/seanmonstar/warp.git"}
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.39"
|
||||
serde = { version = "1.0.105", features = ["derive"] }
|
||||
serde_json = "1.0.50"
|
||||
serde_derive = "1.0.90"
|
||||
pretty_env_logger = "0.3.0"
|
||||
postgres = "0.17.0"
|
||||
|
@ -36,4 +36,5 @@ harness = false
|
|||
|
||||
[features]
|
||||
default = [ "production" ]
|
||||
bench = []
|
||||
production = []
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use flodgatt::{
|
||||
messages::Event,
|
||||
messages::*,
|
||||
parse_client_request::{Content::*, Reach::*, Stream::*, Timeline},
|
||||
redis_to_client_stream::redis_msg::{RedisMsg, RedisParseOutput},
|
||||
redis_to_client_stream::{RedisMsg, RedisParseOutput},
|
||||
};
|
||||
use lru::LruCache;
|
||||
use std::convert::TryFrom;
|
||||
|
@ -17,30 +17,54 @@ fn parse_long_redis_input<'a>(input: &'a str) -> RedisMsg<'a> {
|
|||
}
|
||||
|
||||
fn parse_to_timeline(msg: RedisMsg) -> Timeline {
|
||||
let tl = Timeline::from_redis_text(msg.timeline_txt, &mut LruCache::new(1000), &None).unwrap();
|
||||
let trimmed_tl_txt = &msg.timeline_txt["timeline:".len()..];
|
||||
let tl = Timeline::from_redis_text(trimmed_tl_txt, &mut LruCache::new(1000)).unwrap();
|
||||
assert_eq!(tl, Timeline(User(1), Federated, All));
|
||||
tl
|
||||
}
|
||||
fn parse_to_checked_event(msg: RedisMsg) -> Event {
|
||||
Event::TypeSafe(serde_json::from_str(msg.event_txt).unwrap())
|
||||
}
|
||||
|
||||
fn parse_to_event(msg: RedisMsg) -> Event {
|
||||
serde_json::from_str(msg.event_txt).unwrap()
|
||||
fn parse_to_dyn_event(msg: RedisMsg) -> String {
|
||||
let event: Event = Event::Dynamic(serde_json::from_str(msg.event_txt).unwrap());
|
||||
event.to_json_string()
|
||||
}
|
||||
|
||||
fn redis_msg_to_event_string(msg: RedisMsg) -> String {
|
||||
msg.event_txt.to_string()
|
||||
}
|
||||
|
||||
fn string_to_checked_event(event_txt: &String) -> Event {
|
||||
Event::TypeSafe(serde_json::from_str(event_txt).unwrap())
|
||||
}
|
||||
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
let input = ONE_MESSAGE_FOR_THE_USER_TIMLINE_FROM_REDIS;
|
||||
let mut group = c.benchmark_group("Parse redis RESP array");
|
||||
|
||||
group.bench_function("parse redis input to RedisMsg", |b| {
|
||||
b.iter(|| black_box(parse_long_redis_input(input)))
|
||||
});
|
||||
// group.bench_function("parse redis input to RedisMsg", |b| {
|
||||
// b.iter(|| black_box(parse_long_redis_input(input)))
|
||||
// });
|
||||
|
||||
let msg = parse_long_redis_input(input);
|
||||
group.bench_function("parse RedisMsg to Timeline", |b| {
|
||||
b.iter(|| black_box(parse_to_timeline(msg.clone())))
|
||||
// group.bench_function("parse RedisMsg to Timeline", |b| {
|
||||
// b.iter(|| black_box(parse_to_timeline(msg.clone())))
|
||||
// });
|
||||
|
||||
group.bench_function("parse RedisMsg -> DynamicEvent -> JSON string", |b| {
|
||||
b.iter(|| black_box(parse_to_dyn_event(msg.clone())))
|
||||
});
|
||||
|
||||
group.bench_function("parse RedisMsg to Event", |b| {
|
||||
b.iter(|| black_box(parse_to_event(msg.clone())))
|
||||
group.bench_function("parse RedisMsg -> CheckedEvent", |b| {
|
||||
b.iter(|| black_box(parse_to_checked_event(msg.clone())))
|
||||
});
|
||||
|
||||
group.bench_function("parse RedisMsg -> String -> CheckedEvent", |b| {
|
||||
b.iter(|| {
|
||||
let txt = black_box(redis_msg_to_event_string(msg.clone()));
|
||||
black_box(string_to_checked_event(&txt));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,447 @@
|
|||
use crate::log_fatal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::boxed::Box;
|
||||
use std::{collections::HashSet, string::String};
|
||||
|
||||
pub enum Event {
|
||||
TypeSafe(CheckedEvent),
|
||||
Dynamic(DynamicEvent),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn to_json_string(&self) -> String {
|
||||
let event = &self.event_name();
|
||||
let sendable_event = match self.payload() {
|
||||
Some(payload) => SendableEvent::WithPayload { event, payload },
|
||||
None => SendableEvent::NoPayload { event },
|
||||
};
|
||||
serde_json::to_string(&sendable_event)
|
||||
.unwrap_or_else(|_| log_fatal!("Could not serialize `{:?}`", &sendable_event))
|
||||
}
|
||||
|
||||
pub fn event_name(&self) -> String {
|
||||
String::from(match self {
|
||||
Self::TypeSafe(checked) => match checked {
|
||||
CheckedEvent::Update { .. } => "update",
|
||||
CheckedEvent::Notification { .. } => "notification",
|
||||
CheckedEvent::Delete { .. } => "delete",
|
||||
CheckedEvent::Announcement { .. } => "announcement",
|
||||
CheckedEvent::AnnouncementReaction { .. } => "announcement.reaction",
|
||||
CheckedEvent::AnnouncementDelete { .. } => "announcement.delete",
|
||||
CheckedEvent::Conversation { .. } => "conversation",
|
||||
CheckedEvent::FiltersChanged => "filters_changed",
|
||||
},
|
||||
Self::Dynamic(dyn_event) => &dyn_event.event,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn payload(&self) -> Option<String> {
|
||||
use CheckedEvent::*;
|
||||
match self {
|
||||
Self::TypeSafe(checked) => match checked {
|
||||
Update { payload, .. } => Some(escaped(payload)),
|
||||
Notification { payload, .. } => Some(escaped(payload)),
|
||||
Delete { payload, .. } => Some(payload.0.clone()),
|
||||
Announcement { payload, .. } => Some(escaped(payload)),
|
||||
AnnouncementReaction { payload, .. } => Some(escaped(payload)),
|
||||
AnnouncementDelete { payload, .. } => Some(payload.0.clone()),
|
||||
Conversation { payload, .. } => Some(escaped(payload)),
|
||||
FiltersChanged => None,
|
||||
},
|
||||
Self::Dynamic(dyn_event) => Some(dyn_event.payload.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DynamicEvent {
|
||||
pub event: String,
|
||||
payload: Value,
|
||||
queued_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "snake_case", tag = "event", deny_unknown_fields)]
|
||||
#[rustfmt::skip]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub enum CheckedEvent {
|
||||
Update { payload: Status, queued_at: Option<i64> },
|
||||
Notification { payload: Notification },
|
||||
Delete { payload: DeletedId },
|
||||
FiltersChanged,
|
||||
Announcement { payload: Announcement },
|
||||
#[serde(rename(serialize = "announcement.reaction", deserialize = "announcement.reaction"))]
|
||||
AnnouncementReaction { payload: AnnouncementReaction },
|
||||
#[serde(rename(serialize = "announcement.delete", deserialize = "announcement.delete"))]
|
||||
AnnouncementDelete { payload: DeletedId },
|
||||
Conversation { payload: Conversation, queued_at: Option<i64> },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum SendableEvent<'a> {
|
||||
WithPayload { event: &'a str, payload: String },
|
||||
NoPayload { event: &'a str },
|
||||
}
|
||||
|
||||
fn escaped<T: Serialize + std::fmt::Debug>(content: T) -> String {
|
||||
serde_json::to_string(&content)
|
||||
.unwrap_or_else(|_| log_fatal!("Could not parse Event with: `{:?}`", &content))
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Conversation {
|
||||
id: String,
|
||||
accounts: Vec<Account>,
|
||||
unread: bool,
|
||||
last_status: Option<Status>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DeletedId(String);
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Status {
|
||||
id: String,
|
||||
uri: String,
|
||||
created_at: String,
|
||||
account: Account,
|
||||
content: String,
|
||||
visibility: Visibility,
|
||||
sensitive: bool,
|
||||
spoiler_text: String,
|
||||
media_attachments: Vec<Attachment>,
|
||||
application: Option<Application>, // Should be non-optional?
|
||||
mentions: Vec<Mention>,
|
||||
tags: Vec<Tag>,
|
||||
emojis: Vec<Emoji>,
|
||||
reblogs_count: i64,
|
||||
favourites_count: i64,
|
||||
replies_count: i64,
|
||||
url: Option<String>,
|
||||
in_reply_to_id: Option<String>,
|
||||
in_reply_to_account_id: Option<String>,
|
||||
reblog: Option<Box<Status>>,
|
||||
poll: Option<Poll>,
|
||||
card: Option<Card>,
|
||||
language: Option<String>,
|
||||
text: Option<String>,
|
||||
// ↓↓↓ Only for authorized users
|
||||
favourited: Option<bool>,
|
||||
reblogged: Option<bool>,
|
||||
muted: Option<bool>,
|
||||
bookmarked: Option<bool>,
|
||||
pinned: Option<bool>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Unlisted,
|
||||
Private,
|
||||
Direct,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Account {
|
||||
id: String,
|
||||
username: String,
|
||||
acct: String,
|
||||
url: String,
|
||||
display_name: String,
|
||||
note: String,
|
||||
avatar: String,
|
||||
avatar_static: String,
|
||||
header: String,
|
||||
header_static: String,
|
||||
locked: bool,
|
||||
emojis: Vec<Emoji>,
|
||||
discoverable: Option<bool>, // Shouldn't be option?
|
||||
created_at: String,
|
||||
statuses_count: i64,
|
||||
followers_count: i64,
|
||||
following_count: i64,
|
||||
moved: Option<Box<String>>,
|
||||
fields: Option<Vec<Field>>,
|
||||
bot: Option<bool>,
|
||||
source: Option<Source>,
|
||||
group: Option<bool>, // undocumented
|
||||
last_status_at: Option<String>, // undocumented
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Attachment {
|
||||
id: String,
|
||||
r#type: AttachmentType,
|
||||
url: String,
|
||||
preview_url: String,
|
||||
remote_url: Option<String>,
|
||||
text_url: Option<String>,
|
||||
meta: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
blurhash: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum AttachmentType {
|
||||
Unknown,
|
||||
Image,
|
||||
Gifv,
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Application {
|
||||
name: String,
|
||||
website: Option<String>,
|
||||
vapid_key: Option<String>,
|
||||
client_id: Option<String>,
|
||||
client_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Emoji {
|
||||
shortcode: String,
|
||||
url: String,
|
||||
static_url: String,
|
||||
visible_in_picker: bool,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Field {
|
||||
name: String,
|
||||
value: String,
|
||||
verified_at: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Source {
|
||||
note: String,
|
||||
fields: Vec<Field>,
|
||||
privacy: Option<Visibility>,
|
||||
sensitive: bool,
|
||||
language: String,
|
||||
follow_requests_count: i64,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Mention {
|
||||
id: String,
|
||||
username: String,
|
||||
acct: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Tag {
|
||||
name: String,
|
||||
url: String,
|
||||
history: Option<Vec<History>>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Poll {
|
||||
id: String,
|
||||
expires_at: String,
|
||||
expired: bool,
|
||||
multiple: bool,
|
||||
votes_count: i64,
|
||||
voters_count: Option<i64>,
|
||||
voted: Option<bool>,
|
||||
own_votes: Option<Vec<i64>>,
|
||||
options: Vec<PollOptions>,
|
||||
emojis: Vec<Emoji>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct PollOptions {
|
||||
title: String,
|
||||
votes_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Card {
|
||||
url: String,
|
||||
title: String,
|
||||
description: String,
|
||||
r#type: CardType,
|
||||
author_name: Option<String>,
|
||||
author_url: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
provider_url: Option<String>,
|
||||
html: Option<String>,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
image: Option<String>,
|
||||
embed_url: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum CardType {
|
||||
Link,
|
||||
Photo,
|
||||
Video,
|
||||
Rich,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct History {
|
||||
day: String,
|
||||
uses: String,
|
||||
accounts: String,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Notification {
|
||||
id: String,
|
||||
r#type: NotificationType,
|
||||
created_at: String,
|
||||
account: Account,
|
||||
status: Option<Status>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum NotificationType {
|
||||
Follow,
|
||||
FollowRequest, // Undocumented
|
||||
Mention,
|
||||
Reblog,
|
||||
Favourite,
|
||||
Poll,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Announcement {
|
||||
// Fully undocumented
|
||||
id: String,
|
||||
tags: Vec<Tag>,
|
||||
all_day: bool,
|
||||
content: String,
|
||||
emojis: Vec<Emoji>,
|
||||
starts_at: Option<String>,
|
||||
ends_at: Option<String>,
|
||||
published_at: String,
|
||||
updated_at: String,
|
||||
mentions: Vec<Mention>,
|
||||
reactions: Vec<AnnouncementReaction>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct AnnouncementReaction {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
announcement_id: Option<String>,
|
||||
count: i64,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// Returns `true` if the status is filtered out based on its language
|
||||
pub fn language_not_allowed(&self, allowed_langs: &HashSet<String>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
|
||||
let reject_and_maybe_log = |toot_language| {
|
||||
log::info!("Filtering out toot from `{}`", &self.account.acct);
|
||||
log::info!("Toot language: `{}`", toot_language);
|
||||
log::info!("Recipient's allowed languages: `{:?}`", allowed_langs);
|
||||
REJECT
|
||||
};
|
||||
if allowed_langs.is_empty() {
|
||||
return ALLOW; // listing no allowed_langs results in allowing all languages
|
||||
}
|
||||
|
||||
match self.language.as_ref() {
|
||||
Some(toot_language) if allowed_langs.contains(toot_language) => ALLOW,
|
||||
None => ALLOW, // If toot language is unknown, toot is always allowed
|
||||
Some(empty) if empty == &String::new() => ALLOW,
|
||||
Some(toot_language) => reject_and_maybe_log(toot_language),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this toot originated from a domain the User has blocked.
|
||||
pub fn from_blocked_domain(&self, blocked_domains: &HashSet<String>) -> bool {
|
||||
let full_username = &self.account.acct;
|
||||
|
||||
match full_username.split('@').nth(1) {
|
||||
Some(originating_domain) => blocked_domains.contains(originating_domain),
|
||||
None => false, // None means the user is on the local instance, which can't be blocked
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the Status is from an account that has blocked the current user.
|
||||
pub fn from_blocking_user(&self, blocking_users: &HashSet<i64>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
let err = |_| log_fatal!("Could not process `account.id` in {:?}", &self);
|
||||
|
||||
if blocking_users.contains(&self.account.id.parse().unwrap_or_else(err)) {
|
||||
REJECT
|
||||
} else {
|
||||
ALLOW
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the User's list of blocked and muted users includes a user
|
||||
/// involved in this toot.
|
||||
///
|
||||
/// A user is involved if they:
|
||||
/// * Are mentioned in this toot
|
||||
/// * Wrote this toot
|
||||
/// * Wrote a toot that this toot is replying to (if any)
|
||||
/// * Wrote the toot that this toot is boosting (if any)
|
||||
pub fn involves_blocked_user(&self, blocked_users: &HashSet<i64>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
let err = |_| log_fatal!("Could not process an `id` field in {:?}", &self);
|
||||
|
||||
// involved_users = mentioned_users + author + replied-to user + boosted user
|
||||
let mut involved_users: HashSet<i64> = self
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|mention| mention.id.parse().unwrap_or_else(err))
|
||||
.collect();
|
||||
|
||||
involved_users.insert(self.account.id.parse::<i64>().unwrap_or_else(err));
|
||||
|
||||
if let Some(replied_to_account_id) = self.in_reply_to_account_id.clone() {
|
||||
involved_users.insert(replied_to_account_id.parse().unwrap_or_else(err));
|
||||
}
|
||||
|
||||
if let Some(boosted_status) = self.reblog.clone() {
|
||||
involved_users.insert(boosted_status.account.id.parse().unwrap_or_else(err));
|
||||
}
|
||||
|
||||
if involved_users.is_disjoint(blocked_users) {
|
||||
ALLOW
|
||||
} else {
|
||||
REJECT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
|
@ -0,0 +1,49 @@
|
|||
use super::{emoji::Emoji, visibility::Visibility};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Account {
|
||||
pub id: String,
|
||||
username: String,
|
||||
pub acct: String,
|
||||
url: String,
|
||||
display_name: String,
|
||||
note: String,
|
||||
avatar: String,
|
||||
avatar_static: String,
|
||||
header: String,
|
||||
header_static: String,
|
||||
locked: bool,
|
||||
emojis: Vec<Emoji>,
|
||||
discoverable: Option<bool>, // Shouldn't be option?
|
||||
created_at: String,
|
||||
statuses_count: i64,
|
||||
followers_count: i64,
|
||||
following_count: i64,
|
||||
moved: Option<Box<String>>,
|
||||
fields: Option<Vec<Field>>,
|
||||
bot: Option<bool>,
|
||||
source: Option<Source>,
|
||||
group: Option<bool>, // undocumented
|
||||
last_status_at: Option<String>, // undocumented
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Field {
|
||||
name: String,
|
||||
value: String,
|
||||
verified_at: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Source {
|
||||
note: String,
|
||||
fields: Vec<Field>,
|
||||
privacy: Option<Visibility>,
|
||||
sensitive: bool,
|
||||
language: String,
|
||||
follow_requests_count: i64,
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
use super::{emoji::Emoji, mention::Mention, tag::Tag, AnnouncementReaction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Announcement {
|
||||
// Fully undocumented
|
||||
id: String,
|
||||
tags: Vec<Tag>,
|
||||
all_day: bool,
|
||||
content: String,
|
||||
emojis: Vec<Emoji>,
|
||||
starts_at: Option<String>,
|
||||
ends_at: Option<String>,
|
||||
published_at: String,
|
||||
updated_at: String,
|
||||
mentions: Vec<Mention>,
|
||||
reactions: Vec<AnnouncementReaction>,
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct AnnouncementReaction {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
announcement_id: Option<String>,
|
||||
count: i64,
|
||||
name: String,
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
use super::{account::Account, status::Status};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Conversation {
|
||||
id: String,
|
||||
accounts: Vec<Account>,
|
||||
unread: bool,
|
||||
last_status: Option<Status>,
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Emoji {
|
||||
shortcode: String,
|
||||
url: String,
|
||||
static_url: String,
|
||||
visible_in_picker: bool,
|
||||
category: Option<String>,
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Mention {
|
||||
pub id: String,
|
||||
username: String,
|
||||
acct: String,
|
||||
url: String,
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
mod account;
|
||||
|
||||
mod announcement;
|
||||
mod announcement_reaction;
|
||||
mod conversation;
|
||||
mod emoji;
|
||||
mod mention;
|
||||
mod notification;
|
||||
mod status;
|
||||
mod tag;
|
||||
mod visibility;
|
||||
|
||||
pub use announcement::Announcement;
|
||||
pub(in crate::messages::event) use announcement_reaction::AnnouncementReaction;
|
||||
pub use conversation::Conversation;
|
||||
pub use notification::Notification;
|
||||
pub use status::Status;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[serde(rename_all = "snake_case", tag = "event", deny_unknown_fields)]
|
||||
#[rustfmt::skip]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub enum CheckedEvent {
|
||||
Update { payload: Status, queued_at: Option<i64> },
|
||||
Notification { payload: Notification },
|
||||
Delete { payload: String },
|
||||
FiltersChanged,
|
||||
Announcement { payload: Announcement },
|
||||
#[serde(rename(serialize = "announcement.reaction", deserialize = "announcement.reaction"))]
|
||||
AnnouncementReaction { payload: AnnouncementReaction },
|
||||
#[serde(rename(serialize = "announcement.delete", deserialize = "announcement.delete"))]
|
||||
AnnouncementDelete { payload: String },
|
||||
Conversation { payload: Conversation, queued_at: Option<i64> },
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
use super::{account::Account, status::Status};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Notification {
|
||||
id: String,
|
||||
r#type: NotificationType,
|
||||
created_at: String,
|
||||
account: Account,
|
||||
status: Option<Status>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum NotificationType {
|
||||
Follow,
|
||||
FollowRequest, // Undocumented
|
||||
Mention,
|
||||
Reblog,
|
||||
Favourite,
|
||||
Poll,
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Application {
|
||||
name: String,
|
||||
website: Option<String>,
|
||||
vapid_key: Option<String>,
|
||||
client_id: Option<String>,
|
||||
client_secret: Option<String>,
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Attachment {
|
||||
id: String,
|
||||
r#type: AttachmentType,
|
||||
url: String,
|
||||
preview_url: String,
|
||||
remote_url: Option<String>,
|
||||
text_url: Option<String>,
|
||||
meta: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
blurhash: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum AttachmentType {
|
||||
Unknown,
|
||||
Image,
|
||||
Gifv,
|
||||
Video,
|
||||
Audio,
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Card {
|
||||
url: String,
|
||||
title: String,
|
||||
description: String,
|
||||
r#type: CardType,
|
||||
author_name: Option<String>,
|
||||
author_url: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
provider_url: Option<String>,
|
||||
html: Option<String>,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
image: Option<String>,
|
||||
embed_url: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum CardType {
|
||||
Link,
|
||||
Photo,
|
||||
Video,
|
||||
Rich,
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
mod application;
|
||||
mod attachment;
|
||||
mod card;
|
||||
mod poll;
|
||||
|
||||
use super::{account::Account, emoji::Emoji, mention::Mention, tag::Tag, visibility::Visibility};
|
||||
use {application::Application, attachment::Attachment, card::Card, poll::Poll};
|
||||
|
||||
use crate::log_fatal;
|
||||
use crate::parse_client_request::Blocks;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::boxed::Box;
|
||||
use std::{collections::HashSet, string::String};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Status {
|
||||
id: String,
|
||||
uri: String,
|
||||
created_at: String,
|
||||
account: Account,
|
||||
content: String,
|
||||
visibility: Visibility,
|
||||
sensitive: bool,
|
||||
spoiler_text: String,
|
||||
media_attachments: Vec<Attachment>,
|
||||
application: Option<Application>, // Should be non-optional?
|
||||
mentions: Vec<Mention>,
|
||||
tags: Vec<Tag>,
|
||||
emojis: Vec<Emoji>,
|
||||
reblogs_count: i64,
|
||||
favourites_count: i64,
|
||||
replies_count: i64,
|
||||
url: Option<String>,
|
||||
in_reply_to_id: Option<String>,
|
||||
in_reply_to_account_id: Option<String>,
|
||||
reblog: Option<Box<Status>>,
|
||||
poll: Option<Poll>,
|
||||
card: Option<Card>,
|
||||
language: Option<String>,
|
||||
text: Option<String>,
|
||||
// ↓↓↓ Only for authorized users
|
||||
favourited: Option<bool>,
|
||||
reblogged: Option<bool>,
|
||||
muted: Option<bool>,
|
||||
bookmarked: Option<bool>,
|
||||
pinned: Option<bool>,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// Returns `true` if the status is filtered out based on its language
|
||||
pub fn language_not(&self, allowed_langs: &HashSet<String>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
|
||||
let reject_and_maybe_log = |toot_language| {
|
||||
log::info!("Filtering out toot from `{}`", &self.account.acct);
|
||||
log::info!("Toot language: `{}`", toot_language);
|
||||
log::info!("Recipient's allowed languages: `{:?}`", allowed_langs);
|
||||
REJECT
|
||||
};
|
||||
if allowed_langs.is_empty() {
|
||||
return ALLOW; // listing no allowed_langs results in allowing all languages
|
||||
}
|
||||
|
||||
match self.language.as_ref() {
|
||||
Some(toot_language) if allowed_langs.contains(toot_language) => ALLOW,
|
||||
None => ALLOW, // If toot language is unknown, toot is always allowed
|
||||
Some(empty) if empty == &String::new() => ALLOW,
|
||||
Some(toot_language) => reject_and_maybe_log(toot_language),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the Status originated from a blocked domain, is from an account
|
||||
/// that has blocked the current user, or if the User's list of blocked/muted users
|
||||
/// includes a user involved in the Status.
|
||||
///
|
||||
/// A user is involved in the Status/toot if they:
|
||||
/// * Are mentioned in this toot
|
||||
/// * Wrote this toot
|
||||
/// * Wrote a toot that this toot is replying to (if any)
|
||||
/// * Wrote the toot that this toot is boosting (if any)
|
||||
pub fn involves_any(&self, blocks: &Blocks) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
|
||||
let Blocks {
|
||||
blocked_users,
|
||||
blocking_users,
|
||||
blocked_domains,
|
||||
} = blocks;
|
||||
|
||||
if !self.calculate_involved_users().is_disjoint(blocked_users) {
|
||||
REJECT
|
||||
} else if blocking_users.contains(&self.account.id.parse().expect("TODO")) {
|
||||
REJECT
|
||||
} else {
|
||||
let full_username = &self.account.acct;
|
||||
match full_username.split('@').nth(1) {
|
||||
Some(originating_domain) if blocked_domains.contains(originating_domain) => REJECT,
|
||||
Some(_) | None => ALLOW, // None means the local instance, which can't be blocked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_involved_users(&self) -> HashSet<i64> {
|
||||
// TODO replace vvvv with error handling
|
||||
let err = |_| log_fatal!("Could not process an `id` field in {:?}", &self);
|
||||
|
||||
// involved_users = mentioned_users + author + replied-to user + boosted user
|
||||
let mut involved_users: HashSet<i64> = self
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|mention| mention.id.parse().unwrap_or_else(err))
|
||||
.collect();
|
||||
|
||||
// author
|
||||
involved_users.insert(self.account.id.parse::<i64>().unwrap_or_else(err));
|
||||
// replied-to user
|
||||
if let Some(user_id) = self.in_reply_to_account_id.clone() {
|
||||
involved_users.insert(user_id.parse().unwrap_or_else(err));
|
||||
}
|
||||
// boosted user
|
||||
if let Some(boosted_status) = self.reblog.clone() {
|
||||
involved_users.insert(boosted_status.account.id.parse().unwrap_or_else(err));
|
||||
}
|
||||
involved_users
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
use super::super::emoji::Emoji;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Poll {
|
||||
id: String,
|
||||
expires_at: String,
|
||||
expired: bool,
|
||||
multiple: bool,
|
||||
votes_count: i64,
|
||||
voters_count: Option<i64>,
|
||||
voted: Option<bool>,
|
||||
own_votes: Option<Vec<i64>>,
|
||||
options: Vec<PollOptions>,
|
||||
emojis: Vec<Emoji>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct PollOptions {
|
||||
title: String,
|
||||
votes_count: Option<i32>,
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) struct Tag {
|
||||
name: String,
|
||||
url: String,
|
||||
history: Option<Vec<History>>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct History {
|
||||
day: String,
|
||||
uses: String,
|
||||
accounts: String,
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub(super) enum Visibility {
|
||||
Public,
|
||||
Unlisted,
|
||||
Private,
|
||||
Direct,
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
use crate::parse_client_request::Blocks;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct DynamicEvent {
|
||||
pub event: String,
|
||||
pub payload: Value,
|
||||
queued_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl DynamicEvent {
|
||||
/// Returns `true` if the status is filtered out based on its language
|
||||
pub fn language_not(&self, allowed_langs: &HashSet<String>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
|
||||
if allowed_langs.is_empty() {
|
||||
return ALLOW; // listing no allowed_langs results in allowing all languages
|
||||
}
|
||||
|
||||
match self.payload["language"].as_str() {
|
||||
Some(toot_language) if allowed_langs.contains(toot_language) => ALLOW,
|
||||
None => ALLOW, // If toot language is unknown, toot is always allowed
|
||||
Some(empty) if empty == &String::new() => ALLOW,
|
||||
Some(_toot_language) => REJECT,
|
||||
}
|
||||
}
|
||||
/// Returns `true` if the toot contained in this Event originated from a blocked domain,
|
||||
/// is from an account that has blocked the current user, or if the User's list of
|
||||
/// blocked/muted users includes a user involved in the toot.
|
||||
///
|
||||
/// A user is involved in the toot if they:
|
||||
/// * Are mentioned in this toot
|
||||
/// * Wrote this toot
|
||||
/// * Wrote a toot that this toot is replying to (if any)
|
||||
/// * Wrote the toot that this toot is boosting (if any)
|
||||
pub fn involves_any(&self, blocks: &Blocks) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
let Blocks {
|
||||
blocked_users,
|
||||
blocking_users,
|
||||
blocked_domains,
|
||||
} = blocks;
|
||||
|
||||
let user_id = self.payload["account"]["id"].as_str().expect("TODO");
|
||||
let username = self.payload["account"]["acct"].as_str().expect("TODO");
|
||||
|
||||
if !self.calculate_involved_users().is_disjoint(blocked_users) {
|
||||
REJECT
|
||||
} else if blocking_users.contains(&user_id.parse().expect("TODO")) {
|
||||
REJECT
|
||||
} else {
|
||||
let full_username = &username;
|
||||
match full_username.split('@').nth(1) {
|
||||
Some(originating_domain) if blocked_domains.contains(originating_domain) => REJECT,
|
||||
Some(_) | None => ALLOW, // None means the local instance, which can't be blocked
|
||||
}
|
||||
}
|
||||
}
|
||||
fn calculate_involved_users(&self) -> HashSet<i64> {
|
||||
let mentions = self.payload["mentions"].as_array().expect("TODO");
|
||||
// involved_users = mentioned_users + author + replied-to user + boosted user
|
||||
let mut involved_users: HashSet<i64> = mentions
|
||||
.iter()
|
||||
.map(|mention| mention["id"].as_str().expect("TODO").parse().expect("TODO"))
|
||||
.collect();
|
||||
|
||||
// author
|
||||
let author_id = self.payload["account"]["id"].as_str().expect("TODO");
|
||||
involved_users.insert(author_id.parse::<i64>().expect("TODO"));
|
||||
// replied-to user
|
||||
let replied_to_user = self.payload["in_reply_to_account_id"].as_str();
|
||||
if let Some(user_id) = replied_to_user.clone() {
|
||||
involved_users.insert(user_id.parse().expect("TODO"));
|
||||
}
|
||||
// boosted user
|
||||
|
||||
let id_of_boosted_user = self.payload["reblog"]["account"]["id"]
|
||||
.as_str()
|
||||
.expect("TODO");
|
||||
involved_users.insert(id_of_boosted_user.parse().expect("TODO"));
|
||||
|
||||
involved_users
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
mod checked_event;
|
||||
mod dynamic_event;
|
||||
|
||||
pub use {checked_event::CheckedEvent, dynamic_event::DynamicEvent};
|
||||
|
||||
use crate::log_fatal;
|
||||
use serde::Serialize;
|
||||
use std::string::String;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
TypeSafe(CheckedEvent),
|
||||
Dynamic(DynamicEvent),
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn to_json_string(&self) -> String {
|
||||
let event = &self.event_name();
|
||||
let sendable_event = match self.payload() {
|
||||
Some(payload) => SendableEvent::WithPayload { event, payload },
|
||||
None => SendableEvent::NoPayload { event },
|
||||
};
|
||||
serde_json::to_string(&sendable_event)
|
||||
.unwrap_or_else(|_| log_fatal!("Could not serialize `{:?}`", &sendable_event))
|
||||
}
|
||||
|
||||
pub fn event_name(&self) -> String {
|
||||
String::from(match self {
|
||||
Self::TypeSafe(checked) => match checked {
|
||||
CheckedEvent::Update { .. } => "update",
|
||||
CheckedEvent::Notification { .. } => "notification",
|
||||
CheckedEvent::Delete { .. } => "delete",
|
||||
CheckedEvent::Announcement { .. } => "announcement",
|
||||
CheckedEvent::AnnouncementReaction { .. } => "announcement.reaction",
|
||||
CheckedEvent::AnnouncementDelete { .. } => "announcement.delete",
|
||||
CheckedEvent::Conversation { .. } => "conversation",
|
||||
CheckedEvent::FiltersChanged => "filters_changed",
|
||||
},
|
||||
Self::Dynamic(dyn_event) => &dyn_event.event,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn payload(&self) -> Option<String> {
|
||||
use CheckedEvent::*;
|
||||
match self {
|
||||
Self::TypeSafe(checked) => match checked {
|
||||
Update { payload, .. } => Some(escaped(payload)),
|
||||
Notification { payload, .. } => Some(escaped(payload)),
|
||||
Delete { payload, .. } => Some(payload.clone()),
|
||||
Announcement { payload, .. } => Some(escaped(payload)),
|
||||
AnnouncementReaction { payload, .. } => Some(escaped(payload)),
|
||||
AnnouncementDelete { payload, .. } => Some(payload.clone()),
|
||||
Conversation { payload, .. } => Some(escaped(payload)),
|
||||
FiltersChanged => None,
|
||||
},
|
||||
Self::Dynamic(dyn_event) => Some(dyn_event.payload.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Event {
|
||||
fn from(event_txt: String) -> Event {
|
||||
Event::from(event_txt.as_str())
|
||||
}
|
||||
}
|
||||
impl From<&str> for Event {
|
||||
fn from(event_txt: &str) -> Event {
|
||||
match serde_json::from_str(event_txt) {
|
||||
Ok(checked_event) => Event::TypeSafe(checked_event),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Error safely parsing Redis input. Mastodon and Flodgatt do not \
|
||||
strictly conform to the same version of Mastodon's API.\n{}\
|
||||
Forwarding Redis payload without type checking it.",
|
||||
e
|
||||
);
|
||||
let dyn_event: DynamicEvent = serde_json::from_str(&event_txt).expect("TODO");
|
||||
Event::Dynamic(dyn_event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum SendableEvent<'a> {
|
||||
WithPayload { event: &'a str, payload: String },
|
||||
NoPayload { event: &'a str },
|
||||
}
|
||||
|
||||
fn escaped<T: Serialize + std::fmt::Debug>(content: T) -> String {
|
||||
serde_json::to_string(&content)
|
||||
.unwrap_or_else(|_| log_fatal!("Could not parse Event with: `{:?}`", &content))
|
||||
}
|
|
@ -1,430 +1,3 @@
|
|||
use crate::log_fatal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::boxed::Box;
|
||||
use std::{collections::HashSet, string::String};
|
||||
mod event;
|
||||
|
||||
#[serde(rename_all = "snake_case", tag = "event", deny_unknown_fields)]
|
||||
#[rustfmt::skip]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub enum Event {
|
||||
Update { payload: Status, queued_at: Option<i64> },
|
||||
Notification { payload: Notification },
|
||||
Delete { payload: DeletedId },
|
||||
FiltersChanged,
|
||||
Announcement { payload: Announcement },
|
||||
#[serde(rename(serialize = "announcement.reaction", deserialize = "announcement.reaction"))]
|
||||
AnnouncementReaction { payload: AnnouncementReaction },
|
||||
#[serde(rename(serialize = "announcement.delete", deserialize = "announcement.delete"))]
|
||||
AnnouncementDelete { payload: DeletedId },
|
||||
Conversation { payload: Conversation, queued_at: Option<i64> },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum SendableEvent<'a> {
|
||||
WithPayload { event: &'a str, payload: String },
|
||||
NoPayload { event: &'a str },
|
||||
}
|
||||
#[rustfmt::skip]
|
||||
impl Event {
|
||||
pub fn event_name(&self) -> String {
|
||||
use Event::*;
|
||||
match self {
|
||||
Update { .. } => "update",
|
||||
Notification { .. } => "notification",
|
||||
Delete { .. } => "delete",
|
||||
Announcement { .. } => "announcement",
|
||||
AnnouncementReaction { .. } => "announcement.reaction",
|
||||
AnnouncementDelete { .. } => "announcement.delete",
|
||||
Conversation { .. } => "conversation",
|
||||
FiltersChanged => "filters_changed",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
|
||||
pub fn payload(&self) -> Option<String> {
|
||||
use Event::*;
|
||||
match self {
|
||||
Update { payload: status, .. } => Some(escaped(status)),
|
||||
Notification { payload: notification, .. } => Some(escaped(notification)),
|
||||
Delete { payload: id, .. } => Some(id.0.clone()),
|
||||
Announcement { payload: announcement, .. } => Some(escaped(announcement)),
|
||||
AnnouncementReaction { payload: reaction, .. } => Some(escaped(reaction)),
|
||||
AnnouncementDelete { payload: id, .. } => Some(id.0.clone()),
|
||||
Conversation { payload: conversation, ..} => Some(escaped(conversation)),
|
||||
FiltersChanged => None,
|
||||
}
|
||||
}
|
||||
pub fn to_json_string(&self) -> String {
|
||||
let event = &self.event_name();
|
||||
let sendable_event = match self.payload() {
|
||||
Some(payload) => SendableEvent::WithPayload { event, payload },
|
||||
None => SendableEvent::NoPayload { event },
|
||||
};
|
||||
serde_json::to_string(&sendable_event)
|
||||
.unwrap_or_else(|_| log_fatal!("Could not serialize `{:?}`", &sendable_event))
|
||||
}
|
||||
}
|
||||
|
||||
fn escaped<T: Serialize + std::fmt::Debug>(content: T) -> String {
|
||||
serde_json::to_string(&content)
|
||||
.unwrap_or_else(|_| log_fatal!("Could not parse Event with: `{:?}`", &content))
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Conversation {
|
||||
id: String,
|
||||
accounts: Vec<Account>,
|
||||
unread: bool,
|
||||
last_status: Option<Status>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DeletedId(String);
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Status {
|
||||
id: String,
|
||||
uri: String,
|
||||
created_at: String,
|
||||
account: Account,
|
||||
content: String,
|
||||
visibility: Visibility,
|
||||
sensitive: bool,
|
||||
spoiler_text: String,
|
||||
media_attachments: Vec<Attachment>,
|
||||
application: Option<Application>, // Should be non-optional?
|
||||
mentions: Vec<Mention>,
|
||||
tags: Vec<Tag>,
|
||||
emojis: Vec<Emoji>,
|
||||
reblogs_count: i64,
|
||||
favourites_count: i64,
|
||||
replies_count: i64,
|
||||
url: Option<String>,
|
||||
in_reply_to_id: Option<String>,
|
||||
in_reply_to_account_id: Option<String>,
|
||||
reblog: Option<Box<Status>>,
|
||||
poll: Option<Poll>,
|
||||
card: Option<Card>,
|
||||
language: Option<String>,
|
||||
text: Option<String>,
|
||||
// ↓↓↓ Only for authorized users
|
||||
favourited: Option<bool>,
|
||||
reblogged: Option<bool>,
|
||||
muted: Option<bool>,
|
||||
bookmarked: Option<bool>,
|
||||
pinned: Option<bool>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Unlisted,
|
||||
Private,
|
||||
Direct,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Account {
|
||||
id: String,
|
||||
username: String,
|
||||
acct: String,
|
||||
url: String,
|
||||
display_name: String,
|
||||
note: String,
|
||||
avatar: String,
|
||||
avatar_static: String,
|
||||
header: String,
|
||||
header_static: String,
|
||||
locked: bool,
|
||||
emojis: Vec<Emoji>,
|
||||
discoverable: Option<bool>, // Shouldn't be option?
|
||||
created_at: String,
|
||||
statuses_count: i64,
|
||||
followers_count: i64,
|
||||
following_count: i64,
|
||||
moved: Option<Box<String>>,
|
||||
fields: Option<Vec<Field>>,
|
||||
bot: Option<bool>,
|
||||
source: Option<Source>,
|
||||
group: Option<bool>, // undocumented
|
||||
last_status_at: Option<String>, // undocumented
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Attachment {
|
||||
id: String,
|
||||
r#type: AttachmentType,
|
||||
url: String,
|
||||
preview_url: String,
|
||||
remote_url: Option<String>,
|
||||
text_url: Option<String>,
|
||||
meta: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
blurhash: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum AttachmentType {
|
||||
Unknown,
|
||||
Image,
|
||||
Gifv,
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Application {
|
||||
name: String,
|
||||
website: Option<String>,
|
||||
vapid_key: Option<String>,
|
||||
client_id: Option<String>,
|
||||
client_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Emoji {
|
||||
shortcode: String,
|
||||
url: String,
|
||||
static_url: String,
|
||||
visible_in_picker: bool,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Field {
|
||||
name: String,
|
||||
value: String,
|
||||
verified_at: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Source {
|
||||
note: String,
|
||||
fields: Vec<Field>,
|
||||
privacy: Option<Visibility>,
|
||||
sensitive: bool,
|
||||
language: String,
|
||||
follow_requests_count: i64,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Mention {
|
||||
id: String,
|
||||
username: String,
|
||||
acct: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Tag {
|
||||
name: String,
|
||||
url: String,
|
||||
history: Option<Vec<History>>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Poll {
|
||||
id: String,
|
||||
expires_at: String,
|
||||
expired: bool,
|
||||
multiple: bool,
|
||||
votes_count: i64,
|
||||
voters_count: Option<i64>,
|
||||
voted: Option<bool>,
|
||||
own_votes: Option<Vec<i64>>,
|
||||
options: Vec<PollOptions>,
|
||||
emojis: Vec<Emoji>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct PollOptions {
|
||||
title: String,
|
||||
votes_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct Card {
|
||||
url: String,
|
||||
title: String,
|
||||
description: String,
|
||||
r#type: CardType,
|
||||
author_name: Option<String>,
|
||||
author_url: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
provider_url: Option<String>,
|
||||
html: Option<String>,
|
||||
width: Option<i64>,
|
||||
height: Option<i64>,
|
||||
image: Option<String>,
|
||||
embed_url: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum CardType {
|
||||
Link,
|
||||
Photo,
|
||||
Video,
|
||||
Rich,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct History {
|
||||
day: String,
|
||||
uses: String,
|
||||
accounts: String,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Notification {
|
||||
id: String,
|
||||
r#type: NotificationType,
|
||||
created_at: String,
|
||||
account: Account,
|
||||
status: Option<Status>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
enum NotificationType {
|
||||
Follow,
|
||||
FollowRequest, // Undocumented
|
||||
Mention,
|
||||
Reblog,
|
||||
Favourite,
|
||||
Poll,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Announcement {
|
||||
// Fully undocumented
|
||||
id: String,
|
||||
tags: Vec<Tag>,
|
||||
all_day: bool,
|
||||
content: String,
|
||||
emojis: Vec<Emoji>,
|
||||
starts_at: Option<String>,
|
||||
ends_at: Option<String>,
|
||||
published_at: String,
|
||||
updated_at: String,
|
||||
mentions: Vec<Mention>,
|
||||
reactions: Vec<AnnouncementReaction>,
|
||||
}
|
||||
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct AnnouncementReaction {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
announcement_id: Option<String>,
|
||||
count: i64,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// Returns `true` if the status is filtered out based on its language
|
||||
pub fn language_not_allowed(&self, allowed_langs: &HashSet<String>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
|
||||
let reject_and_maybe_log = |toot_language| {
|
||||
log::info!("Filtering out toot from `{}`", &self.account.acct);
|
||||
log::info!("Toot language: `{}`", toot_language);
|
||||
log::info!("Recipient's allowed languages: `{:?}`", allowed_langs);
|
||||
REJECT
|
||||
};
|
||||
if allowed_langs.is_empty() {
|
||||
return ALLOW; // listing no allowed_langs results in allowing all languages
|
||||
}
|
||||
|
||||
match self.language.as_ref() {
|
||||
Some(toot_language) if allowed_langs.contains(toot_language) => ALLOW,
|
||||
None => ALLOW, // If toot language is unknown, toot is always allowed
|
||||
Some(empty) if empty == &String::new() => ALLOW,
|
||||
Some(toot_language) => reject_and_maybe_log(toot_language),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this toot originated from a domain the User has blocked.
|
||||
pub fn from_blocked_domain(&self, blocked_domains: &HashSet<String>) -> bool {
|
||||
let full_username = &self.account.acct;
|
||||
|
||||
match full_username.split('@').nth(1) {
|
||||
Some(originating_domain) => blocked_domains.contains(originating_domain),
|
||||
None => false, // None means the user is on the local instance, which can't be blocked
|
||||
}
|
||||
}
|
||||
/// Returns `true` if the Status is from an account that has blocked the current user.
|
||||
pub fn from_blocking_user(&self, blocking_users: &HashSet<i64>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
let err = |_| log_fatal!("Could not process `account.id` in {:?}", &self);
|
||||
|
||||
if blocking_users.contains(&self.account.id.parse().unwrap_or_else(err)) {
|
||||
REJECT
|
||||
} else {
|
||||
ALLOW
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the User's list of blocked and muted users includes a user
|
||||
/// involved in this toot.
|
||||
///
|
||||
/// A user is involved if they:
|
||||
/// * Are mentioned in this toot
|
||||
/// * Wrote this toot
|
||||
/// * Wrote a toot that this toot is replying to (if any)
|
||||
/// * Wrote the toot that this toot is boosting (if any)
|
||||
pub fn involves_blocked_user(&self, blocked_users: &HashSet<i64>) -> bool {
|
||||
const ALLOW: bool = false;
|
||||
const REJECT: bool = true;
|
||||
let err = |_| log_fatal!("Could not process an `id` field in {:?}", &self);
|
||||
|
||||
// involved_users = mentioned_users + author + replied-to user + boosted user
|
||||
let mut involved_users: HashSet<i64> = self
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|mention| mention.id.parse().unwrap_or_else(err))
|
||||
.collect();
|
||||
|
||||
involved_users.insert(self.account.id.parse::<i64>().unwrap_or_else(err));
|
||||
|
||||
if let Some(replied_to_account_id) = self.in_reply_to_account_id.clone() {
|
||||
involved_users.insert(replied_to_account_id.parse().unwrap_or_else(err));
|
||||
}
|
||||
|
||||
if let Some(boosted_status) = self.reblog.clone() {
|
||||
involved_users.insert(boosted_status.account.id.parse().unwrap_or_else(err));
|
||||
}
|
||||
|
||||
if involved_users.is_disjoint(blocked_users) {
|
||||
ALLOW
|
||||
} else {
|
||||
REJECT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
pub use event::{CheckedEvent, DynamicEvent, Event};
|
||||
|
|
|
@ -1,3 +1,47 @@
|
|||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub enum Event {
|
||||
Update,
|
||||
Notification,
|
||||
Delete,
|
||||
FiltersChanged,
|
||||
Announcement,
|
||||
#[serde(rename(
|
||||
serialize = "announcement.reaction",
|
||||
deserialize = "announcement.reaction"
|
||||
))]
|
||||
AnnouncementReaction,
|
||||
#[serde(rename(serialize = "announcement.delete", deserialize = "announcement.delete"))]
|
||||
AnnouncementDelete,
|
||||
Conversation,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "snake_case", tag = "event")]
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Msg {
|
||||
event: Event,
|
||||
queued_at: Option<i64>,
|
||||
}
|
||||
#[test]
|
||||
fn parse_redis_msg_to() {
|
||||
let input = REDIS_MSG_EVENT_TXT;
|
||||
let msg: Msg = serde_json::from_str(input).expect("TODO");
|
||||
dbg!(&msg);
|
||||
let raw_txt = match (msg.event, msg.queued_at) {
|
||||
(Event::Update, Some(n)) => {
|
||||
&input[r#"{"event":"update","payload":"#.len()
|
||||
..input.len() - format!(",\"queued_at\":{}", n).len()]
|
||||
}
|
||||
_ => unimplemented!(),
|
||||
};
|
||||
dbg!(raw_txt);
|
||||
panic!("TODO");
|
||||
}
|
||||
|
||||
fn parse_redis_msg_to_old() {}
|
||||
// TODO: Revise these tests to cover *only* the RedisMessage -> (Timeline, Event) parsing
|
||||
// use super::*;
|
||||
// use crate::{
|
||||
|
@ -507,3 +551,5 @@
|
|||
// assert_eq!(rest, String::new());
|
||||
// Ok(())
|
||||
// }
|
||||
|
||||
const REDIS_MSG_EVENT_TXT: &str = r#"{"event":"update","payload":{"id":"102775370117886890","created_at":"2019-09-11T18:42:19.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"unlisted","language":"en","uri":"https://mastodon.host/users/federationbot/statuses/102775346916917099","url":"https://mastodon.host/@federationbot/102775346916917099","replies_count":0,"reblogs_count":0,"favourites_count":0,"favourited":false,"reblogged":false,"muted":false,"content":"<p>Trending tags:<br><a href=\"https://mastodon.host/tags/neverforget\" class=\"mention hashtag\" rel=\"nofollow noopener\" target=\"_blank\">#<span>neverforget</span></a><br><a href=\"https://mastodon.host/tags/4styles\" class=\"mention hashtag\" rel=\"nofollow noopener\" target=\"_blank\">#<span>4styles</span></a><br><a href=\"https://mastodon.host/tags/newpipe\" class=\"mention hashtag\" rel=\"nofollow noopener\" target=\"_blank\">#<span>newpipe</span></a><br><a href=\"https://mastodon.host/tags/uber\" class=\"mention hashtag\" rel=\"nofollow noopener\" target=\"_blank\">#<span>uber</span></a><br><a href=\"https://mastodon.host/tags/mercredifiction\" class=\"mention hashtag\" rel=\"nofollow noopener\" target=\"_blank\">#<span>mercredifiction</span></a></p>","reblog":null,"account":{"id":"78","username":"federationbot","acct":"federationbot@mastodon.host","display_name":"Federation Bot","locked":false,"bot":false,"created_at":"2019-09-10T15:04:25.559Z","note":"<p>Hello, I am mastodon.host official semi bot.</p><p>Follow me if you want to have some updates on the view of the fediverse from here ( I only post unlisted ). </p><p>I also randomly boost one of my followers toot every hour !</p><p>If you don't feel confortable with me following you, tell me: unfollow and I'll do it :)</p><p>If you want me to follow you, just tell me follow ! </p><p>If you want automatic follow for new users on your instance and you are an instance admin, contact me !</p><p>Other commands are private :)</p>","url":"https://mastodon.host/@federationbot","avatar":"https://instance.codesections.com/system/accounts/avatars/000/000/078/original/d9e2be5398629cf8.jpeg?1568127863","avatar_static":"https://instance.codesections.com/system/accounts/avatars/000/000/078/original/d9e2be5398629cf8.jpeg?1568127863","header":"https://instance.codesections.com/headers/original/missing.png","header_static":"https://instance.codesections.com/headers/original/missing.png","followers_count":16636,"following_count":179532,"statuses_count":50554,"emojis":[],"fields":[{"name":"More stats","value":"<a href=\"https://mastodon.host/stats.html\" rel=\"nofollow noopener\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">mastodon.host/stats.html</span><span class=\"invisible\"></span></a>","verified_at":null},{"name":"More infos","value":"<a href=\"https://mastodon.host/about/more\" rel=\"nofollow noopener\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">mastodon.host/about/more</span><span class=\"invisible\"></span></a>","verified_at":null},{"name":"Owner/Friend","value":"<span class=\"h-card\"><a href=\"https://mastodon.host/@gled\" class=\"u-url mention\" rel=\"nofollow noopener\" target=\"_blank\">@<span>gled</span></a></span>","verified_at":null}]},"media_attachments":[],"mentions":[],"tags":[{"name":"4styles","url":"https://instance.codesections.com/tags/4styles"},{"name":"neverforget","url":"https://instance.codesections.com/tags/neverforget"},{"name":"mercredifiction","url":"https://instance.codesections.com/tags/mercredifiction"},{"name":"uber","url":"https://instance.codesections.com/tags/uber"},{"name":"newpipe","url":"https://instance.codesections.com/tags/newpipe"}],"emojis":[],"card":null,"poll":null},"queued_at":1568227693541}"#;
|
||||
|
|
|
@ -6,7 +6,7 @@ mod subscription;
|
|||
|
||||
pub use self::postgres::PgPool;
|
||||
// TODO consider whether we can remove `Stream` from public API
|
||||
pub use subscription::{Stream, Subscription, Timeline};
|
||||
pub use subscription::{Blocks, Stream, Subscription, Timeline};
|
||||
|
||||
//#[cfg(test)]
|
||||
pub use subscription::{Content, Reach};
|
||||
|
|
|
@ -90,35 +90,28 @@ impl futures::stream::Stream for ClientAgent {
|
|||
receiver.poll_for(self.subscription.id, self.subscription.timeline)
|
||||
};
|
||||
|
||||
let timeline = &self.subscription.timeline;
|
||||
let allowed_langs = &self.subscription.allowed_langs;
|
||||
let blocked_users = &self.subscription.blocks.blocked_users;
|
||||
let blocking_users = &self.subscription.blocks.blocking_users;
|
||||
let blocked_domains = &self.subscription.blocks.blocked_domains;
|
||||
let blocks = &self.subscription.blocks;
|
||||
let (send, block) = (|msg| Ok(Ready(Some(msg))), Ok(NotReady));
|
||||
|
||||
use Event::*;
|
||||
use crate::messages::{CheckedEvent::Update, Event::*};
|
||||
match result {
|
||||
Ok(Async::Ready(Some(event))) => match event {
|
||||
Update {
|
||||
payload: status, ..
|
||||
} => match self.subscription.timeline {
|
||||
_ if status.involves_blocked_user(blocked_users) => block,
|
||||
_ if status.from_blocked_domain(blocked_domains) => block,
|
||||
_ if status.from_blocking_user(blocking_users) => block,
|
||||
Timeline(Public, _, _) if status.language_not_allowed(allowed_langs) => block,
|
||||
_ => send(Update {
|
||||
payload: status,
|
||||
queued_at: None,
|
||||
}),
|
||||
TypeSafe(Update { payload, queued_at }) => match timeline {
|
||||
Timeline(Public, _, _) if payload.language_not(allowed_langs) => block,
|
||||
_ if payload.involves_any(blocks) => block,
|
||||
_ => send(TypeSafe(Update { payload, queued_at })),
|
||||
},
|
||||
Notification { .. }
|
||||
| Conversation { .. }
|
||||
| Delete { .. }
|
||||
| FiltersChanged
|
||||
| Announcement { .. }
|
||||
| AnnouncementReaction { .. }
|
||||
| AnnouncementDelete { .. } => send(event),
|
||||
TypeSafe(non_update) => send(Event::TypeSafe(non_update)),
|
||||
Dynamic(event) if event.event == "update" => match timeline {
|
||||
Timeline(Public, _, _) if event.language_not(allowed_langs) => block,
|
||||
_ if event.involves_any(blocks) => block,
|
||||
_ => send(Dynamic(event)),
|
||||
},
|
||||
Dynamic(non_update) => send(Dynamic(non_update)),
|
||||
},
|
||||
|
||||
Ok(Ready(None)) => Ok(Ready(None)),
|
||||
Ok(NotReady) => Ok(NotReady),
|
||||
Err(e) => Err(e),
|
||||
|
|
|
@ -61,11 +61,12 @@ impl EventStream {
|
|||
event_stream
|
||||
.for_each(move |_instant| {
|
||||
match client_agent.poll() {
|
||||
Ok(Async::Ready(Some(msg))) => tx
|
||||
.unbounded_send(Message::text(msg.to_json_string()))
|
||||
Ok(Async::Ready(Some(msg))) => {
|
||||
tx.unbounded_send(Message::text(msg.to_json_string()))
|
||||
.unwrap_or_else(|e| {
|
||||
log::error!("Could not send message to WebSocket: {}", e)
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(Async::Ready(None)) => log::info!("WebSocket ClientAgent got Ready(None)"),
|
||||
Ok(Async::NotReady) if last_ping_time.elapsed() > Duration::from_secs(30) => {
|
||||
tx.unbounded_send(Message::text("{}")).unwrap_or_else(|e| {
|
||||
|
|
|
@ -5,3 +5,6 @@ mod receiver;
|
|||
mod redis;
|
||||
|
||||
pub use {client_agent::ClientAgent, event_stream::EventStream, receiver::Receiver};
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
pub use redis::redis_msg::{RedisMsg, RedisParseOutput};
|
||||
|
|
|
@ -76,13 +76,14 @@ impl Receiver {
|
|||
pub fn poll_for(&mut self, id: Uuid, timeline: Timeline) -> Poll<Option<Event>, ReceiverErr> {
|
||||
loop {
|
||||
match self.redis_connection.poll_redis() {
|
||||
Ok(Async::Ready(Some((timeline, event)))) => self
|
||||
.msg_queues
|
||||
Ok(Async::Ready(Some((timeline, event)))) => {
|
||||
self.msg_queues
|
||||
.values_mut()
|
||||
.filter(|msg_queue| msg_queue.timeline == timeline)
|
||||
.for_each(|msg_queue| {
|
||||
msg_queue.messages.push_back(event.clone());
|
||||
}),
|
||||
});
|
||||
}
|
||||
Ok(Async::NotReady) => break,
|
||||
Ok(Async::Ready(None)) => (),
|
||||
Err(err) => Err(err)?,
|
||||
|
|
|
@ -82,13 +82,13 @@ impl RedisConn {
|
|||
Some(ns) if msg.timeline_txt.starts_with(&format!("{}:timeline:", ns)) => {
|
||||
let trimmed_tl_txt = &msg.timeline_txt[ns.len() + ":timeline:".len()..];
|
||||
let tl = Timeline::from_redis_text(trimmed_tl_txt, &mut self.tag_id_cache)?;
|
||||
let event: Event = serde_json::from_str(msg.event_txt)?;
|
||||
let event = msg.event_txt.into();
|
||||
(Ok(Ready(Some((tl, event)))), msg.leftover_input)
|
||||
}
|
||||
None => {
|
||||
let trimmed_tl_txt = &msg.timeline_txt["timeline:".len()..];
|
||||
let tl = Timeline::from_redis_text(trimmed_tl_txt, &mut self.tag_id_cache)?;
|
||||
let event: Event = serde_json::from_str(msg.event_txt)?;
|
||||
let event = msg.event_txt.into();
|
||||
(Ok(Ready(Some((tl, event)))), msg.leftover_input)
|
||||
}
|
||||
Some(_non_matching_namespace) => (Ok(Ready(None)), msg.leftover_input),
|
||||
|
|
|
@ -34,6 +34,7 @@ fn parse_redis_detects_non_newline() -> Result<(), RedisParseErr> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_redis_msg() -> Result<(), RedisParseErr> {
|
||||
let input =
|
||||
"*3\r\n$7\r\nmessage\r\n$12\r\ntimeline:308\r\n$38\r\n{\"event\":\"delete\",\"payload\":\"1038647\"}\r\n";
|
||||
|
@ -52,3 +53,23 @@ fn parse_redis_msg() -> Result<(), RedisParseErr> {
|
|||
assert_eq!(r_msg.event_txt, r#"{"event":"delete","payload":"1038647"}"#);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_long_redis_msg() -> Result<(), RedisParseErr> {
|
||||
let input = ONE_MESSAGE_FOR_THE_USER_TIMLINE_FROM_REDIS;
|
||||
|
||||
let r_msg = match RedisParseOutput::try_from(input) {
|
||||
Ok(NonMsg(leftover)) => panic!(
|
||||
"Parsed a msg as a non-msg.\nInput `{}` parsed to NonMsg({:?})",
|
||||
&input, leftover
|
||||
),
|
||||
Ok(Msg(msg)) => msg,
|
||||
Err(e) => panic!("Error in parsing subscribe command: {:?}", e),
|
||||
};
|
||||
|
||||
assert!(r_msg.leftover_input.is_empty());
|
||||
assert_eq!(r_msg.timeline_txt, "timeline:1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ONE_MESSAGE_FOR_THE_USER_TIMLINE_FROM_REDIS: &str = "*3\r\n$7\r\nmessage\r\n$10\r\ntimeline:1\r\n$3790\r\n{\"event\":\"update\",\"payload\":{\"id\":\"102775370117886890\",\"created_at\":\"2019-09-11T18:42:19.000Z\",\"in_reply_to_id\":null,\"in_reply_to_account_id\":null,\"sensitive\":false,\"spoiler_text\":\"\",\"visibility\":\"unlisted\",\"language\":\"en\",\"uri\":\"https://mastodon.host/users/federationbot/statuses/102775346916917099\",\"url\":\"https://mastodon.host/@federationbot/102775346916917099\",\"replies_count\":0,\"reblogs_count\":0,\"favourites_count\":0,\"favourited\":false,\"reblogged\":false,\"muted\":false,\"content\":\"<p>Trending tags:<br><a href=\\\"https://mastodon.host/tags/neverforget\\\" class=\\\"mention hashtag\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\">#<span>neverforget</span></a><br><a href=\\\"https://mastodon.host/tags/4styles\\\" class=\\\"mention hashtag\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\">#<span>4styles</span></a><br><a href=\\\"https://mastodon.host/tags/newpipe\\\" class=\\\"mention hashtag\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\">#<span>newpipe</span></a><br><a href=\\\"https://mastodon.host/tags/uber\\\" class=\\\"mention hashtag\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\">#<span>uber</span></a><br><a href=\\\"https://mastodon.host/tags/mercredifiction\\\" class=\\\"mention hashtag\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\">#<span>mercredifiction</span></a></p>\",\"reblog\":null,\"account\":{\"id\":\"78\",\"username\":\"federationbot\",\"acct\":\"federationbot@mastodon.host\",\"display_name\":\"Federation Bot\",\"locked\":false,\"bot\":false,\"created_at\":\"2019-09-10T15:04:25.559Z\",\"note\":\"<p>Hello, I am mastodon.host official semi bot.</p><p>Follow me if you want to have some updates on the view of the fediverse from here ( I only post unlisted ). </p><p>I also randomly boost one of my followers toot every hour !</p><p>If you don\'t feel confortable with me following you, tell me: unfollow and I\'ll do it :)</p><p>If you want me to follow you, just tell me follow ! </p><p>If you want automatic follow for new users on your instance and you are an instance admin, contact me !</p><p>Other commands are private :)</p>\",\"url\":\"https://mastodon.host/@federationbot\",\"avatar\":\"https://instance.codesections.com/system/accounts/avatars/000/000/078/original/d9e2be5398629cf8.jpeg?1568127863\",\"avatar_static\":\"https://instance.codesections.com/system/accounts/avatars/000/000/078/original/d9e2be5398629cf8.jpeg?1568127863\",\"header\":\"https://instance.codesections.com/headers/original/missing.png\",\"header_static\":\"https://instance.codesections.com/headers/original/missing.png\",\"followers_count\":16636,\"following_count\":179532,\"statuses_count\":50554,\"emojis\":[],\"fields\":[{\"name\":\"More stats\",\"value\":\"<a href=\\\"https://mastodon.host/stats.html\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">mastodon.host/stats.html</span><span class=\\\"invisible\\\"></span></a>\",\"verified_at\":null},{\"name\":\"More infos\",\"value\":\"<a href=\\\"https://mastodon.host/about/more\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\"><span class=\\\"invisible\\\">https://</span><span class=\\\"\\\">mastodon.host/about/more</span><span class=\\\"invisible\\\"></span></a>\",\"verified_at\":null},{\"name\":\"Owner/Friend\",\"value\":\"<span class=\\\"h-card\\\"><a href=\\\"https://mastodon.host/@gled\\\" class=\\\"u-url mention\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\">@<span>gled</span></a></span>\",\"verified_at\":null}]},\"media_attachments\":[],\"mentions\":[],\"tags\":[{\"name\":\"4styles\",\"url\":\"https://instance.codesections.com/tags/4styles\"},{\"name\":\"neverforget\",\"url\":\"https://instance.codesections.com/tags/neverforget\"},{\"name\":\"mercredifiction\",\"url\":\"https://instance.codesections.com/tags/mercredifiction\"},{\"name\":\"uber\",\"url\":\"https://instance.codesections.com/tags/uber\"},{\"name\":\"newpipe\",\"url\":\"https://instance.codesections.com/tags/newpipe\"}],\"emojis\":[],\"card\":null,\"poll\":null},\"queued_at\":1568227693541}\r\n";
|
||||
|
|
Loading…
Reference in New Issue