diff --git a/src/ap/context.rs b/src/ap/context.rs deleted file mode 100644 index 41cae55..0000000 --- a/src/ap/context.rs +++ /dev/null @@ -1,357 +0,0 @@ -use serde::de::{MapAccess, SeqAccess}; -use serde::ser::{SerializeMap, SerializeSeq}; -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::HashMap; -use std::fmt; - -use crate::core::*; - -/// The `@context` field in an ActivityPub document. -#[cfg_attr(test, derive(PartialEq))] -#[derive(Debug)] -pub struct Context { - entries: Vec, -} - -pub mod uri { - pub static ACTIVITY_STREAMS: &'static str = "https://www.w3.org/ns/activitystreams"; - pub static ACTIVITY_STREAMS_ALT: &'static str = "http://www.w3.org/ns/activitystreams"; - pub static NYANO: &'static str = "https://nyanoblog.org/ns/activitystreams"; - pub static TOOT: &'static str = "http://joinmastodon.org/ns#"; -} - -#[derive(Debug, Clone)] -pub enum Entry { - Anon(AnonEntry), - Named(NamedEntry), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct AnonEntry(String); - -#[derive(Debug, Clone, PartialEq)] -pub struct NamedEntry(String, String); - -impl Entry { - pub fn anon(uri: &str) -> Entry { - Entry::Anon(AnonEntry(String::from(uri))) - } - - pub fn named(name: &str, uri: &str) -> Entry { - Entry::Named(NamedEntry(String::from(name), String::from(uri))) - } - - pub fn name(&self) -> Option<&str> { - match self { - Entry::Anon(_) => None, - Entry::Named(named) => Some(&named.0), - } - } - - pub fn uri(&self) -> &str { - match self { - Entry::Anon(anon) => &anon.0, - Entry::Named(named) => &named.1, - } - } -} - -impl PartialEq for Entry { - fn eq(&self, other: &Self) -> bool { - match self { - Entry::Anon(self_anon) => self_anon.0.eq(other.uri()), - Entry::Named(self_named) => match other { - Entry::Anon(other_anon) => self_named.1.eq(&other_anon.0), - Entry::Named(other_named) => self_named.eq(other_named), - }, - } - } -} - -impl Context { - pub fn new() -> Context { - Context { - entries: Vec::new(), - } - } - - pub fn has_entry(&self, uri: &str) -> bool { - self.entries.iter().any(|e| e.uri().eq(uri)) - } - - pub fn add_entry(&mut self, mut entry: Entry) { - // As per : - // - // > Implementations producing ActivityStreams 2.0. documents SHOULD include a - // > @context property with a value that includes a reference to the normative - // > Activity Streams 2.0 JSON-LD @context definition using the URL - // > "https://www.w3.org/ns/activitystreams". Implementations MAY use the - // > alternative URL "http://www.w3.org/ns/activitystreams" instead. - // > This can be done using a string, object, or array. - // - // We're just gonna silently rewrite the http:// to https:// and call it a day. - if entry.uri().eq(uri::ACTIVITY_STREAMS_ALT) { - entry = match entry { - Entry::Anon(_) => Entry::anon(uri::ACTIVITY_STREAMS), - Entry::Named(named) => Entry::named(&named.0, uri::ACTIVITY_STREAMS), - }; - } - - if !self.has_entry(entry.uri()) { - self.entries.push(entry); - } - } - - /// Merge with a contained object's context, consuming that child's context. - /// - /// To be used as: - /// - /// ``` - /// if let Some(other_context) = other_object.context.take() { - /// context.merge(other_context); - /// } - /// ``` - pub fn merge(&mut self, other: Context) { - for e in other.entries { - self.add_entry(e); - } - } -} - -impl Default for Context { - fn default() -> Self { - let mut ctx = Context::new(); - ctx.add_entry(Entry::anon(uri::ACTIVITY_STREAMS)); - ctx - } -} - -impl Serialize for Context { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - // FIXME: omg this is just horrible - - if self.entries.len() == 1 { - serializer.serialize_str(self.entries[0].uri()) - } else { - let mut anons = Vec::new(); - let mut nameds = Vec::new(); - for e in &self.entries { - match e { - Entry::Anon(anon) => anons.push(anon), - Entry::Named(named) => nameds.push(named), - } - } - let (anons, nameds) = (anons, nameds); - - let seq_len = anons.len() + if nameds.len() > 0 { 1 } else { 0 }; - let mut seq = serializer.serialize_seq(Some(seq_len))?; - for a in anons { - seq.serialize_element(&a.0)?; - } - if nameds.len() > 0 { - seq.serialize_element(&NamedEntriesWrapper(nameds))?; - } - seq.end() - } - } -} - -impl<'de> de::Deserialize<'de> for Context { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_any(ContextVisitor) - } -} - -struct ContextVisitor; - -impl<'de> de::Visitor<'de> for ContextVisitor { - type Value = Context; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("a valid JSON-LD context specification") - } - - fn visit_str(self, value: &str) -> std::result::Result - where - E: de::Error, - { - Ok(Context { - entries: vec![Entry::anon(value)], - }) - } - - fn visit_none(self) -> std::result::Result - where - E: de::Error, - { - Ok(Context::new()) - } - - fn visit_seq(self, mut seq: S) -> std::result::Result - where - S: SeqAccess<'de>, - { - let mut context = Context::new(); - while let Some(wrapper) = seq.next_element::()? { - match wrapper { - StupidEntryWrapper::Single(entry) => context.add_entry(entry), - StupidEntryWrapper::Multi(entries) => { - for e in entries { - context.add_entry(e); - } - } - } - } - - // As per : - // - // "When a JSON-LD enabled Activity Streams 2.0 implementation encounters - // a JSON document identified using the "application/activity+json" MIME - // media type, and that document does not contain a @context property whose - // value includes a reference to the normative Activity Streams 2.0 JSON-LD - // @context definition, the implementation MUST assume that the normative - // @context definition still applies." - if !context.has_entry(uri::ACTIVITY_STREAMS) { - warn!(target: "ap", "@context does not have canonical ActivityStreams URI"); - context.add_entry(Entry::anon(uri::ACTIVITY_STREAMS)); - } - if context.entries.len() == 0 { - warn!(target: "ap", "Malformed @context: Arrays must always have elements, or be null"); - context.add_entry(Entry::anon(uri::ACTIVITY_STREAMS)); - } - Ok(context) - } - - fn visit_map(self, mut map: A) -> std::result::Result - where - A: MapAccess<'de>, - { - let mut entries = Vec::new(); - - while let Some((k, v)) = map.next_entry::<&str, &str>()? { - entries.push(Entry::named(k, v)); - } - - Ok(Context { entries }) - } -} - -/// Stupid wrapper because if @context is an array, its elements are strings and/or maps. -enum StupidEntryWrapper { - /// Single (anonymous) URI - Single(Entry), - /// Map of (named) URIs - Multi(Vec), -} - -impl<'de> Deserialize<'de> for StupidEntryWrapper { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_any(StupidEntryWrapperVisitor) - } -} - -struct StupidEntryWrapperVisitor; - -impl<'de> de::Visitor<'de> for StupidEntryWrapperVisitor { - type Value = StupidEntryWrapper; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a context URI or map of named URIs") - } - - fn visit_str(self, s: &str) -> std::result::Result - where - E: de::Error, - { - Ok(StupidEntryWrapper::Single(Entry::anon(s))) - } - - fn visit_map(self, mut map: A) -> std::result::Result - where - A: MapAccess<'de>, - { - let mut entries = Vec::new(); - while let Some((k, v)) = map.next_entry()? { - entries.push(Entry::Named(NamedEntry(k, v))); - } - Ok(StupidEntryWrapper::Multi(entries)) - } -} - -/// We can't use a HashMap to serialize named entries because that doesn't -/// produce deterministic results, so we have to use our own serializer -struct NamedEntriesWrapper<'a>(Vec<&'a NamedEntry>); - -impl<'a> Serialize for NamedEntriesWrapper<'a> { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - let mut map = serializer.serialize_map(Some(self.0.len()))?; - for entry in &self.0 { - //let entry = *entry; - map.serialize_entry(&entry.0, &entry.1)?; - } - map.end() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_test::{assert_tokens, Token}; - - #[test] - fn default() { - let ctx = Context::default(); - - assert_tokens(&ctx, &[Token::String(uri::ACTIVITY_STREAMS)]); - } - - #[test] - fn multi_anon() { - let mut ctx = Context::default(); - ctx.add_entry(Entry::anon(uri::NYANO)); - - assert_tokens( - &ctx, - &[ - Token::Seq { len: Some(2) }, - Token::String(uri::ACTIVITY_STREAMS), - Token::String(uri::NYANO), - Token::SeqEnd, - ], - ); - } - - #[test] - fn multi_named() { - let mut ctx = Context::default(); - ctx.add_entry(Entry::named("nyano", uri::NYANO)); - ctx.add_entry(Entry::named("toot", uri::TOOT)); - - assert_tokens( - &ctx, - &[ - Token::Seq { len: Some(2) }, - Token::String(uri::ACTIVITY_STREAMS), - Token::Map { len: Some(2) }, - Token::String("nyano"), - Token::String(uri::NYANO), - Token::String("toot"), - Token::String(uri::TOOT), - Token::MapEnd, - Token::SeqEnd, - ], - ) - } -} diff --git a/src/ap/loader.rs b/src/ap/loader.rs index 5c016fc..a8aa176 100644 --- a/src/ap/loader.rs +++ b/src/ap/loader.rs @@ -9,27 +9,25 @@ // and work with the `application/activity+json` MIME type. // // FIXME: This file is, frankly, just pure chaos and desperately needs a refactor. +// Update 2023-07-29: Things have improved slightly, but it's still bad. -use crate::state::AppState; use bytes::Bytes; use futures::future::{BoxFuture, FutureExt}; use hashbrown::HashSet; use iref::Iri; -use json_ld::{ - syntax::{Parse, Value}, - Loader, Profile, RemoteDocument, -}; +use json_ld::{syntax::Value, Loader, Profile, RemoteDocument}; use locspan::{Meta, Span}; -use rdf_types::vocabulary::IriIndex; -use rdf_types::{IriVocabulary, IriVocabularyMut}; +use rdf_types::{vocabulary::IriIndex, IriVocabulary, IriVocabularyMut}; use reqwest::{ header::{ACCEPT, CONTENT_TYPE, LINK, LOCATION}, StatusCode, }; use std::hash::Hash; +use std::ops::ControlFlow; use crate::core::*; use crate::headers; +use crate::state::AppState; use crate::util::http::{ self, header::{content_type::ContentType, link::Link}, @@ -44,34 +42,26 @@ pub struct CachedLoader> { type DynParser = dyn 'static + Send + Sync + FnMut(&dyn IriVocabulary, &I, Bytes) -> Result>; -impl CachedLoader { - pub fn new(state: AppState) -> Self { - CachedLoader::new_with(state, move |vocab, file, bytes| { - let content = String::from_utf8(bytes.to_vec()) - .map_err(|e| Error::MalformedApub(format!("Invalid encoding: {e}")))?; - json_ld::syntax::Value::parse_str(&content, |span| span) - .map_err(|e| Error::MalformedApub(format!("Syntax error in JSON document: {e}"))) - }) - } -} - impl CachedLoader { - pub fn new_with( - state: AppState, - parser: impl 'static + pub fn new_with(state: AppState, parser: F) -> Self + where + F: 'static + Send + Sync + FnMut(&dyn IriVocabulary, &I, Bytes) -> Result>, - ) -> Self { - CachedLoader { + { + Self { state, parser: Box::new(parser), } } } -impl Loader - for CachedLoader +impl Loader for CachedLoader +where + I: Clone + Eq + Hash + Send + Sync, + M: Send, + T: Clone + Send, { type Output = T; type Error = Error; @@ -88,27 +78,43 @@ impl Loader } } -impl CachedLoader { +struct LoopState<'a, I, V> { + redirect_count: u32, + vocab: &'a mut V, + url: I, +} + +impl CachedLoader +where + I: Clone + Eq + Hash + Send + Sync, + M: Send, + T: Clone + Send, +{ async fn load_chain( &mut self, vocabulary: &mut (impl Send + Sync + IriVocabularyMut), - mut url: I, + url: I, ) -> Result> { const MAX_REDIRECTS: u32 = 8; const ACCEPT_ACTIVITY_PUB: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json"; - let mut redirect_count = 0; + let mut state = LoopState { + redirect_count: 0, + vocab: vocabulary, + url, + }; - 'next_url: loop { - if redirect_count > MAX_REDIRECTS { - return Err(Error::MalformedApub(format!( + loop { + if state.redirect_count > MAX_REDIRECTS { + return apub_err(format!( "Refusing to follow more than {MAX_REDIRECTS} redirects" - ))); + )); } - let iri = vocabulary - .iri(&url) + let iri = state + .vocab + .iri(&state.url) .ok_or_else(|| Error::MalformedApub(String::from("Unresolved IRI")))?; debug!(target: "ap", "downloading {iri}"); let response = http::get( @@ -121,76 +127,14 @@ impl CachedLoader< .await?; match response.status() { - StatusCode::OK => { - let mut content_types = response - .headers() - .get_all(CONTENT_TYPE) - .into_iter() - .filter_map(ContentType::from_header); - - match content_types.find(ContentType::is_json_ld) { - Some(content_type) => { - let mut context_url = None; - if !content_type.is_proper_json_ld() { - context_url = self.get_context_url(vocabulary, &url, &response)?; - } - - let profile: HashSet> = content_type - .profile() - .into_iter() - .flat_map(|profile| profile.split(' ')) - .filter_map(|p| { - Iri::new(p).ok().map(|iri| Profile::new(iri, vocabulary)) - }) - .collect(); - - let bytes = response.bytes().await?; - let document = (*self.parser)(vocabulary, &url, bytes) - .map_err(|e| Error::MalformedApub(e.to_string()))?; - - break Ok(RemoteDocument::new_full( - Some(url), - Some(content_type.into_mime()), - context_url, - profile, - document, - )); - } - None => { - debug!(target: "ap", "no valid media type found"); - for link in response.headers().get_all(LINK).into_iter() { - if let Some(link) = Link::from_header(link) { - if link.rel() == Some("alternate") && link.is_proper_json_ld() { - debug!(target: "ap", "link found"); - let u = link.href().resolved(vocabulary.iri(&url).unwrap()); - url = vocabulary.insert(u.as_iri()); - redirect_count += 1; - continue 'next_url; - } - } - } - - break Err(Error::MalformedApub(String::from("Invalid content type"))); - } - } - } + StatusCode::OK => match self.handle_http_ok(&mut state, response).await { + ControlFlow::Break(result) => break result, + ControlFlow::Continue(_) => continue, + }, code if code.is_redirection() => { - if response.status() == StatusCode::SEE_OTHER { - break Err(Error::NotFound); - } else { - match response.headers().get(LOCATION) { - Some(location) => { - let u = Iri::new(location.as_bytes()).map_err(|_| { - Error::MalformedApub(String::from("Invalid redirect URL")) - })?; - url = vocabulary.insert(u); - } - None => { - break Err(Error::MalformedApub(String::from( - "Missing Location header in HTTP redirect", - ))) - } - } + match self.handle_http_redirect(&mut state, response) { + ControlFlow::Break(result) => break result, + ControlFlow::Continue(_) => continue, } } code => { @@ -203,12 +147,10 @@ impl CachedLoader< } } - fn get_context_url( - &self, - vocabulary: &mut (impl Send + Sync + IriVocabularyMut), - url: &I, - response: &Response, - ) -> Result> { + fn get_context_url(&self, vocab: &mut V, url: &I, response: &Response) -> Result> + where + V: Send + Sync + IriVocabularyMut, + { let mut context_url = None; for link in response.headers().get_all(LINK).into_iter() { if let Some(link) = Link::from_header(link) { @@ -222,14 +164,116 @@ impl CachedLoader< } let iri_buf = link.href().resolved( - vocabulary + vocab .iri(url) .ok_or_else(|| Error::MalformedApub(String::from("Unresolved IRI")))?, ); - context_url = Some(vocabulary.insert(iri_buf.as_iri())); + context_url = Some(vocab.insert(iri_buf.as_iri())); } } } Ok(context_url) } + + async fn handle_http_ok( + &mut self, + state: &mut LoopState<'_, I, V>, + response: Response, + ) -> ControlFlow>> + where + V: Send + Sync + IriVocabularyMut, + { + let mut content_types = response + .headers() + .get_all(CONTENT_TYPE) + .into_iter() + .filter_map(ContentType::from_header); + + match content_types.find(ContentType::is_json_ld) { + Some(content_type) => { + let mut context_url = None; + if !content_type.is_proper_json_ld() { + context_url = match self.get_context_url(state.vocab, &state.url, &response) { + Ok(context_url) => context_url, + Err(e) => return ControlFlow::Break(Err(e)), + }; + } + + let profile: HashSet> = content_type + .profile() + .into_iter() + .flat_map(|profile| profile.split(' ')) + .filter_map(|p| Iri::new(p).ok().map(|iri| Profile::new(iri, state.vocab))) + .collect(); + + let bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(e) => return ControlFlow::Break(Err(e)), + }; + let document = match (*self.parser)(state.vocab, &state.url, bytes) { + Ok(document) => document, + Err(e) => return ControlFlow::Break(apub_err(e.to_string())), + }; + + ControlFlow::Break(Ok(RemoteDocument::new_full( + Some(state.url.clone()), + Some(content_type.into_mime()), + context_url, + profile, + document, + ))) + } + None => { + debug!(target: "ap", "no valid media type found"); + for link in response.headers().get_all(LINK).into_iter() { + if let Some(link) = Link::from_header(link) { + if link.rel() == Some("alternate") && link.is_proper_json_ld() { + debug!(target: "ap", "link found"); + let u = link.href().resolved(state.vocab.iri(&state.url).unwrap()); + state.url = state.vocab.insert(u.as_iri()); + state.redirect_count += 1; + return ControlFlow::Continue(()); + } + } + } + + ControlFlow::Break(apub_err("Invalid content type")) + } + } + } + + fn handle_http_redirect( + &self, + state: &mut LoopState<'_, I, V>, + response: Response, + ) -> ControlFlow>> + where + V: Send + Sync + IriVocabularyMut, + { + state.redirect_count += 1; + + if response.status() == StatusCode::SEE_OTHER { + return ControlFlow::Break(Err(Error::NotFound)); + } + match response.headers().get(LOCATION) { + Some(location) => { + let u = match Iri::new(location.as_bytes()) { + Ok(u) => u, + Err(e) => { + return ControlFlow::Break(apub_err(format!("Invalid redirect URL: {e}"))); + } + }; + state.url = state.vocab.insert(u); + + ControlFlow::Continue(()) + } + None => ControlFlow::Break(Err(Error::MalformedApub(String::from( + "Missing Location header in HTTP redirect", + )))), + } + } +} + +fn apub_err(msg: impl Into) -> Result { + Err(Error::MalformedApub(msg.into())) } diff --git a/src/ap/mod.rs b/src/ap/mod.rs index b798d75..f8bbdfb 100644 --- a/src/ap/mod.rs +++ b/src/ap/mod.rs @@ -1,3 +1,4 @@ pub mod loader; pub mod processor; +pub mod trans; pub mod vocab; diff --git a/src/ap/object.rs b/src/ap/object.rs deleted file mode 100644 index a4f485d..0000000 --- a/src/ap/object.rs +++ /dev/null @@ -1,617 +0,0 @@ -use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; -use serde::ser::{SerializeMap, SerializeSeq}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::fmt; - -use crate::ap::context::{uri, Context}; -use crate::core::*; - -/// Wrapper for [`BaseObject`] because ActivityPub allows -/// any object to be referenced indirectly through a URL. -#[cfg_attr(test, derive(PartialEq))] -#[derive(Debug)] -pub enum Object { - Direct(BaseObject), - Indirect(String), - Multi(Vec), -} - -/// Intermediate representation of actual AP objects. -/// Contains all possible fields for any type of object. -/// We "simplify" AP processing by treating anything as an object. -/// This struct is only used internally as a helper for (de)serializing objects -/// because doing that all in one step would, frankly, be insane. -#[cfg_attr(test, derive(PartialEq))] -#[derive(Debug)] -pub struct BaseObject { - pub context: Option, - pub typ: Option, - pub id: Option, - pub name: Option, - pub content: Option, - pub subject: Option>, - pub actor: Option>, - pub object: Option>, - pub attributed_to: Option>, -} - -#[derive(Debug, PartialEq)] -pub enum ObjectType { - Unknown(String), - // Object - Object, - Article, - Audio, - Document, - Event, - Image, - Note, - Page, - Place, - Profile, - Relationship, - Tombstone, - Video, - // Link - Link, - Mention, - // Activity - Activity, - Accept, - Add, - Announce, - Arrive, - Block, - Create, - Delete, - Dislike, - EmojiReact, // extension - Flag, - Follow, - Ignore, - Invite, - Join, - Leave, - Like, - Listen, - Move, - Offer, - Question, - Reject, - Read, - Remove, - TentativeReject, - TentativeAccept, - Travel, - Undo, - Update, - View, - // Actor - Actor, - Application, - Group, - Organization, - Person, - Service, -} - -/// Globally unique object identifier. -#[derive(Debug, PartialEq)] -pub struct ObjectId(String); - -pub trait GetObjectId { - /// Get this object's globally unique (ActivityPub) id. - /// The `base_url` parameter is for constructing a URI and takes - /// the form `https://domain.tld` (without a trailing slash). - /// Do not rely on the URL starting with `https` though, a future - /// version might support `http` URLs for Tor and such. - fn get_object_id(&self, base_url: &str) -> String; -} - -impl From for ObjectId -where - T: GetObjectId, -{ - fn from(t: T) -> ObjectId { - // TODO: there has to be a more elegant way for this - let base_domain = format!("https://{}", std::env::var("LOCAL_DOMAIN").unwrap()); - ObjectId(t.get_object_id(&base_domain)) - } -} - -impl Serialize for Object { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - match self { - Object::Direct(direct) => direct.serialize(serializer), - Object::Indirect(url) => serializer.serialize_str(url), - Object::Multi(objs) => { - let mut seq = serializer.serialize_seq(Some(objs.len()))?; - for obj in objs { - seq.serialize_element(obj)?; - } - seq.end() - } - } - } -} - -impl Serialize for BaseObject { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - let mut map = serializer.serialize_map(None)?; - - if let Some(context) = &self.context { - map.serialize_entry("@context", context)?; - } - if let Some(typ) = &self.typ { - map.serialize_entry("type", typ)?; - } - if let Some(id) = &self.id { - map.serialize_entry("id", &id.0)?; - } - if let Some(name) = &self.name { - map.serialize_entry("name", name)?; - } - if let Some(content) = &self.content { - map.serialize_entry("content", content)?; - } - if let Some(subject) = &self.subject { - map.serialize_entry("subject", subject.as_ref())?; - } - if let Some(actor) = &self.actor { - map.serialize_entry("actor", actor.as_ref())?; - } - if let Some(object) = &self.object { - map.serialize_entry("object", object.as_ref())?; - } - if let Some(attributed_to) = &self.attributed_to { - map.serialize_entry("attributedTo", attributed_to.as_ref())?; - } - - map.end() - } -} - -impl Serialize for ObjectType { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for Object { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_any(ObjectVisitor) - } -} - -struct ObjectVisitor; - -impl<'de> Visitor<'de> for ObjectVisitor { - type Value = Object; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an Activity Streams 2.0 compliant JSON-LD object") - } - - fn visit_string(self, s: String) -> std::result::Result - where - E: de::Error, - { - Ok(Object::Indirect(s)) - } - - fn visit_seq(self, mut seq: A) -> std::result::Result - where - A: SeqAccess<'de>, - { - let mut objs = Vec::new(); - - while let Some(obj) = seq.next_element()? { - objs.push(obj); - } - - Ok(Object::Multi(objs)) - } - - fn visit_map(self, mut map: A) -> std::result::Result - where - A: MapAccess<'de>, - { - let mut obj = BaseObject::default(); - - while let Some(k) = map.next_key::()? { - match k.as_str() { - "@context" => obj.context = Some(map.next_value()?), - "type" => obj.typ = Some(map.next_value()?), - "id" => obj.id = Some(ObjectId(map.next_value()?)), - "name" => obj.name = Some(map.next_value()?), - "content" => obj.content = Some(map.next_value()?), - "subject" => obj.subject = Some(map.next_value()?), - "actor" => obj.actor = Some(Box::new(map.next_value()?)), - "object" => obj.object = Some(Box::new(map.next_value()?)), - "attributedTo" => obj.attributed_to = Some(Box::new(map.next_value()?)), - _ => { - map.next_value::()?; - } - } - } - - Ok(Object::Direct(obj)) - } -} - -impl<'de> Deserialize<'de> for ObjectType { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_str(ObjectTypeVisitor) - } -} - -struct ObjectTypeVisitor; - -impl<'de> Visitor<'de> for ObjectTypeVisitor { - type Value = ObjectType; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("an Activity Streams 2.0 compliant object type name") - } - - fn visit_str(self, s: &str) -> std::result::Result - where - E: de::Error, - { - Ok(ObjectType::from(s)) - } -} - -impl Default for BaseObject { - fn default() -> Self { - BaseObject { - context: None, - typ: None, - id: None, - name: None, - content: None, - subject: None, - actor: None, - object: None, - attributed_to: None, - } - } -} - -////////// the rest is just boring ObjectType impls and tests ////////// - -impl ObjectType { - pub fn is_object(&self) -> bool { - match self { - ObjectType::Object => true, - ObjectType::Article => true, - ObjectType::Audio => true, - ObjectType::Document => true, - ObjectType::Event => true, - ObjectType::Image => true, - ObjectType::Note => true, - ObjectType::Page => true, - ObjectType::Place => true, - ObjectType::Profile => true, - ObjectType::Relationship => true, - ObjectType::Tombstone => true, - ObjectType::Video => true, - _ => false, - } - } - - pub fn is_link(&self) -> bool { - match self { - ObjectType::Link => true, - ObjectType::Mention => true, - _ => false, - } - } - - pub fn is_activity(&self) -> bool { - match self { - ObjectType::Activity => true, - ObjectType::Accept => true, - ObjectType::Add => true, - ObjectType::Announce => true, - ObjectType::Arrive => true, - ObjectType::Block => true, - ObjectType::Create => true, - ObjectType::Delete => true, - ObjectType::Dislike => true, - ObjectType::EmojiReact => true, - ObjectType::Flag => true, - ObjectType::Follow => true, - ObjectType::Ignore => true, - ObjectType::Invite => true, - ObjectType::Join => true, - ObjectType::Leave => true, - ObjectType::Like => true, - ObjectType::Listen => true, - ObjectType::Move => true, - ObjectType::Offer => true, - ObjectType::Question => true, - ObjectType::Reject => true, - ObjectType::Read => true, - ObjectType::Remove => true, - ObjectType::TentativeReject => true, - ObjectType::TentativeAccept => true, - ObjectType::Travel => true, - ObjectType::Undo => true, - ObjectType::Update => true, - ObjectType::View => true, - _ => false, - } - } - - pub fn is_actor(&self) -> bool { - match self { - ObjectType::Actor => true, - ObjectType::Application => true, - ObjectType::Group => true, - ObjectType::Organization => true, - ObjectType::Person => true, - ObjectType::Service => true, - _ => false, - } - } - - pub fn as_str(&self) -> &str { - match self { - ObjectType::Unknown(s) => s.as_str(), - // Object - ObjectType::Object => "Object", - ObjectType::Article => "Article", - ObjectType::Audio => "Audio", - ObjectType::Document => "Document", - ObjectType::Event => "Event", - ObjectType::Image => "Image", - ObjectType::Note => "Note", - ObjectType::Page => "Page", - ObjectType::Place => "Place", - ObjectType::Profile => "Profile", - ObjectType::Relationship => "Relationship", - ObjectType::Tombstone => "Tombstone", - ObjectType::Video => "Video", - // Link - ObjectType::Link => "Link", - ObjectType::Mention => "Mention", - // Activity - ObjectType::Activity => "Activity", - ObjectType::Accept => "Accept", - ObjectType::Add => "Add", - ObjectType::Announce => "Announce", - ObjectType::Arrive => "Arrive", - ObjectType::Block => "Block", - ObjectType::Create => "Create", - ObjectType::Delete => "Delete", - ObjectType::Dislike => "Dislike", - ObjectType::EmojiReact => "EmojiReact", - ObjectType::Flag => "Flag", - ObjectType::Follow => "Follow", - ObjectType::Ignore => "Ignore", - ObjectType::Invite => "Invite", - ObjectType::Join => "Join", - ObjectType::Leave => "Leave", - ObjectType::Like => "Like", - ObjectType::Listen => "Listen", - ObjectType::Move => "Move", - ObjectType::Offer => "Offer", - ObjectType::Question => "Question", - ObjectType::Reject => "Reject", - ObjectType::Read => "Read", - ObjectType::Remove => "Remove", - ObjectType::TentativeReject => "TentativeReject", - ObjectType::TentativeAccept => "TentativeAccept", - ObjectType::Travel => "Travel", - ObjectType::Undo => "Undo", - ObjectType::Update => "Update", - ObjectType::View => "View", - // Actor - ObjectType::Actor => "Actor", - ObjectType::Application => "Application", - ObjectType::Group => "Group", - ObjectType::Organization => "Organization", - ObjectType::Person => "Person", - ObjectType::Service => "Service", - } - } -} - -impl From<&str> for ObjectType { - fn from(s: &str) -> ObjectType { - match s { - // Object - "Object" => ObjectType::Object, - "Article" => ObjectType::Article, - "Audio" => ObjectType::Audio, - "Document" => ObjectType::Document, - "Event" => ObjectType::Event, - "Image" => ObjectType::Image, - "Note" => ObjectType::Note, - "Page" => ObjectType::Page, - "Place" => ObjectType::Place, - "Profile" => ObjectType::Profile, - "Relationship" => ObjectType::Relationship, - "Tombstone" => ObjectType::Tombstone, - "Video" => ObjectType::Video, - // Link - "Link" => ObjectType::Link, - "Mention" => ObjectType::Mention, - // Activity - "Activity" => ObjectType::Activity, - "Accept" => ObjectType::Accept, - "Add" => ObjectType::Add, - "Announce" => ObjectType::Announce, - "Arrive" => ObjectType::Arrive, - "Block" => ObjectType::Block, - "Create" => ObjectType::Create, - "Delete" => ObjectType::Delete, - "Dislike" => ObjectType::Dislike, - "EmojiReact" => ObjectType::EmojiReact, - "Flag" => ObjectType::Flag, - "Follow" => ObjectType::Follow, - "Ignore" => ObjectType::Ignore, - "Invite" => ObjectType::Invite, - "Join" => ObjectType::Join, - "Leave" => ObjectType::Leave, - "Like" => ObjectType::Like, - "Listen" => ObjectType::Listen, - "Move" => ObjectType::Move, - "Offer" => ObjectType::Offer, - "Question" => ObjectType::Question, - "Reject" => ObjectType::Reject, - "Read" => ObjectType::Read, - "Remove" => ObjectType::Remove, - "TentativeReject" => ObjectType::TentativeReject, - "TentativeAccept" => ObjectType::TentativeAccept, - "Travel" => ObjectType::Travel, - "Undo" => ObjectType::Undo, - "Update" => ObjectType::Update, - "View" => ObjectType::View, - // Actor - "Actor" => ObjectType::Actor, - "Application" => ObjectType::Application, - "Group" => ObjectType::Group, - "Organization" => ObjectType::Organization, - "Person" => ObjectType::Person, - "Service" => ObjectType::Service, - // Unknown - _ => ObjectType::Unknown(String::from(s)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_test::{assert_tokens, Token}; - - #[test] - fn indirect() { - let obj = Object::Indirect(String::from("https://example.org/notes/1")); - - assert_tokens(&obj, &[Token::String("https://example.org/notes/1")]); - } - - #[test] - fn empty() { - let obj = Object::Direct(BaseObject::default()); - - assert_tokens(&obj, &[Token::Map { len: None }, Token::MapEnd]); - } - - #[test] - fn basic() { - let obj = Object::Direct(BaseObject { - context: Some(Context::default()), - typ: Some(ObjectType::Object), - id: Some(ObjectId(String::from("https://www.example.com/object/1"))), - name: Some(String::from("A Simple, non-specific object")), - ..Default::default() - }); - - assert_tokens( - &obj, - &[ - Token::Map { len: None }, - Token::String("@context"), - Token::String(uri::ACTIVITY_STREAMS), - Token::String("type"), - Token::String("Object"), - Token::String("id"), - Token::String("https://www.example.com/object/1"), - Token::String("name"), - Token::String("A Simple, non-specific object"), - Token::MapEnd, - ], - ); - } - - #[test] - fn full() { - let obj = Object::Direct(BaseObject { - context: Some(Context::default()), - typ: Some(ObjectType::Create), - id: Some(ObjectId(String::from("https://www.example.org/object/2"))), - name: None, - content: None, - subject: Some(Box::new(Object::Indirect(String::from( - "https://www.example.org/actor/1", - )))), - actor: Some(Box::new(Object::Direct(BaseObject { - typ: Some(ObjectType::Person), - name: Some(String::from("Alice")), - ..Default::default() - }))), - object: Some(Box::new(Object::Direct(BaseObject { - typ: Some(ObjectType::Note), - content: Some(String::from("hi i'm gay uwu")), - ..Default::default() - }))), - attributed_to: Some(Box::new(Object::Multi(vec![ - Object::Indirect(String::from("https://www.example.org/actor/2")), - Object::Direct(BaseObject { - typ: Some(ObjectType::Person), - name: Some(String::from("Bob")), - ..Default::default() - }), - ]))), - }); - - assert_tokens( - &obj, - &[ - Token::Map { len: None }, - Token::String("@context"), - Token::String(uri::ACTIVITY_STREAMS), - Token::String("type"), - Token::String("Create"), - Token::String("id"), - Token::String("https://www.example.org/object/2"), - Token::String("subject"), - Token::String("https://www.example.org/actor/1"), - Token::String("actor"), - Token::Map { len: None }, - Token::String("type"), - Token::String("Person"), - Token::String("name"), - Token::String("Alice"), - Token::MapEnd, - Token::String("object"), - Token::Map { len: None }, - Token::String("type"), - Token::String("Note"), - Token::String("content"), - Token::String("hi i'm gay uwu"), - Token::MapEnd, - Token::String("attributedTo"), - Token::Seq { len: Some(2) }, - Token::String("https://www.example.org/actor/2"), - Token::Map { len: None }, - Token::String("type"), - Token::String("Person"), - Token::String("name"), - Token::String("Bob"), - Token::MapEnd, - Token::SeqEnd, - Token::MapEnd, - ], - ); - } -} diff --git a/src/ap/processor.rs b/src/ap/processor.rs index 162c118..df9355d 100644 --- a/src/ap/processor.rs +++ b/src/ap/processor.rs @@ -2,28 +2,32 @@ use json_ld::{ syntax::{Parse, Value}, Expand, IndexedObject, RemoteDocument, }; -use locspan::Span; +use locspan::{Meta, Span}; +use mime::Mime; use rdf_types::vocabulary::BlankIdIndex; use rdf_types::{vocabulary::IriIndex, IndexVocabulary, Vocabulary}; -use crate::ap::{loader::CachedLoader, vocab::Ids}; +use crate::ap::{ + loader::CachedLoader, + trans::{ApDocument, ParseApub}, + vocab::Ids, +}; use crate::core::*; use crate::state::AppState; /// Main API for handling ActivityPub ingress, called by [`crate::job::inbox::InboxWorker`]. -pub async fn process_document(state: &AppState, raw: &str) -> Result<()> { +pub async fn process_document(state: &AppState, raw: &str, mime: &Mime) -> Result<()> { let mut vocab: IndexVocabulary = IndexVocabulary::new(); let indices = Ids::populate(&mut vocab); - let document = Value::parse_str(raw, |span| span) - .map_err(|e| Error::MalformedApub(format!("Could not parse document: {e}")))?; + let json = preprocess(raw)?; + let rd = RemoteDocument::new(None, Some(mime.clone()), json); - let rd = RemoteDocument::new( - None, - Some("application/activity+json".parse().unwrap()), - document, - ); - let mut loader = CachedLoader::new(state.clone()); + let mut loader = CachedLoader::new_with(state.clone(), move |_vocab, _iri, bytes| { + let content = std::str::from_utf8(bytes.as_ref()) + .map_err(|e| Error::MalformedApub(format!("Invalid encoding: {e}")))?; + preprocess(&content) + }); let rd = rd.expand_with(&mut vocab, &mut loader).await.unwrap(); let vocab = vocab; @@ -37,42 +41,20 @@ pub async fn process_document(state: &AppState, raw: &str) -> Result<()> { Ok(()) } +fn preprocess(raw: &str) -> Result, Span>> { + json_ld::syntax::Value::parse_str(raw, |span| span) + .map_err(|e| Error::MalformedApub(format!("{e}"))) +} + async fn process_object( obj: IndexedObject, vocab: &impl Vocabulary, ids: &Ids, ) -> Result<()> { - let id = obj - .id() - .and_then(|id| id.as_iri()) - .and_then(|index| vocab.iri(index)) - .ok_or_else(apub_err("Object does not have an id"))?; - let typ = obj - .types() - .next() - .ok_or_else(apub_err("Object does not have a type"))? - .as_iri() - .and_then(|index| vocab.iri(index)) - .ok_or_else(apub_err("Object has invalid type"))?; - - // just for testing: extract and print `object.content` - // to see if the expansion actually works as expected - let properties = obj - .as_node() - .ok_or_else(apub_err("Object is not a node"))? - .properties(); - let object = properties - .get_any(&ids.apub.property.object) - .and_then(|prop| prop.as_node()) - .and_then(|node| node.properties().get_any(&ids.apub.property.content)) - .and_then(|prop| prop.as_value()) - .and_then(|val| val.as_str()) - .unwrap(); - debug!("content = \"{object}\""); + let document = ApDocument::parse_apub(&obj, vocab, ids); + if let Some(doc) = document { + debug!("\nParsed document:\n{doc:?}"); + } Ok(()) } - -const fn apub_err(msg: impl Into) -> impl FnOnce() -> Error { - move || Error::MalformedApub(msg.into()) -} diff --git a/src/ap/trans/activity.rs b/src/ap/trans/activity.rs new file mode 100644 index 0000000..22c96c4 --- /dev/null +++ b/src/ap/trans/activity.rs @@ -0,0 +1,303 @@ +use chrono::NaiveDateTime; +use rdf_types::vocabulary::IriIndex; +use rdf_types::Vocabulary; +use std::fmt; + +use crate::ap::trans::{ + matches_type, AbstractObject, ApDocument, DebugApub, ParseApub, PropHelper, RawObject, +}; +use crate::ap::vocab::Ids; + +pub struct AbstractActivity { + _super: AbstractObject, + pub actor: Vec, + pub object: Vec, + pub target: Option>, + pub origin: Option>, + pub instrument: Vec, +} +ap_extends!(AbstractActivity, AbstractObject); + +impl> ParseApub for AbstractActivity { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.activity)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + actor: ph.parse_prop_vec(&prop_ids.actor), + object: ph.parse_prop_vec(&prop_ids.object), + target: ph.parse_prop_box(&prop_ids.target), + origin: ph.parse_prop_box(&prop_ids.origin), + instrument: ph.parse_prop_vec(&prop_ids.instrument), + }) + } +} + +impl DebugApub for AbstractActivity { + fn apub_class_name(&self) -> &str { + "Activity" + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop_vec!(self, f, actor, depth)?; + ap_display_prop_vec!(self, f, object, depth)?; + ap_display_prop_box!(self, f, target, depth)?; + ap_display_prop_box!(self, f, origin, depth)?; + ap_display_prop_vec!(self, f, instrument, depth) + } +} + +pub enum Activity { + Accept(Accept), + Add(Add), + Announce(Announce), + Arrive(Arrive), + Block(Block), + Create(Create), + Delete(Delete), + Dislike(Dislike), + Flag(Flag), + Follow(Follow), + Ignore(Ignore), + Invite(Invite), + Join(Join), + Leave(Leave), + Like(Like), + Listen(Listen), + Move(Move), + Offer(Offer), + Question(Question), + Reject(Reject), + Read(Read), + Remove(Remove), + TentativeReject(TentativeReject), + TentativeAccept(TentativeAccept), + Travel(Travel), + Undo(Undo), + Update(Update), + View(View), +} + +impl> ParseApub for Activity { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + Accept::parse_apub(obj, vocab, ids) + .map(Self::Accept) + .or_else(|| Add::parse_apub(obj, vocab, ids).map(Self::Add)) + .or_else(|| Announce::parse_apub(obj, vocab, ids).map(Self::Announce)) + .or_else(|| Arrive::parse_apub(obj, vocab, ids).map(Self::Arrive)) + .or_else(|| Block::parse_apub(obj, vocab, ids).map(Self::Block)) + .or_else(|| Create::parse_apub(obj, vocab, ids).map(Self::Create)) + .or_else(|| Delete::parse_apub(obj, vocab, ids).map(Self::Delete)) + .or_else(|| Dislike::parse_apub(obj, vocab, ids).map(Self::Dislike)) + .or_else(|| Flag::parse_apub(obj, vocab, ids).map(Self::Flag)) + .or_else(|| Follow::parse_apub(obj, vocab, ids).map(Self::Follow)) + .or_else(|| Ignore::parse_apub(obj, vocab, ids).map(Self::Ignore)) + .or_else(|| Invite::parse_apub(obj, vocab, ids).map(Self::Invite)) + .or_else(|| Join::parse_apub(obj, vocab, ids).map(Self::Join)) + .or_else(|| Leave::parse_apub(obj, vocab, ids).map(Self::Leave)) + .or_else(|| Like::parse_apub(obj, vocab, ids).map(Self::Like)) + .or_else(|| Listen::parse_apub(obj, vocab, ids).map(Self::Listen)) + .or_else(|| Move::parse_apub(obj, vocab, ids).map(Self::Move)) + .or_else(|| Offer::parse_apub(obj, vocab, ids).map(Self::Offer)) + .or_else(|| Question::parse_apub(obj, vocab, ids).map(Self::Question)) + .or_else(|| Reject::parse_apub(obj, vocab, ids).map(Self::Reject)) + .or_else(|| Read::parse_apub(obj, vocab, ids).map(Self::Read)) + .or_else(|| Remove::parse_apub(obj, vocab, ids).map(Self::Remove)) + .or_else(|| TentativeReject::parse_apub(obj, vocab, ids).map(Self::TentativeReject)) + .or_else(|| TentativeAccept::parse_apub(obj, vocab, ids).map(Self::TentativeAccept)) + .or_else(|| Travel::parse_apub(obj, vocab, ids).map(Self::Travel)) + .or_else(|| Undo::parse_apub(obj, vocab, ids).map(Self::Undo)) + .or_else(|| Update::parse_apub(obj, vocab, ids).map(Self::Update)) + .or_else(|| View::parse_apub(obj, vocab, ids).map(Self::View)) + } +} + +impl DebugApub for Activity { + fn apub_class_name(&self) -> &str { + use Activity::*; + + match self { + Accept(a) => a.apub_class_name(), + Add(a) => a.apub_class_name(), + Announce(a) => a.apub_class_name(), + Arrive(a) => a.apub_class_name(), + Block(b) => b.apub_class_name(), + Create(c) => c.apub_class_name(), + Delete(d) => d.apub_class_name(), + Dislike(d) => d.apub_class_name(), + Flag(f) => f.apub_class_name(), + Follow(f) => f.apub_class_name(), + Ignore(i) => i.apub_class_name(), + Invite(i) => i.apub_class_name(), + Join(j) => j.apub_class_name(), + Leave(l) => l.apub_class_name(), + Like(l) => l.apub_class_name(), + Listen(l) => l.apub_class_name(), + Move(m) => m.apub_class_name(), + Offer(o) => o.apub_class_name(), + Question(q) => q.apub_class_name(), + Reject(r) => r.apub_class_name(), + Read(r) => r.apub_class_name(), + Remove(r) => r.apub_class_name(), + TentativeReject(t) => t.apub_class_name(), + TentativeAccept(t) => t.apub_class_name(), + Travel(t) => t.apub_class_name(), + Undo(u) => u.apub_class_name(), + Update(u) => u.apub_class_name(), + View(v) => v.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Activity::*; + + match self { + Accept(a) => a.debug_apub(f, depth), + Add(a) => a.debug_apub(f, depth), + Announce(a) => a.debug_apub(f, depth), + Arrive(a) => a.debug_apub(f, depth), + Block(b) => b.debug_apub(f, depth), + Create(c) => c.debug_apub(f, depth), + Delete(d) => d.debug_apub(f, depth), + Dislike(d) => d.debug_apub(f, depth), + Flag(flag) => flag.debug_apub(f, depth), + Follow(follow) => follow.debug_apub(f, depth), + Ignore(i) => i.debug_apub(f, depth), + Invite(i) => i.debug_apub(f, depth), + Join(j) => j.debug_apub(f, depth), + Leave(l) => l.debug_apub(f, depth), + Like(l) => l.debug_apub(f, depth), + Listen(l) => l.debug_apub(f, depth), + Move(m) => m.debug_apub(f, depth), + Offer(o) => o.debug_apub(f, depth), + Question(q) => q.debug_apub(f, depth), + Reject(r) => r.debug_apub(f, depth), + Read(r) => r.debug_apub(f, depth), + Remove(r) => r.debug_apub(f, depth), + TentativeReject(t) => t.debug_apub(f, depth), + TentativeAccept(t) => t.debug_apub(f, depth), + Travel(t) => t.debug_apub(f, depth), + Undo(u) => u.debug_apub(f, depth), + Update(u) => u.debug_apub(f, depth), + View(v) => v.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Activity::*; + + match self { + Accept(a) => a.debug_apub_members(f, depth), + Add(a) => a.debug_apub_members(f, depth), + Announce(a) => a.debug_apub_members(f, depth), + Arrive(a) => a.debug_apub_members(f, depth), + Block(b) => b.debug_apub_members(f, depth), + Create(c) => c.debug_apub_members(f, depth), + Delete(d) => d.debug_apub_members(f, depth), + Dislike(d) => d.debug_apub_members(f, depth), + Flag(flag) => flag.debug_apub_members(f, depth), + Follow(follow) => follow.debug_apub_members(f, depth), + Ignore(i) => i.debug_apub_members(f, depth), + Invite(i) => i.debug_apub_members(f, depth), + Join(j) => j.debug_apub_members(f, depth), + Leave(l) => l.debug_apub_members(f, depth), + Like(l) => l.debug_apub_members(f, depth), + Listen(l) => l.debug_apub_members(f, depth), + Move(m) => m.debug_apub_members(f, depth), + Offer(o) => o.debug_apub_members(f, depth), + Question(q) => q.debug_apub_members(f, depth), + Reject(r) => r.debug_apub_members(f, depth), + Read(r) => r.debug_apub_members(f, depth), + Remove(r) => r.debug_apub_members(f, depth), + TentativeReject(t) => t.debug_apub_members(f, depth), + TentativeAccept(t) => t.debug_apub_members(f, depth), + Travel(t) => t.debug_apub_members(f, depth), + Undo(u) => u.debug_apub_members(f, depth), + Update(u) => u.debug_apub_members(f, depth), + View(v) => v.debug_apub_members(f, depth), + } + } +} + +pub struct Question { + _super: AbstractActivity, + pub one_of: Vec, + pub any_of: Vec, + pub closed: Option, +} +ap_extends!(Question, AbstractActivity); + +impl> ParseApub for Question { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.question)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractActivity::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + one_of: ph.parse_prop_vec(&prop_ids.one_of), + any_of: ph.parse_prop_vec(&prop_ids.any_of), + closed: ph.parse_prop(&prop_ids.closed), + }) + } +} + +impl DebugApub for Question { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop_vec!(self, f, one_of, depth)?; + ap_display_prop_vec!(self, f, any_of, depth)?; + ap_display_prop!(self, f, closed, depth) + } +} + +ap_empty_child_impl!(Accept, AbstractActivity, apub, activity, accept); +ap_empty_child_impl!(Add, AbstractActivity, apub, activity, add); +ap_empty_child_impl!(Announce, AbstractActivity, apub, activity, announce); +ap_empty_child_impl!(Arrive, AbstractActivity, apub, activity, arrive); +ap_empty_child_impl!(Block, Ignore, apub, activity, block); +ap_empty_child_impl!(Create, AbstractActivity, apub, activity, create); +ap_empty_child_impl!(Delete, AbstractActivity, apub, activity, delete); +ap_empty_child_impl!(Dislike, AbstractActivity, apub, activity, dislike); +ap_empty_child_impl!(Flag, AbstractActivity, apub, activity, flag); +ap_empty_child_impl!(Follow, AbstractActivity, apub, activity, follow); +ap_empty_child_impl!(Ignore, AbstractActivity, apub, activity, ignore); +ap_empty_child_impl!(Invite, AbstractActivity, apub, activity, invite); +ap_empty_child_impl!(Join, AbstractActivity, apub, activity, join); +ap_empty_child_impl!(Leave, AbstractActivity, apub, activity, leave); +ap_empty_child_impl!(Like, AbstractActivity, apub, activity, like); +ap_empty_child_impl!(Listen, AbstractActivity, apub, activity, listen); +ap_empty_child_impl!(Move, AbstractActivity, apub, activity, mov); +ap_empty_child_impl!(Offer, AbstractActivity, apub, activity, offer); +ap_empty_child_impl!(Reject, AbstractActivity, apub, activity, reject); +ap_empty_child_impl!(Read, AbstractActivity, apub, activity, read); +ap_empty_child_impl!(Remove, AbstractActivity, apub, activity, remove); +ap_empty_child_impl!( + TentativeReject, + AbstractActivity, + apub, + activity, + tentative_reject +); +ap_empty_child_impl!( + TentativeAccept, + AbstractActivity, + apub, + activity, + tentative_accept +); +ap_empty_child_impl!(Travel, AbstractActivity, apub, activity, travel); +ap_empty_child_impl!(Undo, AbstractActivity, apub, activity, undo); +ap_empty_child_impl!(Update, AbstractActivity, apub, activity, update); +ap_empty_child_impl!(View, AbstractActivity, apub, activity, view); diff --git a/src/ap/trans/actor.rs b/src/ap/trans/actor.rs new file mode 100644 index 0000000..64c60e6 --- /dev/null +++ b/src/ap/trans/actor.rs @@ -0,0 +1,124 @@ +use iref::IriBuf; +use rdf_types::vocabulary::IriIndex; +use rdf_types::Vocabulary; +use std::fmt; + +use crate::ap::trans::{AbstractObject, ApDocument, DebugApub, ParseApub, PropHelper, RawObject}; +use crate::ap::vocab::Ids; + +pub struct AbstractActor { + _super: AbstractObject, + inbox: Option, + outbox: Option, + following: Option, + followers: Option, + liked: Option, + preferred_username: Option, + // Mastodon extensions + featured: Option>, + featured_tags: Option>, + discoverable: Option, + suspended: Option, +} +ap_extends!(AbstractActor, AbstractObject); + +impl> ParseApub for AbstractActor { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + + let result = unsafe { AbstractObject::_parse_apub_unchecked(obj, vocab, ids) }; + result.map(|s| Self { + _super: s, + inbox: ph.parse_prop(&ids.apub.property.inbox), + outbox: ph.parse_prop(&ids.apub.property.outbox), + following: ph.parse_prop(&ids.apub.property.following), + followers: ph.parse_prop(&ids.apub.property.followers), + liked: ph.parse_prop(&ids.apub.property.liked), + preferred_username: ph.parse_prop(&ids.apub.property.preferred_username), + featured: ph.parse_prop_box(&ids.toot.props.featured), + featured_tags: ph.parse_prop_box(&ids.toot.props.featured_tags), + discoverable: ph.parse_prop(&ids.toot.props.discoverable), + suspended: ph.parse_prop(&ids.toot.props.suspended), + }) + } +} + +impl DebugApub for AbstractActor { + fn apub_class_name(&self) -> &str { + "Actor" + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop!(self, f, inbox, depth)?; + ap_display_prop!(self, f, outbox, depth)?; + ap_display_prop!(self, f, following, depth)?; + ap_display_prop!(self, f, followers, depth)?; + ap_display_prop!(self, f, liked, depth)?; + ap_display_prop!(self, f, preferred_username, depth)?; + ap_display_prop_box!(self, f, featured, depth)?; + ap_display_prop_box!(self, f, featured_tags, depth)?; + ap_display_prop!(self, f, discoverable, depth)?; + ap_display_prop!(self, f, suspended, depth) + } +} + +pub enum Actor { + Application(Application), + Group(Group), + Organization(Organization), + Person(Person), + Service(Service), +} + +impl> ParseApub for Actor { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + Application::parse_apub(obj, vocab, ids) + .map(Self::Application) + .or_else(|| Group::parse_apub(obj, vocab, ids).map(Self::Group)) + .or_else(|| Organization::parse_apub(obj, vocab, ids).map(Self::Organization)) + .or_else(|| Person::parse_apub(obj, vocab, ids).map(Self::Person)) + .or_else(|| Service::parse_apub(obj, vocab, ids).map(Self::Service)) + } +} + +impl DebugApub for Actor { + fn apub_class_name(&self) -> &str { + use Actor::*; + match self { + Application(a) => a.apub_class_name(), + Group(g) => g.apub_class_name(), + Organization(o) => o.apub_class_name(), + Person(p) => p.apub_class_name(), + Service(s) => s.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Actor::*; + match self { + Application(a) => a.debug_apub(f, depth), + Group(g) => g.debug_apub(f, depth), + Organization(o) => o.debug_apub(f, depth), + Person(p) => p.debug_apub(f, depth), + Service(s) => s.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Actor::*; + match self { + Application(a) => a.debug_apub_members(f, depth), + Group(g) => g.debug_apub_members(f, depth), + Organization(o) => o.debug_apub_members(f, depth), + Person(p) => p.debug_apub_members(f, depth), + Service(s) => s.debug_apub_members(f, depth), + } + } +} + +ap_empty_child_impl!(Application, AbstractActor, apub, object, application); +ap_empty_child_impl!(Group, AbstractActor, apub, object, group); +ap_empty_child_impl!(Organization, AbstractActor, apub, object, organization); +ap_empty_child_impl!(Person, AbstractActor, apub, object, person); +ap_empty_child_impl!(Service, AbstractActor, apub, object, service); diff --git a/src/ap/trans/link.rs b/src/ap/trans/link.rs new file mode 100644 index 0000000..56c5bea --- /dev/null +++ b/src/ap/trans/link.rs @@ -0,0 +1,103 @@ +use iref::IriBuf; +use mime::Mime; +use rdf_types::vocabulary::IriIndex; +use rdf_types::Vocabulary; +use std::fmt; + +use crate::ap::trans::{matches_type, ApDocument, DebugApub, ParseApub, PropHelper, RawObject}; +use crate::ap::vocab::Ids; + +pub enum Link { + Link(BaseLink), + Mention(Mention), +} + +impl> ParseApub for Link { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + BaseLink::parse_apub(obj, vocab, ids) + .map(Self::Link) + .or_else(|| Mention::parse_apub(obj, vocab, ids).map(Self::Mention)) + } +} + +impl DebugApub for Link { + fn apub_class_name(&self) -> &str { + match self { + Link::Link(link) => link.apub_class_name(), + Link::Mention(mention) => mention.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + match self { + Link::Link(link) => link.debug_apub(f, depth), + Link::Mention(mention) => mention.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + match self { + Link::Link(link) => link.debug_apub_members(f, depth), + Link::Mention(mention) => mention.debug_apub_members(f, depth), + } + } +} + +pub struct BaseLink { + pub id: Option, + pub href: Option, + pub rel: Option, + pub media_type: Option, + pub name: Option, // TODO: this could be a langString + pub hreflang: Option, + pub height: Option, + pub width: Option, + pub preview: Option>, +} + +impl> ParseApub for BaseLink { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.link.link)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + Some(Self { + id: obj + .id() + .and_then(|id| id.as_iri()) + .and_then(|idx| vocab.iri(idx)) + .map(|iri| iri.to_owned()), + href: ph.parse_prop(&prop_ids.href), + rel: ph.parse_prop(&prop_ids.rel), + media_type: ph.parse_prop(&prop_ids.media_type), + name: ph.parse_prop(&prop_ids.name), + hreflang: ph.parse_prop(&prop_ids.hreflang), + height: ph.parse_prop(&prop_ids.height), + width: ph.parse_prop(&prop_ids.width), + preview: ph.parse_prop_box(&prop_ids.preview), + }) + } +} + +impl DebugApub for BaseLink { + fn apub_class_name(&self) -> &str { + "Link" + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + ap_display_prop!(self, f, href, depth)?; + ap_display_prop!(self, f, rel, depth)?; + ap_display_prop!(self, f, media_type, depth)?; + ap_display_prop!(self, f, name, depth)?; + ap_display_prop!(self, f, hreflang, depth)?; + ap_display_prop!(self, f, height, depth)?; + ap_display_prop!(self, f, width, depth)?; + ap_display_prop_box!(self, f, preview, depth) + } +} + +ap_empty_child_impl!(Mention, BaseLink, apub, link, mention); diff --git a/src/ap/trans/mod.rs b/src/ap/trans/mod.rs new file mode 100644 index 0000000..74a8b2a --- /dev/null +++ b/src/ap/trans/mod.rs @@ -0,0 +1,652 @@ +//! A (nearly) complete model of ActivityPub's classes, with translators. +//! This is used as an intermediate representation between raw JSON-LD objects +//! as spit out by the loader, and our own internal representation of the data. + +use chrono::NaiveDateTime; +use iref::IriBuf; +use json_ld::object::node::Properties; +use json_ld::IndexedObject; +use locspan::Span; +use mime::Mime; +use rdf_types::vocabulary::{BlankIdIndex, IriIndex}; +use rdf_types::Vocabulary; +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; + +use crate::ap::vocab::{Id, Ids}; +use crate::core::*; +use crate::util::xsd; + +pub type RawProps = Properties; +pub type RawObject = IndexedObject; + +pub trait ParseApub>: Sized { + /// Attempt to translate a raw JSON-LD object (as in, from the `json-ld` crate) + /// to this type. Returns the parsed object on success, and the input on failure. + /// Unsupported properties SHOULD be logged but otherwise ignored. + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option; + + /// Only for internal use from subclasses, **DO NOT TOUCH**. + /// Can cause an infinite recursion loop that ends in a segfault. + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + Self::parse_apub(obj, vocab, ids) + } +} + +trait DebugApub { + fn apub_class_name(&self) -> &str { + std::any::type_name::().split("::").last().unwrap() + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + f.write_str(self.apub_class_name())?; + f.write_str(" {\n")?; + self.debug_apub_members(f, depth + 1)?; + writeln!(f, "{}}}", indent(depth)) + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result; +} + +// just ... don't ask questions and be glad this, somehow, works + +macro_rules! ap_extends { + ($child:ident, $parent:ty) => { + impl ::std::ops::Deref for $child { + type Target = $parent; + + fn deref(&self) -> &Self::Target { + &self._super + } + } + + impl ::std::ops::DerefMut for $child { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self._super + } + } + }; +} + +macro_rules! ap_empty_child { + ($child:ident, $parent:ty) => { + pub struct $child { + _super: $parent, + } + ap_extends!($child, $parent); + + impl $crate::ap::trans::DebugApub for $child { + fn debug_apub_members( + &self, + f: &mut ::std::fmt::Formatter, + depth: usize, + ) -> ::std::fmt::Result { + $crate::ap::trans::DebugApub::debug_apub_members(&self._super, f, depth) + } + } + }; +} + +macro_rules! ap_empty_child_impl { + ($child:ident, $parent:ty, $id1:ident, $id2:ident, $id3:ident) => { + ap_empty_child!($child, $parent); + + impl $crate::ap::trans::ParseApub for $child + where + V: ::rdf_types::Vocabulary, + { + fn parse_apub( + obj: &$crate::ap::trans::RawObject, + vocab: &V, + ids: &$crate::ap::vocab::Ids, + ) -> Option { + $crate::ap::trans::matches_type(obj, &ids.$id1.$id2.$id3)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked( + obj: &$crate::ap::trans::RawObject, + vocab: &V, + ids: &$crate::ap::vocab::Ids, + ) -> Option { + <$parent>::_parse_apub_unchecked(obj, vocab, ids).map(|p| Self { _super: p }) + } + } + }; +} + +macro_rules! ap_display_prop { + ($this:expr, $f:ident, $name:ident, $depth:expr) => { + $crate::ap::trans::display_prop($f, stringify!($name), $this.$name.as_ref(), $depth) + }; +} + +macro_rules! ap_display_prop_box { + ($this:expr, $f:ident, $name:ident, $depth:expr) => { + $crate::ap::trans::display_prop($f, stringify!($name), $this.$name.as_deref(), $depth) + }; +} + +macro_rules! ap_display_prop_vec { + ($this:expr, $f:ident, $name:ident, $depth:expr) => { + $crate::ap::trans::display_prop_vec($f, stringify!($name), $this.$name.as_slice(), $depth) + }; +} + +mod activity; +pub use activity::*; + +mod actor; +pub use actor::*; + +mod link; +pub use link::*; + +mod object; +pub use object::*; + +/// The top-level type for any kind of object or link +pub enum ApDocument { + Object(Object), + Link(Link), + Remote(String), +} + +impl> ParseApub for ApDocument { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + if let Some(s) = obj.as_value().and_then(|v| v.as_str()) { + Some(Self::Remote(String::from(s))) + } else { + Object::parse_apub(obj, vocab, ids) + .map(Self::Object) + .or_else(|| Link::parse_apub(obj, vocab, ids).map(Self::Link)) + } + } +} + +impl DebugApub for ApDocument { + fn apub_class_name(&self) -> &str { + use ApDocument::*; + match self { + Object(o) => o.apub_class_name(), + Link(l) => l.apub_class_name(), + Remote(_) => "", + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use ApDocument::*; + match self { + Object(o) => o.debug_apub(f, depth), + Link(l) => l.debug_apub(f, depth), + Remote(url) => writeln!(f, "{} => {url}", indent(depth)), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use ApDocument::*; + match self { + Object(o) => o.debug_apub_members(f, depth), + Link(l) => l.debug_apub_members(f, depth), + Remote(url) => writeln!(f, "{} => {url}", indent(depth)), + } + } +} + +impl fmt::Debug for ApDocument { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.debug_apub(f, 0) + } +} + +pub enum ImageOrLink { + Link(Link), + Image(Image), +} + +impl> ParseApub for ImageOrLink { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + Image::parse_apub(obj, vocab, ids) + .map(Self::Image) + .or_else(|| Link::parse_apub(obj, vocab, ids).map(Self::Link)) + } +} + +impl DebugApub for ImageOrLink { + fn apub_class_name(&self) -> &str { + match self { + ImageOrLink::Link(link) => link.apub_class_name(), + ImageOrLink::Image(image) => image.apub_class_name(), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + match self { + ImageOrLink::Link(link) => link.debug_apub_members(f, depth), + ImageOrLink::Image(image) => image.debug_apub_members(f, depth), + } + } +} + +pub enum Collection { + Base(BaseCollection), + Ordered(OrderedCollection), + Page(CollectionPage), + OrderedPage(OrderedCollectionPage), +} + +impl> ParseApub for Collection { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + BaseCollection::parse_apub(obj, vocab, ids) + .map(Self::Base) + .or_else(|| OrderedCollection::parse_apub(obj, vocab, ids).map(Self::Ordered)) + .or_else(|| CollectionPage::parse_apub(obj, vocab, ids).map(Self::Page)) + .or_else(|| OrderedCollectionPage::parse_apub(obj, vocab, ids).map(Self::OrderedPage)) + } +} + +impl DebugApub for Collection { + fn apub_class_name(&self) -> &str { + use Collection::*; + match self { + Base(b) => b.apub_class_name(), + Ordered(o) => o.apub_class_name(), + Page(p) => p.apub_class_name(), + OrderedPage(o) => o.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Collection::*; + match self { + Base(b) => b.debug_apub(f, depth), + Ordered(o) => o.debug_apub(f, depth), + Page(p) => p.debug_apub(f, depth), + OrderedPage(o) => o.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Collection::*; + match self { + Base(b) => b.debug_apub_members(f, depth), + Ordered(o) => o.debug_apub_members(f, depth), + Page(p) => p.debug_apub_members(f, depth), + OrderedPage(o) => o.debug_apub_members(f, depth), + } + } +} + +pub struct BaseCollection { + _super: AbstractObject, + pub total_items: Option, + pub current: Option>, + pub first: Option>, + pub last: Option>, + pub items: Vec, +} +ap_extends!(BaseCollection, AbstractObject); + +impl> ParseApub for BaseCollection { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.collection)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + total_items: ph.parse_prop(&prop_ids.total_items), + current: ph.parse_prop_box(&prop_ids.current), + first: ph.parse_prop_box(&prop_ids.first), + last: ph.parse_prop_box(&prop_ids.last), + items: ph.parse_prop_vec(&prop_ids.items), + }) + } +} + +impl DebugApub for BaseCollection { + fn apub_class_name(&self) -> &str { + "Collection" + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop!(self, f, total_items, depth)?; + ap_display_prop_box!(self, f, current, depth)?; + ap_display_prop_box!(self, f, first, depth)?; + ap_display_prop_box!(self, f, last, depth)?; + ap_display_prop_vec!(self, f, items, depth) + } +} + +ap_empty_child_impl!( + OrderedCollection, + BaseCollection, + apub, + object, + ordered_collection +); + +pub struct CollectionPage { + _super: BaseCollection, + pub part_of: Option>, + pub next: Option>, + pub prev: Option>, +} +ap_extends!(CollectionPage, BaseCollection); + +impl> ParseApub for CollectionPage { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.collection_page)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + BaseCollection::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + part_of: ph.parse_prop_box(&prop_ids.part_of), + next: ph.parse_prop_box(&prop_ids.next), + prev: ph.parse_prop_box(&prop_ids.prev), + }) + } +} + +impl DebugApub for CollectionPage { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop_box!(self, f, part_of, depth)?; + ap_display_prop_box!(self, f, next, depth)?; + ap_display_prop_box!(self, f, prev, depth) + } +} + +pub struct OrderedCollectionPage { + _super: CollectionPage, + pub start_index: Option, +} +ap_extends!(OrderedCollectionPage, CollectionPage); + +impl> ParseApub for OrderedCollectionPage { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.ordered_collection_page)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + CollectionPage::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + start_index: ph.parse_prop(&prop_ids.start_index), + }) + } +} + +impl DebugApub for OrderedCollectionPage { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop!(self, f, start_index, depth) + } +} + +pub enum CollectionOrLink { + Collection(Collection), + Link(BaseLink), +} + +impl> ParseApub for CollectionOrLink { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + Collection::parse_apub(obj, vocab, ids) + .map(Self::Collection) + .or_else(|| BaseLink::parse_apub(obj, vocab, ids).map(Self::Link)) + } +} + +impl DebugApub for CollectionOrLink { + fn apub_class_name(&self) -> &str { + use CollectionOrLink::*; + match self { + Collection(c) => c.apub_class_name(), + Link(l) => l.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use CollectionOrLink::*; + match self { + Collection(c) => c.debug_apub(f, depth), + Link(l) => l.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use CollectionOrLink::*; + match self { + Collection(c) => c.debug_apub_members(f, depth), + Link(l) => l.debug_apub_members(f, depth), + } + } +} + +pub enum CollectionPageOrLink { + CollectionPage(CollectionPage), + Link(BaseLink), +} + +impl> ParseApub for CollectionPageOrLink { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + CollectionPage::parse_apub(obj, vocab, ids) + .map(Self::CollectionPage) + .or_else(|| BaseLink::parse_apub(obj, vocab, ids).map(Self::Link)) + } +} + +impl DebugApub for CollectionPageOrLink { + fn apub_class_name(&self) -> &str { + use CollectionPageOrLink::*; + match self { + CollectionPage(c) => c.apub_class_name(), + Link(l) => l.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use CollectionPageOrLink::*; + match self { + CollectionPage(c) => c.debug_apub(f, depth), + Link(l) => l.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use CollectionPageOrLink::*; + match self { + CollectionPage(c) => c.debug_apub_members(f, depth), + Link(l) => l.debug_apub_members(f, depth), + } + } +} + +impl> ParseApub for IriBuf { + fn parse_apub(obj: &RawObject, vocab: &V, _ids: &Ids) -> Option { + vocab.iri(obj.as_iri()?).map(|iri| iri.to_owned()) + } +} + +impl> ParseApub for xsd::Duration { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + xsd::Duration::from_str(obj.as_value()?.as_str()?).ok() + } +} + +impl> ParseApub for NaiveDateTime { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + use chrono::{DateTime, Utc}; + // TODO: close enough for now, but this only supports UTC + let dt = DateTime::::from_str(obj.as_value()?.as_str()?).ok()?; + Some(dt.naive_utc()) + } +} + +impl> ParseApub for Mime { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + Mime::from_str(obj.as_value()?.as_str()?).ok() + } +} + +impl> ParseApub for String { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + Some(obj.as_value()?.as_str()?.to_owned()) + } +} + +impl> ParseApub for u32 { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + obj.as_value()?.as_number()?.as_u32() + } +} + +impl> ParseApub for f32 { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + Some(obj.as_value()?.as_number()?.as_f32_lossy()) + } +} + +impl> ParseApub for bool { + fn parse_apub(obj: &RawObject, _vocab: &V, _ids: &Ids) -> Option { + obj.as_bool() + } +} + +fn matches_type(obj: &RawObject, iri_id: &Id) -> Option<()> { + let iri = iri_id.as_iri().expect("IDs should only refer to IRIs"); + let type_matches = obj + .types() + .any(|t| t.as_iri().is_some_and(|index| index == iri)); + if type_matches { + Some(()) + } else { + None + } +} + +struct PropHelper<'a, V: Vocabulary> { + props: &'a RawProps, + vocab: &'a V, + ids: &'a Ids, +} + +impl<'a, V: Vocabulary> PropHelper<'a, V> { + fn new(obj: &'a RawObject, vocab: &'a V, ids: &'a Ids) -> Option { + let props = obj.as_node()?.properties(); + Some(Self { props, vocab, ids }) + } + + fn parse_prop>(&self, prop_id: &Id) -> Option { + T::parse_apub(self.props.get_any(prop_id)?, self.vocab, self.ids).or_else(|| { + let iri = prop_id + .as_iri() + .and_then(|index| self.vocab.iri(index)) + .expect("predefined IRIs must always exist"); + warn!("Ignoring unknown value for property {iri}"); + None + }) + } + + fn parse_prop_box>(&self, prop_id: &Id) -> Option> { + self.parse_prop(prop_id).map(Box::new) + } + + fn parse_prop_vec>(&self, prop_id: &Id) -> Vec { + self.props + .get(prop_id) + .filter_map(|prop| T::parse_apub(prop, self.vocab, self.ids)) + .collect() + } +} + +impl DebugApub for IriBuf { + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self.debug_apub_members(f, depth) + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, _depth: usize) -> fmt::Result { + writeln!(f, "{}", self.as_str()) + } +} + +impl DebugApub for Mime { + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self.debug_apub_members(f, depth) + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, _depth: usize) -> fmt::Result { + writeln!(f, "{self}") + } +} + +macro_rules! primitive_debug_apub_impl { + ($t:ty) => { + impl DebugApub for $t { + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self.debug_apub_members(f, depth) + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, _depth: usize) -> fmt::Result { + writeln!(f, "{self:?}") + } + } + }; +} + +primitive_debug_apub_impl!(bool); +primitive_debug_apub_impl!(u32); +primitive_debug_apub_impl!(f32); +primitive_debug_apub_impl!(String); +primitive_debug_apub_impl!(NaiveDateTime); +primitive_debug_apub_impl!(xsd::Duration); +primitive_debug_apub_impl!(HashMap); + +fn display_prop(f: &mut fmt::Formatter, name: &str, val: Option<&T>, depth: usize) -> fmt::Result +where + T: DebugApub, +{ + match val { + Some(val) => { + write!(f, "{}{name}: ", indent(depth))?; + val.debug_apub(f, depth) + } + None => Ok(()), + } +} + +fn display_prop_vec(f: &mut fmt::Formatter, name: &str, val: &[T], depth: usize) -> fmt::Result +where + T: DebugApub, +{ + match val.len() { + 0 => Ok(()), + 1 => display_prop(f, name, Some(&val[0]), depth), + _ => { + writeln!(f, "{}{name}: [", indent(depth))?; + for v in val { + write!(f, "{}", indent(depth))?; + v.debug_apub(f, depth + 1)?; + } + writeln!(f, "{}]", indent(depth)) + } + } +} + +fn indent(n: usize) -> impl fmt::Display { + " ".repeat(n) +} diff --git a/src/ap/trans/object.rs b/src/ap/trans/object.rs new file mode 100644 index 0000000..f4042f8 --- /dev/null +++ b/src/ap/trans/object.rs @@ -0,0 +1,428 @@ +use chrono::NaiveDateTime; +use iref::IriBuf; +use mime::Mime; +use rdf_types::vocabulary::IriIndex; +use rdf_types::Vocabulary; +use std::fmt; + +use crate::ap::trans::{ + activity, actor, matches_type, ApDocument, BaseCollection, Collection, DebugApub, ImageOrLink, + ParseApub, PropHelper, RawObject, +}; +use crate::ap::vocab::Ids; +use crate::util::xsd; + +// The ActivityStreams vocabulary actually defines Image, Audio, Video, and Page +// as subclasses of Document. However, since Document is a child of Object and +// does not introduce any new properties, we simplify the class hierarchy by +// making all of the ones mentioned above direct subclasses of Object. + +pub enum Object { + Activity(activity::Activity), + Actor(actor::Actor), + Article(Article), + Audio(Audio), + Document(Document), + Event(Event), + Image(Image), + Note(Note), + Page(Page), + Place(Place), + Profile(Profile), + Relationship(Relationship), + Tombstone(Tombstone), + Video(Video), + Collection(Collection), + + // Mastodon extensions + Emoji(Emoji), +} + +impl> ParseApub for Object { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + activity::Activity::parse_apub(obj, vocab, ids) + .map(Self::Activity) + .or_else(|| actor::Actor::parse_apub(obj, vocab, ids).map(Self::Actor)) + .or_else(|| Article::parse_apub(obj, vocab, ids).map(Self::Article)) + .or_else(|| Audio::parse_apub(obj, vocab, ids).map(Self::Audio)) + .or_else(|| Document::parse_apub(obj, vocab, ids).map(Self::Document)) + .or_else(|| Event::parse_apub(obj, vocab, ids).map(Self::Event)) + .or_else(|| Image::parse_apub(obj, vocab, ids).map(Self::Image)) + .or_else(|| Note::parse_apub(obj, vocab, ids).map(Self::Note)) + .or_else(|| Page::parse_apub(obj, vocab, ids).map(Self::Page)) + .or_else(|| Place::parse_apub(obj, vocab, ids).map(Self::Place)) + .or_else(|| Profile::parse_apub(obj, vocab, ids).map(Self::Profile)) + .or_else(|| Relationship::parse_apub(obj, vocab, ids).map(Self::Relationship)) + .or_else(|| Tombstone::parse_apub(obj, vocab, ids).map(Self::Tombstone)) + .or_else(|| Video::parse_apub(obj, vocab, ids).map(Self::Video)) + .or_else(|| Collection::parse_apub(obj, vocab, ids).map(Self::Collection)) + .or_else(|| Emoji::parse_apub(obj, vocab, ids).map(Self::Emoji)) + } +} + +impl DebugApub for Object { + fn apub_class_name(&self) -> &str { + use Object::*; + match self { + Activity(a) => a.apub_class_name(), + Actor(a) => a.apub_class_name(), + Article(a) => a.apub_class_name(), + Audio(a) => a.apub_class_name(), + Document(d) => d.apub_class_name(), + Event(e) => e.apub_class_name(), + Image(i) => i.apub_class_name(), + Note(n) => n.apub_class_name(), + Page(p) => p.apub_class_name(), + Place(p) => p.apub_class_name(), + Profile(p) => p.apub_class_name(), + Relationship(r) => r.apub_class_name(), + Tombstone(t) => t.apub_class_name(), + Video(v) => v.apub_class_name(), + Collection(c) => c.apub_class_name(), + Emoji(e) => e.apub_class_name(), + } + } + + fn debug_apub(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Object::*; + match self { + Activity(a) => a.debug_apub(f, depth), + Actor(a) => a.debug_apub(f, depth), + Article(a) => a.debug_apub(f, depth), + Audio(a) => a.debug_apub(f, depth), + Document(d) => d.debug_apub(f, depth), + Event(e) => e.debug_apub(f, depth), + Image(i) => i.debug_apub(f, depth), + Note(n) => n.debug_apub(f, depth), + Page(p) => p.debug_apub(f, depth), + Place(p) => p.debug_apub(f, depth), + Profile(p) => p.debug_apub(f, depth), + Relationship(r) => r.debug_apub(f, depth), + Tombstone(t) => t.debug_apub(f, depth), + Video(v) => v.debug_apub(f, depth), + Collection(c) => c.debug_apub(f, depth), + Emoji(e) => e.debug_apub(f, depth), + } + } + + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + use Object::*; + match self { + Activity(a) => a.debug_apub_members(f, depth), + Actor(a) => a.debug_apub_members(f, depth), + Article(a) => a.debug_apub_members(f, depth), + Audio(a) => a.debug_apub_members(f, depth), + Document(d) => d.debug_apub_members(f, depth), + Event(e) => e.debug_apub_members(f, depth), + Image(i) => i.debug_apub_members(f, depth), + Note(n) => n.debug_apub_members(f, depth), + Page(p) => p.debug_apub_members(f, depth), + Place(p) => p.debug_apub_members(f, depth), + Profile(p) => p.debug_apub_members(f, depth), + Relationship(r) => r.debug_apub_members(f, depth), + Tombstone(t) => t.debug_apub_members(f, depth), + Video(v) => v.debug_apub_members(f, depth), + Collection(c) => c.debug_apub_members(f, depth), + Emoji(e) => e.debug_apub_members(f, depth), + } + } +} + +pub struct AbstractObject { + pub id: Option, + pub attachment: Vec, + pub attributed_to: Vec, + pub audience: Vec, + pub content: Option, // TODO: this could be a langString + pub context: Option>, + pub name: Option, // TODO: this could be a langString + pub end_time: Option, + pub generator: Option>, + pub icon: Option>, + pub image: Option>, + pub in_reply_to: Vec, + pub location: Option>, + pub preview: Option>, + pub published: Option, + pub replies: Option>, + pub start_time: Option, + pub summary: Option, // TODO: this could be a langString + pub tag: Option>, + pub updated: Option, + pub url: Option, + pub to: Option>, + pub bto: Option>, + pub cc: Option>, + pub bcc: Option>, + pub media_type: Option, + pub duration: Option, +} + +impl> ParseApub for AbstractObject { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + Some(Self { + id: obj + .id() + .and_then(|id| id.as_iri()) + .and_then(|idx| vocab.iri(idx)) + .map(|iri| iri.to_owned()), + attachment: ph.parse_prop_vec(&prop_ids.attachment), + attributed_to: ph.parse_prop_vec(&prop_ids.attributed_to), + audience: ph.parse_prop_vec(&prop_ids.audience), + content: ph.parse_prop(&prop_ids.content), + context: ph.parse_prop_box(&prop_ids.context), + name: ph.parse_prop(&prop_ids.name), + end_time: ph.parse_prop(&prop_ids.end_time), + generator: ph.parse_prop_box(&prop_ids.generator), + icon: ph.parse_prop_box(&prop_ids.icon), + image: ph.parse_prop_box(&prop_ids.image), + in_reply_to: ph.parse_prop_vec(&prop_ids.in_reply_to), + location: ph.parse_prop_box(&prop_ids.location), + preview: ph.parse_prop_box(&prop_ids.preview), + published: ph.parse_prop(&prop_ids.published), + replies: ph.parse_prop_box(&prop_ids.replies), + start_time: ph.parse_prop(&prop_ids.start_time), + summary: ph.parse_prop(&prop_ids.summary), + tag: ph.parse_prop_box(&prop_ids.tag), + updated: ph.parse_prop(&prop_ids.updated), + url: ph.parse_prop(&prop_ids.url), + to: ph.parse_prop_box(&prop_ids.to), + bto: ph.parse_prop_box(&prop_ids.bto), + cc: ph.parse_prop_box(&prop_ids.cc), + bcc: ph.parse_prop_box(&prop_ids.bcc), + media_type: ph.parse_prop(&prop_ids.media_type), + duration: ph.parse_prop(&prop_ids.duration), + }) + } +} + +impl DebugApub for AbstractObject { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + ap_display_prop!(self, f, id, depth)?; + ap_display_prop_vec!(self, f, attachment, depth)?; + ap_display_prop_vec!(self, f, attributed_to, depth)?; + ap_display_prop_vec!(self, f, audience, depth)?; + ap_display_prop!(self, f, content, depth)?; + ap_display_prop_box!(self, f, context, depth)?; + ap_display_prop!(self, f, name, depth)?; + ap_display_prop!(self, f, end_time, depth)?; + ap_display_prop_box!(self, f, generator, depth)?; + ap_display_prop_box!(self, f, icon, depth)?; + ap_display_prop_box!(self, f, image, depth)?; + ap_display_prop_vec!(self, f, in_reply_to, depth)?; + ap_display_prop_box!(self, f, location, depth)?; + ap_display_prop_box!(self, f, preview, depth)?; + ap_display_prop!(self, f, published, depth)?; + ap_display_prop_box!(self, f, replies, depth)?; + ap_display_prop!(self, f, start_time, depth)?; + ap_display_prop!(self, f, summary, depth)?; + ap_display_prop_box!(self, f, tag, depth)?; + ap_display_prop!(self, f, updated, depth)?; + ap_display_prop!(self, f, url, depth)?; + ap_display_prop_box!(self, f, to, depth)?; + ap_display_prop_box!(self, f, bto, depth)?; + ap_display_prop_box!(self, f, cc, depth)?; + ap_display_prop_box!(self, f, bcc, depth)?; + ap_display_prop!(self, f, media_type, depth)?; + ap_display_prop!(self, f, duration, depth) + } +} + +pub struct Relationship { + _super: AbstractObject, + pub subject: Option>, + pub object: Option>, + pub relationship: Option>, +} +ap_extends!(Relationship, AbstractObject); + +impl> ParseApub for Relationship { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.relationship)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + subject: ph.parse_prop_box(&prop_ids.subject), + object: ph.parse_prop_box(&prop_ids.object), + relationship: ph.parse_prop_box(&prop_ids.relationship), + }) + } +} + +impl DebugApub for Relationship { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop_box!(self, f, subject, depth)?; + ap_display_prop_box!(self, f, object, depth)?; + ap_display_prop_box!(self, f, relationship, depth) + } +} + +pub struct Place { + _super: AbstractObject, + pub accuracy: Option, + pub altitude: Option, + pub latitude: Option, + pub longitude: Option, + pub radius: Option, + pub units: Option, +} +ap_extends!(Place, AbstractObject); + +impl> ParseApub for Place { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.place)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + accuracy: ph.parse_prop(&prop_ids.accuracy), + altitude: ph.parse_prop(&prop_ids.altitude), + latitude: ph.parse_prop(&prop_ids.latitude), + longitude: ph.parse_prop(&prop_ids.longitude), + radius: ph.parse_prop(&prop_ids.radius), + units: ph.parse_prop(&prop_ids.units), + }) + } +} + +impl DebugApub for Place { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop!(self, f, accuracy, depth)?; + ap_display_prop!(self, f, altitude, depth)?; + ap_display_prop!(self, f, latitude, depth)?; + ap_display_prop!(self, f, longitude, depth)?; + ap_display_prop!(self, f, radius, depth)?; + ap_display_prop!(self, f, units, depth) + } +} + +pub struct Profile { + _super: AbstractObject, + pub describes: Option>, +} +ap_extends!(Profile, AbstractObject); + +impl> ParseApub for Profile { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.profile)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + describes: ph.parse_prop_box(&prop_ids.describes), + }) + } +} + +impl DebugApub for Profile { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop_box!(self, f, describes, depth) + } +} + +pub struct Tombstone { + _super: AbstractObject, + pub former_type: Option, + pub deleted: Option, +} +ap_extends!(Tombstone, AbstractObject); + +impl> ParseApub for Tombstone { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.tombstone)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + let prop_ids = &ids.apub.property; + + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + former_type: ph.parse_prop(&prop_ids.former_type), + deleted: ph.parse_prop(&prop_ids.deleted), + }) + } +} + +impl DebugApub for Tombstone { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + ap_display_prop!(self, f, former_type, depth)?; + ap_display_prop!(self, f, deleted, depth) + } +} + +pub struct Image { + _super: AbstractObject, + // Mastodon extensions + pub focal_point: Option<[f32; 2]>, + pub blurhash: Option, +} +ap_extends!(Image, AbstractObject); + +impl> ParseApub for Image { + fn parse_apub(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + matches_type(obj, &ids.apub.object.image)?; + unsafe { Self::_parse_apub_unchecked(obj, vocab, ids) } + } + + unsafe fn _parse_apub_unchecked(obj: &RawObject, vocab: &V, ids: &Ids) -> Option { + let ph = PropHelper::new(obj, vocab, ids)?; + + let focal_point: Vec = ph.parse_prop_vec(&ids.toot.props.focal_point); + AbstractObject::_parse_apub_unchecked(obj, vocab, ids).map(|s| Self { + _super: s, + focal_point: (focal_point.len() >= 2).then(|| [focal_point[0], focal_point[1]]), + blurhash: ph.parse_prop(&ids.toot.props.blurhash), + }) + } +} + +impl DebugApub for Image { + fn debug_apub_members(&self, f: &mut fmt::Formatter, depth: usize) -> fmt::Result { + self._super.debug_apub_members(f, depth)?; + if let Some(focal_point) = self.focal_point { + writeln!( + f, + "{:>3$}: [{:.3}, {:.3}]", + "focal_point", + focal_point[0], + focal_point[1], + depth * 4 + )?; + } + ap_display_prop!(self, f, blurhash, depth) + } +} + +ap_empty_child_impl!(Article, AbstractObject, apub, object, article); +ap_empty_child_impl!(Document, AbstractObject, apub, object, document); +ap_empty_child_impl!(Audio, AbstractObject, apub, object, audio); +ap_empty_child_impl!(Video, AbstractObject, apub, object, video); +ap_empty_child_impl!(Note, AbstractObject, apub, object, note); +ap_empty_child_impl!(Page, AbstractObject, apub, object, page); +ap_empty_child_impl!(Event, AbstractObject, apub, object, event); +ap_empty_child_impl!(Emoji, AbstractObject, toot, class, emoji); diff --git a/src/ap/vocab.rs b/src/ap/vocab.rs index d66bc35..fa0595b 100644 --- a/src/ap/vocab.rs +++ b/src/ap/vocab.rs @@ -143,6 +143,7 @@ pub struct ApubActivityIds { pub mov: Id, // move pub offer: Id, pub read: Id, + pub remove: Id, pub reject: Id, pub tentative_accept: Id, pub tentative_reject: Id, @@ -176,6 +177,7 @@ impl ApubActivityIds { mov => iri!("https://www.w3.org/ns/activitystreams#Move"), offer => iri!("https://www.w3.org/ns/activitystreams#Offer"), read => iri!("https://www.w3.org/ns/activitystreams#Read"), + remove => iri!("https://www.w3.org/ns/activitystreams#Remove"), reject => iri!("https://www.w3.org/ns/activitystreams#Reject"), tentative_accept => iri!("https://www.w3.org/ns/activitystreams#TentativeAccept"), tentative_reject => iri!("https://www.w3.org/ns/activitystreams#TentativeReject"), @@ -194,6 +196,8 @@ pub struct ApubLinkIds { pub is_followed_by: Id, pub is_contact: Id, pub is_member: Id, + // Mastodon extension + pub hashtag: Id, } impl ApubLinkIds { @@ -207,6 +211,7 @@ impl ApubLinkIds { is_followed_by => iri!("https://www.w3.org/ns/activitystreams#IsFollowedBy"), is_contact => iri!("https://www.w3.org/ns/activitystreams#IsContact"), is_member => iri!("https://www.w3.org/ns/activitystreams#IsMember"), + hashtag => iri!("https://www.w3.org/ns/activitystreams#Hashtag"), } } } @@ -255,9 +260,7 @@ pub struct ApubPropertyIds { pub url: Id, pub altitude: Id, pub content: Id, - pub content_map: Id, pub name: Id, - pub name_map: Id, pub downstream_duplicates: Id, pub duration: Id, pub end_time: Id, @@ -274,7 +277,6 @@ pub struct ApubPropertyIds { pub start_index: Id, pub start_time: Id, pub summary: Id, - pub summary_map: Id, pub total_items: Id, pub units: Id, pub updated: Id, @@ -357,9 +359,7 @@ impl ApubPropertyIds { url => iri!("https://www.w3.org/ns/activitystreams#url"), altitude => iri!("https://www.w3.org/ns/activitystreams#altitude"), content => iri!("https://www.w3.org/ns/activitystreams#content"), - content_map => iri!("https://www.w3.org/ns/activitystreams#contentMap"), name => iri!("https://www.w3.org/ns/activitystreams#name"), - name_map => iri!("https://www.w3.org/ns/activitystreams#nameMap"), downstream_duplicates => iri!("https://www.w3.org/ns/activitystreams#downstreamDuplicates"), duration => iri!("https://www.w3.org/ns/activitystreams#duration"), end_time => iri!("https://www.w3.org/ns/activitystreams#endTime"), @@ -376,7 +376,6 @@ impl ApubPropertyIds { start_index => iri!("https://www.w3.org/ns/activitystreams#startIndex"), start_time => iri!("https://www.w3.org/ns/activitystreams#startTime"), summary => iri!("https://www.w3.org/ns/activitystreams#summary"), - summary_map => iri!("https://www.w3.org/ns/activitystreams#summaryMap"), total_items => iri!("https://www.w3.org/ns/activitystreams#totalItems"), units => iri!("https://www.w3.org/ns/activitystreams#units"), updated => iri!("https://www.w3.org/ns/activitystreams#updated"), @@ -391,7 +390,7 @@ impl ApubPropertyIds { endpoints => iri!("https://www.w3.org/ns/activitystreams#endpoints"), following => iri!("https://www.w3.org/ns/activitystreams#following"), followers => iri!("https://www.w3.org/ns/activitystreams#followers"), - inbox => iri!("https://www.w3.org/ns/activitystreams#inbox"), + inbox => iri!("http://www.w3.org/ns/ldp#inbox"), liked => iri!("https://www.w3.org/ns/activitystreams#liked"), shares => iri!("https://www.w3.org/ns/activitystreams#shares"), likes => iri!("https://www.w3.org/ns/activitystreams#likes"), diff --git a/src/job/inbox.rs b/src/job/inbox.rs index 3bd103a..ba0bacb 100644 --- a/src/job/inbox.rs +++ b/src/job/inbox.rs @@ -1,7 +1,9 @@ use crate::ap::processor::process_document; use async_trait::async_trait; -use serde::{Deserialize, Serialize}; +use mime::Mime; +use serde::{de, de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; +use std::str::FromStr; use super::Job; use crate::core::*; @@ -10,18 +12,23 @@ use crate::state::AppState; #[derive(Serialize, Deserialize)] pub struct InboxWorker { raw: String, + #[serde( + serialize_with = "serialize_mime", + deserialize_with = "deserialize_mime" + )] + mime: Mime, } impl InboxWorker { - pub fn new(raw: String) -> InboxWorker { - InboxWorker { raw } + pub fn new(raw: String, mime: Mime) -> InboxWorker { + InboxWorker { raw, mime } } } #[async_trait] impl Job for InboxWorker { async fn perform(&self, state: &AppState) -> Result<()> { - process_document(state, &self.raw).await + process_document(state, &self.raw, &self.mime).await } fn name(&self) -> &'static str { @@ -34,3 +41,26 @@ impl fmt::Debug for InboxWorker { write!(f, "ActivityPub ingress: {}", &self.raw) } } + +fn serialize_mime(val: &Mime, ser: S) -> StdResult { + let string = format!("{val}"); + ser.serialize_str(&string) +} + +struct MimeVisitor; + +impl<'de> Visitor<'de> for MimeVisitor { + type Value = Mime; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a valid MIME media type") + } + + fn visit_str(self, val: &str) -> StdResult { + Mime::from_str(val).map_err(|e| E::custom(e.to_string())) + } +} + +fn deserialize_mime<'de, D: Deserializer<'de>>(de: D) -> StdResult { + de.deserialize_str(MimeVisitor) +} diff --git a/src/route/inbox.rs b/src/route/inbox.rs index b80341f..1af36c6 100644 --- a/src/route/inbox.rs +++ b/src/route/inbox.rs @@ -1,5 +1,7 @@ use actix_web::http::header::{ACCEPT, CONTENT_TYPE}; use actix_web::{post, web, HttpRequest, HttpResponse}; +use mime::Mime; +use std::str::FromStr; use crate::core::*; use crate::job::inbox::InboxWorker; @@ -17,13 +19,25 @@ async fn post_inbox(body: String, request: HttpRequest, state: AppState) -> Resu .get(CONTENT_TYPE) .ok_or(Error::BadRequest)? .to_str()?; - if CONTENT_TYPES.iter().all(|&typ| typ != content_type) { + let content_type = Mime::from_str(content_type).map_err(|_| Error::BadRequest)?; + + let is_apub = if content_type.type_() == "application" { + match content_type.subtype().as_str() { + "activity" => content_type.suffix().map(|s| s.as_str()) == Some("json"), + "ld" => content_type.suffix().map(|s| s.as_str()) == Some("json"), + "json" => true, + _ => false, + } + } else { + false + }; + if !is_apub { return Ok(HttpResponse::UnsupportedMediaType() .append_header((ACCEPT, "application/activity+json, application/ld+json")) .finish()); } - state.sched.schedule(InboxWorker::new(body)); + state.sched.schedule(InboxWorker::new(body, content_type)); Ok(HttpResponse::Accepted().finish()) }