Big refactoring of the Inbox (#443)

* Big refactoring of the Inbox

We now have a type that routes an activity through the registered handlers
until one of them matches.

Each Actor/Activity/Object combination is represented by an implementation of AsObject

These combinations are then registered on the Inbox type, which will try to deserialize
the incoming activity in the requested types.

Advantages:
- nicer syntax: the final API is clearer and more idiomatic
- more generic: only two traits (`AsActor` and `AsObject`) instead of one for each kind of activity
- it is easier to see which activities we handle and which one we don't

* Small fixes

- Avoid panics
- Don't search for AP ID infinitely
- Code style issues

* Fix tests

* Introduce a new trait: FromId

It should be implemented for any AP object.

It allows to look for an object in database using its AP ID, or to dereference it if it was not present in database

Also moves the inbox code to plume-models to test it (and write a basic test for each activity type we handle)

* Use if let instead of match

* Don't require PlumeRocket::intl for tests

* Return early and remove a forgotten dbg!

* Add more tests to try to understand where the issues come from

* Also add a test for comment federation

* Don't check creation_date is the same for blogs

* Make user and blog federation more tolerant to errors/missing fields

* Make clippy happy

* Use the correct Accept header when dereferencing

* Fix follow approval with Mastodon

* Add spaces to characters that should not be in usernames

And validate blog names too

* Smarter dereferencing: only do it once for each actor/object

* Forgot some files

* Cargo fmt

* Delete plume_test

* Delete plume_tests

* Update get_id docs + Remove useless : Sized

* Appease cargo fmt

* Remove dbg! + Use as_ref instead of clone when possible + Use and_then instead of map when possible

* Remove .po~

* send unfollow to local instance

* read cover from update activity

* Make sure "cc" and "to" are never empty

and fix a typo in a constant name

* Cargo fmt
This commit is contained in:
Baptiste Gelez
2019-04-17 18:31:47 +01:00
committed by GitHub
parent c19c094e0c
commit 12efe721cc
49 changed files with 2883 additions and 1521 deletions
+1
View File
@@ -15,6 +15,7 @@ itertools = "0.8.0"
lazy_static = "*"
openssl = "0.10.15"
rocket = "0.4.0"
rocket_i18n = "0.4.0"
reqwest = "0.9"
scheduled-thread-pool = "0.2.0"
serde = "1.0"
+175 -124
View File
@@ -7,10 +7,6 @@ use openssl::{
rsa::Rsa,
sign::{Signer, Verifier},
};
use reqwest::{
header::{HeaderValue, ACCEPT},
Client,
};
use serde_json;
use url::Url;
use webfinger::*;
@@ -18,8 +14,7 @@ use webfinger::*;
use instance::*;
use medias::Media;
use plume_common::activity_pub::{
ap_accept_header,
inbox::{Deletable, WithInbox},
inbox::{AsActor, FromId},
sign, ActivityStream, ApSignature, Id, IntoId, PublicKey, Source,
};
use posts::Post;
@@ -27,7 +22,7 @@ use safe_string::SafeString;
use schema::blogs;
use search::Searcher;
use users::User;
use {Connection, Error, Result, CONFIG};
use {Connection, Error, PlumeRocket, Result};
pub type CustomGroup = CustomObject<ApSignature, Group>;
@@ -135,121 +130,27 @@ impl Blog {
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<Blog> {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<Blog> {
let from_db = blogs::table
.filter(blogs::fqn.eq(fqn))
.limit(1)
.load::<Blog>(conn)?
.load::<Blog>(&*c.conn)?
.into_iter()
.next();
if let Some(from_db) = from_db {
Ok(from_db)
} else {
Blog::fetch_from_webfinger(conn, fqn)
Blog::fetch_from_webfinger(c, fqn)
}
}
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<Blog> {
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
resolve(acct.to_owned(), true)?
.links
.into_iter()
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
.ok_or(Error::Webfinger)
.and_then(|l| Blog::fetch_from_url(conn, &l.href?))
}
fn fetch_from_url(conn: &Connection, url: &str) -> Result<Blog> {
let mut res = Client::new()
.get(url)
.header(
ACCEPT,
HeaderValue::from_str(
&ap_accept_header()
.into_iter()
.collect::<Vec<_>>()
.join(", "),
)?,
)
.send()?;
let text = &res.text()?;
let ap_sign: ApSignature = serde_json::from_str(text)?;
let mut json: CustomGroup = serde_json::from_str(text)?;
json.custom_props = ap_sign; // without this workaround, publicKey is not correctly deserialized
Blog::from_activity(conn, &json, Url::parse(url)?.host_str()?)
}
fn from_activity(conn: &Connection, acct: &CustomGroup, inst: &str) -> Result<Blog> {
let instance = Instance::find_by_domain(conn, inst).or_else(|_| {
Instance::insert(
conn,
NewInstance {
public_domain: inst.to_owned(),
name: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
let icon_id = acct
.object
.object_props
.icon_image()
.ok()
.and_then(|icon| {
let owner: String = icon.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
conn,
icon.object_props.url_string().ok()?,
&User::from_url(conn, &owner).ok()?,
)
.ok()
})
.map(|m| m.id);
let banner_id = acct
.object
.object_props
.image_image()
.ok()
.and_then(|banner| {
let owner: String = banner.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
conn,
banner.object_props.url_string().ok()?,
&User::from_url(conn, &owner).ok()?,
)
.ok()
})
.map(|m| m.id);
Blog::insert(
conn,
NewBlog {
actor_id: acct.object.ap_actor_props.preferred_username_string()?,
title: acct.object.object_props.name_string()?,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
summary: acct.object.object_props.summary_string()?,
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
banner_id,
icon_id,
summary_html: SafeString::new(&acct.object.object_props.summary_string()?),
},
)
.and_then(|l| Blog::from_id(c, &l.href?, None).map_err(|(_, e)| e))
}
pub fn to_activity(&self, conn: &Connection) -> Result<CustomGroup> {
@@ -368,18 +269,6 @@ impl Blog {
})
}
pub fn from_url(conn: &Connection, url: &str) -> Result<Blog> {
Blog::find_by_ap_url(conn, url).or_else(|_| {
// The requested blog was not in the DB
// We try to fetch it if it is remote
if Url::parse(url)?.host_str()? != CONFIG.base_url.as_str() {
Blog::fetch_from_url(conn, url)
} else {
Err(Error::NotFound)
}
})
}
pub fn icon_url(&self, conn: &Connection) -> String {
self.icon_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url(conn)).ok())
@@ -394,7 +283,7 @@ impl Blog {
pub fn delete(&self, conn: &Connection, searcher: &Searcher) -> Result<()> {
for post in Post::get_for_blog(conn, &self)? {
post.delete(&(conn, searcher))?;
post.delete(conn, searcher)?;
}
diesel::delete(self)
.execute(conn)
@@ -409,7 +298,106 @@ impl IntoId for Blog {
}
}
impl WithInbox for Blog {
impl FromId<PlumeRocket> for Blog {
type Error = Error;
type Object = CustomGroup;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, acct: CustomGroup) -> Result<Self> {
let url = Url::parse(&acct.object.object_props.id_string()?)?;
let inst = url.host_str()?;
let instance = Instance::find_by_domain(&c.conn, inst).or_else(|_| {
Instance::insert(
&c.conn,
NewInstance {
public_domain: inst.to_owned(),
name: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
let icon_id = acct
.object
.object_props
.icon_image()
.ok()
.and_then(|icon| {
let owner: String = icon.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
&c.conn,
icon.object_props.url_string().ok()?,
&User::from_id(c, &owner, None).ok()?,
)
.ok()
})
.map(|m| m.id);
let banner_id = acct
.object
.object_props
.image_image()
.ok()
.and_then(|banner| {
let owner: String = banner.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
&c.conn,
banner.object_props.url_string().ok()?,
&User::from_id(c, &owner, None).ok()?,
)
.ok()
})
.map(|m| m.id);
let name = acct.object.ap_actor_props.preferred_username_string()?;
if name.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
return Err(Error::InvalidValue);
}
Blog::insert(
&c.conn,
NewBlog {
actor_id: name.clone(),
title: acct.object.object_props.name_string().unwrap_or(name),
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
summary: acct
.object
.ap_object_props
.source_object::<Source>()
.map(|s| s.content)
.unwrap_or_default(),
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
banner_id,
icon_id,
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
},
)
}
}
impl AsActor<&PlumeRocket> for Blog {
fn get_inbox_url(&self) -> String {
self.inbox_url.clone()
}
@@ -419,7 +407,7 @@ impl WithInbox for Blog {
}
fn is_local(&self) -> bool {
self.instance_id == 0
self.instance_id == 1 // TODO: this is not always true
}
}
@@ -471,8 +459,9 @@ pub(crate) mod tests {
use blog_authors::*;
use diesel::Connection;
use instance::tests as instance_tests;
use medias::NewMedia;
use search::tests::get_searcher;
use tests::db;
use tests::{db, rockets};
use users::tests as usersTests;
use Connection as Conn;
@@ -687,7 +676,8 @@ pub(crate) mod tests {
#[test]
fn find_local() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
fill_database(conn);
@@ -703,7 +693,7 @@ pub(crate) mod tests {
)
.unwrap();
assert_eq!(Blog::find_by_fqn(conn, "SomeName").unwrap().id, blog.id);
assert_eq!(Blog::find_by_fqn(&r, "SomeName").unwrap().id, blog.id);
Ok(())
});
@@ -816,4 +806,65 @@ pub(crate) mod tests {
Ok(())
});
}
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, mut blogs) = fill_database(conn);
blogs[0].icon_id = Some(
Media::insert(
conn,
NewMedia {
file_path: "aaa.png".into(),
alt_text: String::new(),
is_remote: false,
remote_url: None,
sensitive: false,
content_warning: None,
owner_id: users[0].id,
},
)
.unwrap()
.id,
);
blogs[0].banner_id = Some(
Media::insert(
conn,
NewMedia {
file_path: "bbb.png".into(),
alt_text: String::new(),
is_remote: false,
remote_url: None,
sensitive: false,
content_warning: None,
owner_id: users[0].id,
},
)
.unwrap()
.id,
);
let _: Blog = blogs[0].save_changes(conn).unwrap();
let ap_repr = blogs[0].to_activity(conn).unwrap();
blogs[0].delete(conn, &*r.searcher).unwrap();
let blog = Blog::from_activity(&r, ap_repr).unwrap();
assert_eq!(blog.actor_id, blogs[0].actor_id);
assert_eq!(blog.title, blogs[0].title);
assert_eq!(blog.summary, blogs[0].summary);
assert_eq!(blog.outbox_url, blogs[0].outbox_url);
assert_eq!(blog.inbox_url, blogs[0].inbox_url);
assert_eq!(blog.instance_id, blogs[0].instance_id);
assert_eq!(blog.ap_url, blogs[0].ap_url);
assert_eq!(blog.public_key, blogs[0].public_key);
assert_eq!(blog.fqn, blogs[0].fqn);
assert_eq!(blog.summary_html, blogs[0].summary_html);
assert_eq!(blog.icon_url(conn), blogs[0].icon_url(conn));
assert_eq!(blog.banner_url(conn), blogs[0].banner_url(conn));
Ok(())
});
}
}
+151 -75
View File
@@ -15,15 +15,15 @@ use medias::Media;
use mentions::Mention;
use notifications::*;
use plume_common::activity_pub::{
inbox::{Deletable, FromActivity, Notify},
Id, IntoId, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Id, IntoId, PUBLIC_VISIBILITY,
};
use plume_common::utils;
use posts::Post;
use safe_string::SafeString;
use schema::comments;
use users::User;
use {Connection, Error, Result};
use {Connection, Error, PlumeRocket, Result};
#[derive(Queryable, Identifiable, Clone, AsChangeset)]
pub struct Comment {
@@ -103,18 +103,17 @@ impl Comment {
.unwrap_or(false)
}
pub fn to_activity<'b>(&self, conn: &'b Connection) -> Result<Note> {
let author = User::get(conn, self.author_id)?;
pub fn to_activity(&self, c: &PlumeRocket) -> Result<Note> {
let author = User::get(&c.conn, self.author_id)?;
let (html, mentions, _hashtags) = utils::md_to_html(
self.content.get().as_ref(),
&Instance::get_local(conn)?.public_domain,
&Instance::get_local(&c.conn)?.public_domain,
true,
Some(Media::get_media_processor(conn, vec![&author])),
Some(Media::get_media_processor(&c.conn, vec![&author])),
);
let mut note = Note::default();
let to = vec![Id::new(PUBLIC_VISIBILTY.to_string())];
let to = vec![Id::new(PUBLIC_VISIBILITY.to_string())];
note.object_props
.set_id_string(self.ap_url.clone().unwrap_or_default())?;
@@ -123,8 +122,8 @@ impl Comment {
note.object_props.set_content_string(html)?;
note.object_props
.set_in_reply_to_link(Id::new(self.in_response_to_id.map_or_else(
|| Ok(Post::get(conn, self.post_id)?.ap_url),
|id| Ok(Comment::get(conn, id)?.ap_url.unwrap_or_default()) as Result<String>,
|| Ok(Post::get(&c.conn, self.post_id)?.ap_url),
|id| Ok(Comment::get(&c.conn, id)?.ap_url.unwrap_or_default()) as Result<String>,
)?))?;
note.object_props
.set_published_string(chrono::Utc::now().to_rfc3339())?;
@@ -134,16 +133,16 @@ impl Comment {
note.object_props.set_tag_link_vec(
mentions
.into_iter()
.filter_map(|m| Mention::build_activity(conn, &m).ok())
.filter_map(|m| Mention::build_activity(c, &m).ok())
.collect::<Vec<link::Mention>>(),
)?;
Ok(note)
}
pub fn create_activity(&self, conn: &Connection) -> Result<Create> {
let author = User::get(conn, self.author_id)?;
pub fn create_activity(&self, c: &PlumeRocket) -> Result<Create> {
let author = User::get(&c.conn, self.author_id)?;
let note = self.to_activity(conn)?;
let note = self.to_activity(c)?;
let mut act = Create::default();
act.create_props.set_actor_link(author.into_id())?;
act.create_props.set_object_object(note.clone())?;
@@ -151,15 +150,53 @@ impl Comment {
.set_id_string(format!("{}/activity", self.ap_url.clone()?,))?;
act.object_props
.set_to_link_vec(note.object_props.to_link_vec::<Id>()?)?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_author(&c.conn)?.followers_endpoint)])?;
Ok(act)
}
pub fn notify(&self, conn: &Connection) -> Result<()> {
for author in self.get_post(conn)?.get_authors(conn)? {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::COMMENT.to_string(),
object_id: self.id,
user_id: author.id,
},
)?;
}
Ok(())
}
pub fn build_delete(&self, conn: &Connection) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_author(conn)?.into_id())?;
let mut tombstone = Tombstone::default();
tombstone.object_props.set_id_string(self.ap_url.clone()?)?;
act.delete_props.set_object_object(tombstone)?;
act.object_props
.set_id_string(format!("{}#delete", self.ap_url.clone().unwrap()))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY)])?;
Ok(act)
}
}
impl FromActivity<Note, Connection> for Comment {
impl FromId<PlumeRocket> for Comment {
type Error = Error;
type Object = Note;
fn from_activity(conn: &Connection, note: Note, actor: Id) -> Result<Comment> {
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, note: Note) -> Result<Self> {
let conn = &*c.conn;
let comm = {
let previous_url = note.object_props.in_reply_to.as_ref()?.as_str()?;
let previous_comment = Comment::find_by_ap_url(conn, previous_url);
@@ -171,8 +208,8 @@ impl FromActivity<Note, Connection> for Comment {
serde_json::Value::Array(v) => v
.iter()
.filter_map(serde_json::Value::as_str)
.any(|s| s == PUBLIC_VISIBILTY),
serde_json::Value::String(s) => s == PUBLIC_VISIBILTY,
.any(|s| s == PUBLIC_VISIBILITY),
serde_json::Value::String(s) => s == PUBLIC_VISIBILITY,
_ => false,
};
@@ -191,8 +228,17 @@ impl FromActivity<Note, Connection> for Comment {
post_id: previous_comment.map(|c| c.post_id).or_else(|_| {
Ok(Post::find_by_ap_url(conn, previous_url)?.id) as Result<i32>
})?,
author_id: User::from_url(conn, actor.as_ref())?.id,
sensitive: false, // "sensitive" is not a standard property, we need to think about how to support it with the activitypub crate
author_id: User::from_id(
c,
&{
let res: String = note.object_props.attributed_to_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
sensitive: note.object_props.summary_string().is_ok(),
public_visibility,
},
)?;
@@ -243,10 +289,10 @@ impl FromActivity<Note, Connection> for Comment {
.chain(cc)
.chain(bto)
.chain(bcc)
.collect::<HashSet<_>>() //remove duplicates (don't do a query more than once)
.collect::<HashSet<_>>() // remove duplicates (don't do a query more than once)
.into_iter()
.map(|v| {
if let Ok(user) = User::from_url(conn, &v) {
if let Ok(user) = User::from_id(c, &v, None) {
vec![user]
} else {
vec![] // TODO try to fetch collection
@@ -272,20 +318,41 @@ impl FromActivity<Note, Connection> for Comment {
}
}
impl Notify<Connection> for Comment {
impl AsObject<User, Create, &PlumeRocket> for Comment {
type Error = Error;
type Output = Self;
fn notify(&self, conn: &Connection) -> Result<()> {
for author in self.get_post(conn)?.get_authors(conn)? {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::COMMENT.to_string(),
object_id: self.id,
user_id: author.id,
},
)?;
fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result<Self> {
// The actual creation takes place in the FromId impl
Ok(self)
}
}
impl AsObject<User, Delete, &PlumeRocket> for Comment {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
if self.author_id != actor.id {
return Err(Error::Unauthorized);
}
for m in Mention::list_for_comment(&c.conn, self.id)? {
for n in Notification::find_for_mention(&c.conn, &m)? {
n.delete(&c.conn)?;
}
m.delete(&c.conn)?;
}
for n in Notification::find_for_comment(&c.conn, &self)? {
n.delete(&c.conn)?;
}
diesel::update(comments::table)
.filter(comments::in_response_to_id.eq(self.id))
.set(comments::in_response_to_id.eq(self.in_response_to_id))
.execute(&*c.conn)?;
diesel::delete(&self).execute(&*c.conn)?;
Ok(())
}
}
@@ -316,49 +383,58 @@ impl CommentTree {
}
}
impl<'a> Deletable<Connection, Delete> for Comment {
type Error = Error;
#[cfg(test)]
mod tests {
use super::*;
use crate::inbox::{inbox, tests::fill_database, InboxResult};
use crate::safe_string::SafeString;
use crate::tests::rockets;
use diesel::Connection;
fn delete(&self, conn: &Connection) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_author(conn)?.into_id())?;
// creates a post, get it's Create activity, delete the post,
// "send" the Create to the inbox, and check it works
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let mut tombstone = Tombstone::default();
tombstone.object_props.set_id_string(self.ap_url.clone()?)?;
act.delete_props.set_object_object(tombstone)?;
let original_comm = Comment::insert(
conn,
NewComment {
content: SafeString::new("My comment"),
in_response_to_id: None,
post_id: posts[0].id,
author_id: users[0].id,
ap_url: None,
sensitive: true,
spoiler_text: "My CW".into(),
public_visibility: true,
},
)
.unwrap();
let act = original_comm.create_activity(&r).unwrap();
inbox(
&r,
serde_json::to_value(original_comm.build_delete(conn).unwrap()).unwrap(),
)
.unwrap();
act.object_props
.set_id_string(format!("{}#delete", self.ap_url.clone().unwrap()))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILTY)])?;
match inbox(&r, serde_json::to_value(act).unwrap()).unwrap() {
InboxResult::Commented(c) => {
// TODO: one is HTML, the other markdown: assert_eq!(c.content, original_comm.content);
assert_eq!(c.in_response_to_id, original_comm.in_response_to_id);
assert_eq!(c.post_id, original_comm.post_id);
assert_eq!(c.author_id, original_comm.author_id);
assert_eq!(c.ap_url, original_comm.ap_url);
assert_eq!(c.spoiler_text, original_comm.spoiler_text);
assert_eq!(c.public_visibility, original_comm.public_visibility);
}
_ => panic!("Unexpected result"),
};
for m in Mention::list_for_comment(conn, self.id)? {
for n in Notification::find_for_mention(conn, &m)? {
n.delete(conn)?;
}
m.delete(conn)?;
}
for n in Notification::find_for_comment(conn, &self)? {
n.delete(conn)?;
}
diesel::update(comments::table)
.filter(comments::in_response_to_id.eq(self.id))
.set(comments::in_response_to_id.eq(self.in_response_to_id))
.execute(conn)?;
diesel::delete(self).execute(conn)?;
Ok(act)
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<Delete> {
let actor = User::find_by_ap_url(conn, actor_id)?;
let comment = Comment::find_by_ap_url(conn, id)?;
if comment.author_id == actor.id {
comment.delete(conn)
} else {
Err(Error::Unauthorized)
}
Ok(())
});
}
}
+95 -72
View File
@@ -1,20 +1,16 @@
use activitypub::{
activity::{Accept, Follow as FollowAct, Undo},
actor::Person,
};
use activitypub::activity::{Accept, Follow as FollowAct, Undo};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use blogs::Blog;
use notifications::*;
use plume_common::activity_pub::{
broadcast,
inbox::{Deletable, FromActivity, Notify, WithInbox},
inbox::{AsActor, AsObject, FromId},
sign::Signer,
Id, IntoId,
Id, IntoId, PUBLIC_VISIBILITY,
};
use schema::follows;
use users::User;
use {ap_url, Connection, Error, Result, CONFIG};
use {ap_url, Connection, Error, PlumeRocket, Result, CONFIG};
#[derive(Clone, Queryable, Identifiable, Associations, AsChangeset)]
#[belongs_to(User, foreign_key = "following_id")]
@@ -65,14 +61,26 @@ impl Follow {
act.follow_props
.set_object_link::<Id>(target.clone().into_id())?;
act.object_props.set_id_string(self.ap_url.clone())?;
act.object_props.set_to_link(target.into_id())?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
act.object_props.set_to_link_vec(vec![target.into_id()])?;
act.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
Ok(act)
}
pub fn notify(&self, conn: &Connection) -> Result<Notification> {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::FOLLOW.to_string(),
object_id: self.id,
user_id: self.following_id,
},
)
}
/// from -> The one sending the follow request
/// target -> The target of the request, responding with Accept
pub fn accept_follow<A: Signer + IntoId + Clone, B: Clone + WithInbox + IntoId>(
pub fn accept_follow<A: Signer + IntoId + Clone, B: Clone + AsActor<T> + IntoId, T>(
conn: &Connection,
from: &B,
target: &A,
@@ -88,6 +96,7 @@ impl Follow {
ap_url: follow.object_props.id_string()?,
},
)?;
res.notify(conn)?;
let mut accept = Accept::default();
let accept_id = ap_url(&format!(
@@ -96,8 +105,12 @@ impl Follow {
&res.id
));
accept.object_props.set_id_string(accept_id)?;
accept.object_props.set_to_link(from.clone().into_id())?;
accept.object_props.set_cc_link_vec::<Id>(vec![])?;
accept
.object_props
.set_to_link_vec(vec![from.clone().into_id()])?;
accept
.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
accept
.accept_props
.set_actor_link::<Id>(target.clone().into_id())?;
@@ -105,61 +118,8 @@ impl Follow {
broadcast(&*target, accept, vec![from.clone()]);
Ok(res)
}
}
impl FromActivity<FollowAct, Connection> for Follow {
type Error = Error;
fn from_activity(conn: &Connection, follow: FollowAct, _actor: Id) -> Result<Follow> {
let from_id = follow
.follow_props
.actor_link::<Id>()
.map(Into::into)
.or_else(|_| {
Ok(follow
.follow_props
.actor_object::<Person>()?
.object_props
.id_string()?) as Result<String>
})?;
let from = User::from_url(conn, &from_id)?;
match User::from_url(conn, follow.follow_props.object.as_str()?) {
Ok(user) => Follow::accept_follow(conn, &from, &user, follow, from.id, user.id),
Err(_) => {
let blog = Blog::from_url(conn, follow.follow_props.object.as_str()?)?;
Follow::accept_follow(conn, &from, &blog, follow, from.id, blog.id)
}
}
}
}
impl Notify<Connection> for Follow {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::FOLLOW.to_string(),
object_id: self.id,
user_id: self.following_id,
},
)
.map(|_| ())
}
}
impl Deletable<Connection, Undo> for Follow {
type Error = Error;
fn delete(&self, conn: &Connection) -> Result<Undo> {
diesel::delete(self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) {
diesel::delete(&notif).execute(conn)?;
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut undo = Undo::default();
undo.undo_props
.set_actor_link(User::get(conn, self.follower_id)?.into_id())?;
@@ -167,14 +127,77 @@ impl Deletable<Connection, Undo> for Follow {
.set_id_string(format!("{}/undo", self.ap_url))?;
undo.undo_props
.set_object_link::<Id>(self.clone().into_id())?;
undo.object_props
.set_to_link_vec(vec![User::get(conn, self.following_id)?.into_id()])?;
undo.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
Ok(undo)
}
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<Undo> {
let follow = Follow::find_by_ap_url(conn, id)?;
let user = User::find_by_ap_url(conn, actor_id)?;
if user.id == follow.follower_id {
follow.delete(conn)
impl AsObject<User, FollowAct, &PlumeRocket> for User {
type Error = Error;
type Output = Follow;
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Follow> {
// Mastodon (at least) requires the full Follow object when accepting it,
// so we rebuilt it here
let mut follow = FollowAct::default();
follow.object_props.set_id_string(id.to_string())?;
follow
.follow_props
.set_actor_link::<Id>(actor.clone().into_id())?;
Follow::accept_follow(&c.conn, &actor, &self, follow, actor.id, self.id)
}
}
impl FromId<PlumeRocket> for Follow {
type Error = Error;
type Object = FollowAct;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Follow::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, follow: FollowAct) -> Result<Self> {
let actor = User::from_id(
c,
&{
let res: String = follow.follow_props.actor_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?;
let target = User::from_id(
c,
&{
let res: String = follow.follow_props.object_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?;
Follow::accept_follow(&c.conn, &actor, &target, follow, actor.id, target.id)
}
}
impl AsObject<User, Undo, &PlumeRocket> for Follow {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if self.follower_id == actor.id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) {
diesel::delete(&notif).execute(conn)?;
}
Ok(())
} else {
Err(Error::Unauthorized)
}
+487
View File
@@ -0,0 +1,487 @@
use activitypub::activity::*;
use serde_json;
use crate::{
comments::Comment,
follows, likes,
posts::{Post, PostUpdate},
reshares::Reshare,
users::User,
Error, PlumeRocket,
};
use plume_common::activity_pub::inbox::Inbox;
macro_rules! impl_into_inbox_result {
( $( $t:ty => $variant:ident ),+ ) => {
$(
impl From<$t> for InboxResult {
fn from(x: $t) -> InboxResult {
InboxResult::$variant(x)
}
}
)+
}
}
pub enum InboxResult {
Commented(Comment),
Followed(follows::Follow),
Liked(likes::Like),
Other,
Post(Post),
Reshared(Reshare),
}
impl From<()> for InboxResult {
fn from(_: ()) -> InboxResult {
InboxResult::Other
}
}
impl_into_inbox_result! {
Comment => Commented,
follows::Follow => Followed,
likes::Like => Liked,
Post => Post,
Reshare => Reshared
}
pub fn inbox(ctx: &PlumeRocket, act: serde_json::Value) -> Result<InboxResult, Error> {
Inbox::handle(ctx, act)
.with::<User, Announce, Post>()
.with::<User, Create, Comment>()
.with::<User, Create, Post>()
.with::<User, Delete, Comment>()
.with::<User, Delete, Post>()
.with::<User, Follow, User>()
.with::<User, Like, Post>()
.with::<User, Undo, Reshare>()
.with::<User, Undo, follows::Follow>()
.with::<User, Undo, likes::Like>()
.with::<User, Update, PostUpdate>()
.done()
}
#[cfg(test)]
pub(crate) mod tests {
use super::InboxResult;
use crate::blogs::tests::fill_database as blog_fill_db;
use crate::safe_string::SafeString;
use crate::tests::rockets;
use crate::PlumeRocket;
use diesel::Connection;
pub fn fill_database(
rockets: &PlumeRocket,
) -> (
Vec<crate::posts::Post>,
Vec<crate::users::User>,
Vec<crate::blogs::Blog>,
) {
use crate::post_authors::*;
use crate::posts::*;
let (users, blogs) = blog_fill_db(&rockets.conn);
let post = Post::insert(
&rockets.conn,
NewPost {
blog_id: blogs[0].id,
slug: "testing".to_owned(),
title: "Testing".to_owned(),
content: crate::safe_string::SafeString::new("Hello"),
published: true,
license: "WTFPL".to_owned(),
creation_date: None,
ap_url: format!("https://plu.me/~/{}/testing", blogs[0].actor_id),
subtitle: String::new(),
source: String::new(),
cover_id: None,
},
&rockets.searcher,
)
.unwrap();
PostAuthor::insert(
&rockets.conn,
NewPostAuthor {
post_id: post.id,
author_id: users[0].id,
},
)
.unwrap();
(vec![post], users, blogs)
}
#[test]
fn announce_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/announce/1",
"actor": users[0].ap_url,
"object": posts[0].ap_url,
"type": "Announce",
});
match super::inbox(&r, act).unwrap() {
super::InboxResult::Reshared(r) => {
assert_eq!(r.post_id, posts[0].id);
assert_eq!(r.user_id, users[0].id);
assert_eq!(r.ap_url, "https://plu.me/announce/1".to_owned());
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn create_comment() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Note",
"id": "https://plu.me/comment/1",
"attributedTo": users[0].ap_url,
"inReplyTo": posts[0].ap_url,
"content": "Hello.",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
match super::inbox(&r, act).unwrap() {
super::InboxResult::Commented(c) => {
assert_eq!(c.author_id, users[0].id);
assert_eq!(c.post_id, posts[0].id);
assert_eq!(c.in_response_to_id, None);
assert_eq!(c.content, SafeString::new("Hello."));
assert!(c.public_visibility);
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn create_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&r);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": [users[0].ap_url, blogs[0].ap_url],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
match super::inbox(&r, act).unwrap() {
super::InboxResult::Post(p) => {
assert!(p.is_author(conn, users[0].id).unwrap());
assert_eq!(p.source, "Hello.".to_owned());
assert_eq!(p.blog_id, blogs[0].id);
assert_eq!(p.content, SafeString::new("Hello."));
assert_eq!(p.subtitle, "Bye.".to_owned());
assert_eq!(p.title, "My Article".to_owned());
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn delete_comment() {
use crate::comments::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
Comment::insert(
conn,
NewComment {
content: SafeString::new("My comment"),
in_response_to_id: None,
post_id: posts[0].id,
author_id: users[0].id,
ap_url: Some("https://plu.me/comment/1".to_owned()),
sensitive: false,
spoiler_text: "spoiler".to_owned(),
public_visibility: true,
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[1].ap_url, // Not the author of the comment, it should fail
"object": "https://plu.me/comment/1",
"type": "Delete",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[0].ap_url,
"object": "https://plu.me/comment/1",
"type": "Delete",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn delete_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let fail_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[1].ap_url, // Not the author of the post, it should fail
"object": posts[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[0].ap_url,
"object": posts[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn follow() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/follow/1",
"actor": users[0].ap_url,
"object": users[1].ap_url,
"type": "Follow",
});
match super::inbox(&r, act).unwrap() {
InboxResult::Followed(f) => {
assert_eq!(f.follower_id, users[0].id);
assert_eq!(f.following_id, users[1].id);
assert_eq!(f.ap_url, "https://plu.me/follow/1".to_owned());
}
_ => panic!("Unexpected result"),
}
Ok(())
});
}
#[test]
fn like() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/like/1",
"actor": users[1].ap_url,
"object": posts[0].ap_url,
"type": "Like",
});
match super::inbox(&r, act).unwrap() {
InboxResult::Liked(l) => {
assert_eq!(l.user_id, users[1].id);
assert_eq!(l.post_id, posts[0].id);
assert_eq!(l.ap_url, "https://plu.me/like/1".to_owned());
}
_ => panic!("Unexpected result"),
}
Ok(())
});
}
#[test]
fn undo_reshare() {
use crate::reshares::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let announce = Reshare::insert(
conn,
NewReshare {
post_id: posts[0].id,
user_id: users[1].id,
ap_url: "https://plu.me/announce/1".to_owned(),
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[0].ap_url,
"object": announce.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[1].ap_url,
"object": announce.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn undo_follow() {
use crate::follows::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&r);
let follow = Follow::insert(
conn,
NewFollow {
follower_id: users[0].id,
following_id: users[1].id,
ap_url: "https://plu.me/follow/1".to_owned(),
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[2].ap_url,
"object": follow.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[0].ap_url,
"object": follow.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn undo_like() {
use crate::likes::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let like = Like::insert(
conn,
NewLike {
post_id: posts[0].id,
user_id: users[1].id,
ap_url: "https://plu.me/like/1".to_owned(),
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[0].ap_url,
"object": like.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[1].ap_url,
"object": like.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn update_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/update/1",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": posts[0].ap_url,
"name": "Mia Artikolo",
"summary": "Jes, mi parolas esperanton nun",
"content": "<b>Saluton</b>, mi skribas testojn",
"source": {
"mediaType": "text/markdown",
"content": "**Saluton**, mi skribas testojn"
},
},
"type": "Update",
});
super::inbox(&r, act).unwrap();
Ok(())
});
}
}
+45 -10
View File
@@ -20,6 +20,7 @@ extern crate plume_api;
extern crate plume_common;
extern crate reqwest;
extern crate rocket;
extern crate rocket_i18n;
extern crate scheduled_thread_pool;
extern crate serde;
#[macro_use]
@@ -36,6 +37,8 @@ extern crate whatlang;
#[macro_use]
extern crate diesel_migrations;
use plume_common::activity_pub::inbox::InboxError;
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("Either feature \"sqlite\" or \"postgres\" must be enabled for this crate.");
#[cfg(all(feature = "sqlite", feature = "postgres"))]
@@ -51,6 +54,7 @@ pub type Connection = diesel::PgConnection;
#[derive(Debug)]
pub enum Error {
Db(diesel::result::Error),
Inbox(Box<InboxError<Error>>),
InvalidValue,
Io(std::io::Error),
MissingApProperty,
@@ -139,6 +143,15 @@ impl From<std::io::Error> for Error {
}
}
impl From<InboxError<Error>> for Error {
fn from(err: InboxError<Error>) -> Error {
match err {
InboxError::InvalidActor(Some(e)) | InboxError::InvalidObject(Some(e)) => e,
e => Error::Inbox(Box::new(e)),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub type ApiResult<T> = std::result::Result<T, canapi::Error>;
@@ -288,7 +301,13 @@ pub fn ap_url(url: &str) -> String {
#[cfg(test)]
#[macro_use]
mod tests {
use diesel::{dsl::sql_query, Connection, RunQueryDsl};
use db_conn;
use diesel::r2d2::ConnectionManager;
#[cfg(feature = "sqlite")]
use diesel::{dsl::sql_query, RunQueryDsl};
use scheduled_thread_pool::ScheduledThreadPool;
use search;
use std::sync::Arc;
use Connection as Conn;
use CONFIG;
@@ -309,15 +328,28 @@ mod tests {
};
}
pub fn db() -> Conn {
let conn = Conn::establish(CONFIG.database_url.as_str())
.expect("Couldn't connect to the database");
embedded_migrations::run(&conn).expect("Couldn't run migrations");
#[cfg(feature = "sqlite")]
sql_query("PRAGMA foreign_keys = on;")
.execute(&conn)
.expect("PRAGMA foreign_keys fail");
conn
pub fn db<'a>() -> db_conn::DbConn {
db_conn::DbConn((*DB_POOL).get().unwrap())
}
lazy_static! {
static ref DB_POOL: db_conn::DbPool = {
let pool = db_conn::DbPool::builder()
.connection_customizer(Box::new(db_conn::PragmaForeignKey))
.build(ConnectionManager::<Conn>::new(CONFIG.database_url.as_str()))
.unwrap();
embedded_migrations::run(&*pool.get().unwrap()).expect("Migrations error");
pool
};
}
pub fn rockets() -> super::PlumeRocket {
super::PlumeRocket {
conn: db_conn::DbConn((*DB_POOL).get().unwrap()),
searcher: Arc::new(search::tests::get_searcher()),
worker: Arc::new(ScheduledThreadPool::new(2)),
user: None,
}
}
}
@@ -331,11 +363,13 @@ pub mod comments;
pub mod db_conn;
pub mod follows;
pub mod headers;
pub mod inbox;
pub mod instance;
pub mod likes;
pub mod medias;
pub mod mentions;
pub mod notifications;
pub mod plume_rocket;
pub mod post_authors;
pub mod posts;
pub mod reshares;
@@ -344,3 +378,4 @@ pub mod schema;
pub mod search;
pub mod tags;
pub mod users;
pub use plume_rocket::PlumeRocket;
+87 -48
View File
@@ -4,13 +4,13 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use notifications::*;
use plume_common::activity_pub::{
inbox::{Deletable, FromActivity, Notify},
Id, IntoId, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Id, IntoId, PUBLIC_VISIBILITY,
};
use posts::Post;
use schema::likes;
use users::User;
use {Connection, Error, Result};
use {Connection, Error, PlumeRocket, Result};
#[derive(Clone, Queryable, Identifiable)]
pub struct Like {
@@ -42,37 +42,16 @@ impl Like {
act.like_props
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props.set_cc_link_vec(vec![Id::new(
User::get(conn, self.user_id)?.followers_endpoint,
)])?;
act.object_props.set_id_string(self.ap_url.clone())?;
Ok(act)
}
}
impl FromActivity<activity::Like, Connection> for Like {
type Error = Error;
fn from_activity(conn: &Connection, like: activity::Like, _actor: Id) -> Result<Like> {
let liker = User::from_url(conn, like.like_props.actor.as_str()?)?;
let post = Post::find_by_ap_url(conn, like.like_props.object.as_str()?)?;
let res = Like::insert(
conn,
NewLike {
post_id: post.id,
user_id: liker.id,
ap_url: like.object_props.id_string()?,
},
)?;
res.notify(conn)?;
Ok(res)
}
}
impl Notify<Connection> for Like {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
pub fn notify(&self, conn: &Connection) -> Result<()> {
let post = Post::get(conn, self.post_id)?;
for author in post.get_authors(conn)? {
Notification::insert(
@@ -86,19 +65,8 @@ impl Notify<Connection> for Like {
}
Ok(())
}
}
impl Deletable<Connection, activity::Undo> for Like {
type Error = Error;
fn delete(&self, conn: &Connection) -> Result<activity::Undo> {
diesel::delete(self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
pub fn build_undo(&self, conn: &Connection) -> Result<activity::Undo> {
let mut act = activity::Undo::default();
act.undo_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
@@ -106,17 +74,87 @@ impl Deletable<Connection, activity::Undo> for Like {
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props.set_cc_link_vec(vec![Id::new(
User::get(conn, self.user_id)?.followers_endpoint,
)])?;
Ok(act)
}
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<activity::Undo> {
let like = Like::find_by_ap_url(conn, id)?;
let user = User::find_by_ap_url(conn, actor_id)?;
if user.id == like.user_id {
like.delete(conn)
impl AsObject<User, activity::Like, &PlumeRocket> for Post {
type Error = Error;
type Output = Like;
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Like> {
let res = Like::insert(
&c.conn,
NewLike {
post_id: self.id,
user_id: actor.id,
ap_url: id.to_string(),
},
)?;
res.notify(&c.conn)?;
Ok(res)
}
}
impl FromId<PlumeRocket> for Like {
type Error = Error;
type Object = activity::Like;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Like::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, act: activity::Like) -> Result<Self> {
let res = Like::insert(
&c.conn,
NewLike {
post_id: Post::from_id(
c,
&{
let res: String = act.like_props.object_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(
c,
&{
let res: String = act.like_props.actor_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
ap_url: act.object_props.id_string()?,
},
)?;
res.notify(&c.conn)?;
Ok(res)
}
}
impl AsObject<User, activity::Undo, &PlumeRocket> for Like {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
Ok(())
} else {
Err(Error::Unauthorized)
}
@@ -125,6 +163,7 @@ impl Deletable<Connection, activity::Undo> for Like {
impl NewLike {
pub fn new(p: &Post, u: &User) -> Self {
// TODO: this URL is not valid
let ap_url = format!("{}/like/{}", u.ap_url, p.ap_url);
NewLike {
post_id: p.id,
+12 -6
View File
@@ -5,13 +5,16 @@ use guid_create::GUID;
use reqwest;
use std::{fs, path::Path};
use plume_common::{activity_pub::Id, utils::MediaProcessor};
use plume_common::{
activity_pub::{inbox::FromId, Id},
utils::MediaProcessor,
};
use instance::Instance;
use safe_string::SafeString;
use schema::medias;
use users::User;
use {ap_url, Connection, Error, Result};
use {ap_url, Connection, Error, PlumeRocket, Result};
#[derive(Clone, Identifiable, Queryable)]
pub struct Media {
@@ -183,7 +186,8 @@ impl Media {
}
// TODO: merge with save_remote?
pub fn from_activity(conn: &Connection, image: &Image) -> Result<Media> {
pub fn from_activity(c: &PlumeRocket, image: &Image) -> Result<Media> {
let conn = &*c.conn;
let remote_url = image.object_props.url_string().ok()?;
let ext = remote_url
.rsplit('.')
@@ -210,8 +214,8 @@ impl Media {
remote_url: None,
sensitive: image.object_props.summary_string().is_ok(),
content_warning: image.object_props.summary_string().ok(),
owner_id: User::from_url(
conn,
owner_id: User::from_id(
c,
image
.object_props
.attributed_to_link_vec::<Id>()
@@ -219,7 +223,9 @@ impl Media {
.into_iter()
.next()?
.as_ref(),
)?
None,
)
.map_err(|(_, e)| e)?
.id,
},
)
+3 -6
View File
@@ -3,10 +3,10 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use comments::Comment;
use notifications::*;
use plume_common::activity_pub::inbox::Notify;
use posts::Post;
use schema::mentions;
use users::User;
use PlumeRocket;
use {Connection, Error, Result};
#[derive(Clone, Queryable, Identifiable)]
@@ -55,8 +55,8 @@ impl Mention {
}
}
pub fn build_activity(conn: &Connection, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(conn, ment)?;
pub fn build_activity(c: &PlumeRocket, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(c, ment)?;
let mut mention = link::Mention::default();
mention.link_props.set_href_string(user.ap_url)?;
mention.link_props.set_name_string(format!("@{}", ment))?;
@@ -126,10 +126,7 @@ impl Mention {
.map(|_| ())
.map_err(Error::from)
}
}
impl Notify<Connection> for Mention {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
let m = self.get_mentioned(conn)?;
Notification::insert(
+80
View File
@@ -0,0 +1,80 @@
pub use self::module::PlumeRocket;
#[cfg(not(test))]
mod module {
use crate::db_conn::DbConn;
use crate::search;
use crate::users;
use rocket::{
request::{self, FromRequest, Request},
Outcome, State,
};
use scheduled_thread_pool::ScheduledThreadPool;
use std::sync::Arc;
/// Common context needed by most routes and operations on models
pub struct PlumeRocket {
pub conn: DbConn,
pub intl: rocket_i18n::I18n,
pub user: Option<users::User>,
pub searcher: Arc<search::Searcher>,
pub worker: Arc<ScheduledThreadPool>,
}
impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
let conn = request.guard::<DbConn>()?;
let intl = request.guard::<rocket_i18n::I18n>()?;
let user = request.guard::<users::User>().succeeded();
let worker = request.guard::<State<Arc<ScheduledThreadPool>>>()?;
let searcher = request.guard::<State<Arc<search::Searcher>>>()?;
Outcome::Success(PlumeRocket {
conn,
intl,
user,
worker: worker.clone(),
searcher: searcher.clone(),
})
}
}
}
#[cfg(test)]
mod module {
use crate::db_conn::DbConn;
use crate::search;
use crate::users;
use rocket::{
request::{self, FromRequest, Request},
Outcome, State,
};
use scheduled_thread_pool::ScheduledThreadPool;
use std::sync::Arc;
/// Common context needed by most routes and operations on models
pub struct PlumeRocket {
pub conn: DbConn,
pub user: Option<users::User>,
pub searcher: Arc<search::Searcher>,
pub worker: Arc<ScheduledThreadPool>,
}
impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
let conn = request.guard::<DbConn>()?;
let user = request.guard::<users::User>().succeeded();
let worker = request.guard::<State<Arc<ScheduledThreadPool>>>()?;
let searcher = request.guard::<State<Arc<search::Searcher>>>()?;
Outcome::Success(PlumeRocket {
conn,
user,
worker: worker.clone(),
searcher: searcher.clone(),
})
}
}
}
+389 -230
View File
@@ -8,7 +8,6 @@ use canapi::{Error as ApiError, Provider};
use chrono::{NaiveDateTime, TimeZone, Utc};
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use heck::{CamelCase, KebabCase};
use scheduled_thread_pool::ScheduledThreadPool as Worker;
use serde_json;
use std::collections::HashSet;
@@ -20,8 +19,8 @@ use plume_api::posts::PostEndpoint;
use plume_common::{
activity_pub::{
broadcast,
inbox::{Deletable, FromActivity},
Hashtag, Id, IntoId, Licensed, Source, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Hashtag, Id, IntoId, Licensed, Source, PUBLIC_VISIBILITY,
},
utils::md_to_html,
};
@@ -31,7 +30,7 @@ use schema::posts;
use search::Searcher;
use tags::*;
use users::User;
use {ap_url, ApiResult, Connection, Error, Result, CONFIG};
use {ap_url, ApiResult, Connection, Error, PlumeRocket, Result, CONFIG};
pub type LicensedArticle = CustomObject<Licensed, Article>;
@@ -68,17 +67,17 @@ pub struct NewPost {
pub cover_id: Option<i32>,
}
impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for Post {
impl Provider<PlumeRocket> for Post {
type Data = PostEndpoint;
fn get(
(conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
id: i32,
) -> ApiResult<PostEndpoint> {
fn get(rockets: &PlumeRocket, id: i32) -> ApiResult<PostEndpoint> {
let conn = &*rockets.conn;
if let Ok(post) = Post::get(conn, id) {
if !post.published
&& !user_id
.map(|u| post.is_author(conn, u).unwrap_or(false))
&& !rockets
.user
.as_ref()
.and_then(|u| post.is_author(conn, u.id).ok())
.unwrap_or(false)
{
return Err(ApiError::Authorization(
@@ -115,10 +114,8 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
}
}
fn list(
(conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
filter: PostEndpoint,
) -> Vec<PostEndpoint> {
fn list(rockets: &PlumeRocket, filter: PostEndpoint) -> Vec<PostEndpoint> {
let conn = &*rockets.conn;
let mut query = posts::table.into_boxed();
if let Some(title) = filter.title {
query = query.filter(posts::title.eq(title));
@@ -131,13 +128,15 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
}
query
.get_results::<Post>(*conn)
.get_results::<Post>(conn)
.map(|ps| {
ps.into_iter()
.filter(|p| {
p.published
|| user_id
.map(|u| p.is_author(conn, u).unwrap_or(false))
|| rockets
.user
.as_ref()
.and_then(|u| p.is_author(conn, u.id).ok())
.unwrap_or(false)
})
.map(|p| PostEndpoint {
@@ -166,31 +165,33 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
}
fn update(
(_conn, _worker, _search, _user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
_rockets: &PlumeRocket,
_id: i32,
_new_data: PostEndpoint,
) -> ApiResult<PostEndpoint> {
unimplemented!()
}
fn delete(
(conn, _worker, search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
id: i32,
) {
let user_id = user_id.expect("Post as Provider::delete: not authenticated");
fn delete(rockets: &PlumeRocket, id: i32) {
let conn = &*rockets.conn;
let user_id = rockets
.user
.as_ref()
.expect("Post as Provider::delete: not authenticated")
.id;
if let Ok(post) = Post::get(conn, id) {
if post.is_author(conn, user_id).unwrap_or(false) {
post.delete(&(conn, search))
post.delete(conn, &rockets.searcher)
.expect("Post as Provider::delete: delete error");
}
}
}
fn create(
(conn, worker, search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
query: PostEndpoint,
) -> ApiResult<PostEndpoint> {
if user_id.is_none() {
fn create(rockets: &PlumeRocket, query: PostEndpoint) -> ApiResult<PostEndpoint> {
let conn = &*rockets.conn;
let search = &rockets.searcher;
let worker = &rockets.worker;
if rockets.user.is_none() {
return Err(ApiError::Authorization(
"You are not authorized to create new articles.".to_string(),
));
@@ -207,11 +208,10 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
let domain = &Instance::get_local(&conn)
.map_err(|_| ApiError::NotFound("posts::update: Error getting local instance".into()))?
.public_domain;
let author = User::get(
conn,
user_id.expect("<Post as Provider>::create: no user_id error"),
)
.map_err(|_| ApiError::NotFound("Author not found".into()))?;
let author = rockets
.user
.clone()
.ok_or_else(|| ApiError::NotFound("Author not found".into()))?;
let (content, mentions, hashtags) = md_to_html(
query.source.clone().unwrap_or_default().clone().as_ref(),
@@ -298,7 +298,7 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
for m in mentions.into_iter() {
Mention::from_activity(
&*conn,
&Mention::build_activity(&*conn, &m)
&Mention::build_activity(&rockets, &m)
.map_err(|_| ApiError::NotFound("Couldn't build mentions".into()))?,
post.id,
true,
@@ -367,6 +367,7 @@ impl Post {
searcher.add_document(conn, &post)?;
Ok(post)
}
pub fn update(&self, conn: &Connection, searcher: &Searcher) -> Result<Self> {
diesel::update(self).set(self).execute(conn)?;
let post = Self::get(conn, self.id)?;
@@ -374,6 +375,15 @@ impl Post {
Ok(post)
}
pub fn delete(&self, conn: &Connection, searcher: &Searcher) -> Result<()> {
for m in Mention::list_for_post(&conn, self.id)? {
m.delete(conn)?;
}
diesel::delete(self).execute(conn)?;
searcher.delete_document(self);
Ok(())
}
pub fn list_by_tag(
conn: &Connection,
tag: String,
@@ -625,7 +635,7 @@ impl Post {
pub fn to_activity(&self, conn: &Connection) -> Result<LicensedArticle> {
let cc = self.get_receivers_urls(conn)?;
let to = vec![PUBLIC_VISIBILTY.to_string()];
let to = vec![PUBLIC_VISIBILITY.to_string()];
let mut mentions_json = Mention::list_for_post(conn, self.id)?
.into_iter()
@@ -726,77 +736,6 @@ impl Post {
Ok(act)
}
pub fn handle_update(
conn: &Connection,
updated: &LicensedArticle,
searcher: &Searcher,
) -> Result<()> {
let id = updated.object.object_props.id_string()?;
let mut post = Post::find_by_ap_url(conn, &id)?;
if let Ok(title) = updated.object.object_props.name_string() {
post.slug = title.to_kebab_case();
post.title = title;
}
if let Ok(content) = updated.object.object_props.content_string() {
post.content = SafeString::new(&content);
}
if let Ok(subtitle) = updated.object.object_props.summary_string() {
post.subtitle = subtitle;
}
if let Ok(ap_url) = updated.object.object_props.url_string() {
post.ap_url = ap_url;
}
if let Ok(source) = updated.object.ap_object_props.source_object::<Source>() {
post.source = source.content;
}
if let Ok(license) = updated.custom_props.license_string() {
post.license = license;
}
let mut txt_hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(mention_tags)) =
updated.object.object_props.tag.clone()
{
let mut mentions = vec![];
let mut tags = vec![];
let mut hashtags = vec![];
for tag in mention_tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| mentions.push(m))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
if txt_hashtags.remove(&tag_name) {
hashtags.push(t);
} else {
tags.push(t);
}
Ok(())
})
.ok();
}
post.update_mentions(conn, mentions)?;
post.update_tags(conn, tags)?;
post.update_hashtags(conn, hashtags)?;
}
post.update(conn, searcher)?;
Ok(())
}
pub fn update_mentions(&self, conn: &Connection, mentions: Vec<link::Mention>) -> Result<()> {
let mentions = mentions
.into_iter()
@@ -925,112 +864,8 @@ impl Post {
.and_then(|i| Media::get(conn, i).ok())
.and_then(|c| c.url(conn).ok())
}
}
impl<'a> FromActivity<LicensedArticle, (&'a Connection, &'a Searcher)> for Post {
type Error = Error;
fn from_activity(
(conn, searcher): &(&'a Connection, &'a Searcher),
article: LicensedArticle,
_actor: Id,
) -> Result<Post> {
let license = article.custom_props.license_string().unwrap_or_default();
let article = article.object;
if let Ok(post) =
Post::find_by_ap_url(conn, &article.object_props.id_string().unwrap_or_default())
{
Ok(post)
} else {
let (blog, authors) = article
.object_props
.attributed_to_link_vec::<Id>()?
.into_iter()
.fold((None, vec![]), |(blog, mut authors), link| {
let url: String = link.into();
match User::from_url(conn, &url) {
Ok(u) => {
authors.push(u);
(blog, authors)
}
Err(_) => (blog.or_else(|| Blog::from_url(conn, &url).ok()), authors),
}
});
let cover = article
.object_props
.icon_object::<Image>()
.ok()
.and_then(|img| Media::from_activity(conn, &img).ok().map(|m| m.id));
let title = article.object_props.name_string()?;
let post = Post::insert(
conn,
NewPost {
blog_id: blog?.id,
slug: title.to_kebab_case(),
title,
content: SafeString::new(&article.object_props.content_string()?),
published: true,
license,
// FIXME: This is wrong: with this logic, we may use the display URL as the AP ID. We need two different fields
ap_url: article
.object_props
.url_string()
.or_else(|_| article.object_props.id_string())?,
creation_date: Some(article.object_props.published_utctime()?.naive_utc()),
subtitle: article.object_props.summary_string()?,
source: article.ap_object_props.source_object::<Source>()?.content,
cover_id: cover,
},
searcher,
)?;
for author in authors {
PostAuthor::insert(
conn,
NewPostAuthor {
post_id: post.id,
author_id: author.id,
},
)?;
}
// save mentions and tags
let mut hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(tags)) = article.object_props.tag.clone() {
for tag in tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| Mention::from_activity(conn, &m, post.id, true, true))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
Ok(Tag::from_activity(
conn,
&t,
post.id,
hashtags.remove(&tag_name),
))
})
.ok();
}
}
Ok(post)
}
}
}
impl<'a> Deletable<(&'a Connection, &'a Searcher), Delete> for Post {
type Error = Error;
fn delete(&self, (conn, searcher): &(&Connection, &Searcher)) -> Result<Delete> {
pub fn build_delete(&self, conn: &Connection) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_authors(conn)?[0].clone().into_id())?;
@@ -1042,37 +877,361 @@ impl<'a> Deletable<(&'a Connection, &'a Searcher), Delete> for Post {
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILTY)])?;
for m in Mention::list_for_post(&conn, self.id)? {
m.delete(conn)?;
}
diesel::delete(self).execute(*conn)?;
searcher.delete_document(self);
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY)])?;
Ok(act)
}
}
fn delete_id(
id: &str,
actor_id: &str,
(conn, searcher): &(&Connection, &Searcher),
) -> Result<Delete> {
let actor = User::find_by_ap_url(conn, actor_id)?;
let post = Post::find_by_ap_url(conn, id)?;
let can_delete = post
.get_authors(conn)?
impl FromId<PlumeRocket> for Post {
type Error = Error;
type Object = LicensedArticle;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, article: LicensedArticle) -> Result<Self> {
let conn = &*c.conn;
let searcher = &c.searcher;
let license = article.custom_props.license_string().unwrap_or_default();
let article = article.object;
let (blog, authors) = article
.object_props
.attributed_to_link_vec::<Id>()?
.into_iter()
.fold((None, vec![]), |(blog, mut authors), link| {
let url: String = link.into();
match User::from_id(&c, &url, None) {
Ok(u) => {
authors.push(u);
(blog, authors)
}
Err(_) => (blog.or_else(|| Blog::from_id(&c, &url, None).ok()), authors),
}
});
let cover = article
.object_props
.icon_object::<Image>()
.ok()
.and_then(|img| Media::from_activity(&c, &img).ok().map(|m| m.id));
let title = article.object_props.name_string()?;
let post = Post::insert(
conn,
NewPost {
blog_id: blog?.id,
slug: title.to_kebab_case(),
title,
content: SafeString::new(&article.object_props.content_string()?),
published: true,
license,
// FIXME: This is wrong: with this logic, we may use the display URL as the AP ID. We need two different fields
ap_url: article
.object_props
.url_string()
.or_else(|_| article.object_props.id_string())?,
creation_date: Some(article.object_props.published_utctime()?.naive_utc()),
subtitle: article.object_props.summary_string()?,
source: article.ap_object_props.source_object::<Source>()?.content,
cover_id: cover,
},
searcher,
)?;
for author in authors {
PostAuthor::insert(
conn,
NewPostAuthor {
post_id: post.id,
author_id: author.id,
},
)?;
}
// save mentions and tags
let mut hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(tags)) = article.object_props.tag.clone() {
for tag in tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| Mention::from_activity(conn, &m, post.id, true, true))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
Ok(Tag::from_activity(
conn,
&t,
post.id,
hashtags.remove(&tag_name),
))
})
.ok();
}
}
Ok(post)
}
}
impl AsObject<User, Create, &PlumeRocket> for Post {
type Error = Error;
type Output = Post;
fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result<Post> {
// TODO: check that _actor is actually one of the author?
Ok(self)
}
}
impl AsObject<User, Delete, &PlumeRocket> for Post {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let can_delete = self
.get_authors(&c.conn)?
.into_iter()
.any(|a| actor.id == a.id);
if can_delete {
post.delete(&(conn, searcher))
self.delete(&c.conn, &c.searcher).map(|_| ())
} else {
Err(Error::Unauthorized)
}
}
}
pub struct PostUpdate {
pub ap_url: String,
pub title: Option<String>,
pub subtitle: Option<String>,
pub content: Option<String>,
pub cover: Option<i32>,
pub source: Option<String>,
pub license: Option<String>,
pub tags: Option<serde_json::Value>,
}
impl FromId<PlumeRocket> for PostUpdate {
type Error = Error;
type Object = LicensedArticle;
fn from_db(_: &PlumeRocket, _: &str) -> Result<Self> {
// Always fail because we always want to deserialize the AP object
Err(Error::NotFound)
}
fn from_activity(c: &PlumeRocket, updated: LicensedArticle) -> Result<Self> {
Ok(PostUpdate {
ap_url: updated.object.object_props.id_string()?,
title: updated.object.object_props.name_string().ok(),
subtitle: updated.object.object_props.summary_string().ok(),
content: updated.object.object_props.content_string().ok(),
cover: updated
.object
.object_props
.icon_object::<Image>()
.ok()
.and_then(|img| Media::from_activity(&c, &img).ok().map(|m| m.id)),
source: updated
.object
.ap_object_props
.source_object::<Source>()
.ok()
.map(|x| x.content),
license: updated.custom_props.license_string().ok(),
tags: updated.object.object_props.tag.clone(),
})
}
}
impl AsObject<User, Update, &PlumeRocket> for PostUpdate {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
let searcher = &c.searcher;
let mut post = Post::from_id(c, &self.ap_url, None).map_err(|(_, e)| e)?;
if !post.is_author(conn, actor.id)? {
// TODO: maybe the author was added in the meantime
return Err(Error::Unauthorized);
}
if let Some(title) = self.title {
post.slug = title.to_kebab_case();
post.title = title;
}
if let Some(content) = self.content {
post.content = SafeString::new(&content);
}
if let Some(subtitle) = self.subtitle {
post.subtitle = subtitle;
}
post.cover_id = self.cover;
if let Some(source) = self.source {
post.source = source;
}
if let Some(license) = self.license {
post.license = license;
}
let mut txt_hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(mention_tags)) = self.tags {
let mut mentions = vec![];
let mut tags = vec![];
let mut hashtags = vec![];
for tag in mention_tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| mentions.push(m))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
if txt_hashtags.remove(&tag_name) {
hashtags.push(t);
} else {
tags.push(t);
}
Ok(())
})
.ok();
}
post.update_mentions(conn, mentions)?;
post.update_tags(conn, tags)?;
post.update_hashtags(conn, hashtags)?;
}
post.update(conn, searcher)?;
Ok(())
}
}
impl IntoId for Post {
fn into_id(self) -> Id {
Id::new(self.ap_url.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inbox::{inbox, tests::fill_database, InboxResult};
use crate::safe_string::SafeString;
use crate::tests::rockets;
use diesel::Connection;
// creates a post, get it's Create activity, delete the post,
// "send" the Create to the inbox, and check it works
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&r);
let post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "yo".into(),
title: "Yo".into(),
content: SafeString::new("Hello"),
published: true,
license: "WTFPL".to_string(),
creation_date: None,
ap_url: String::new(), // automatically updated when inserting
subtitle: "Testing".into(),
source: "Hello".into(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
PostAuthor::insert(
conn,
NewPostAuthor {
post_id: post.id,
author_id: users[0].id,
},
)
.unwrap();
let create = post.create_activity(conn).unwrap();
post.delete(conn, &r.searcher).unwrap();
match inbox(&r, serde_json::to_value(create).unwrap()).unwrap() {
InboxResult::Post(p) => {
assert!(p.is_author(conn, users[0].id).unwrap());
assert_eq!(p.source, "Hello".to_owned());
assert_eq!(p.blog_id, blogs[0].id);
assert_eq!(p.content, SafeString::new("Hello"));
assert_eq!(p.subtitle, "Testing".to_owned());
assert_eq!(p.title, "Yo".to_owned());
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn licensed_article_serde() {
let mut article = Article::default();
article.object_props.set_id_string("Yo".into()).unwrap();
let mut license = Licensed::default();
license.set_license_string("WTFPL".into()).unwrap();
let full_article = LicensedArticle::new(article, license);
let json = serde_json::to_value(full_article).unwrap();
let article_from_json: LicensedArticle = serde_json::from_value(json).unwrap();
assert_eq!(
"Yo",
&article_from_json.object.object_props.id_string().unwrap()
);
assert_eq!(
"WTFPL",
&article_from_json.custom_props.license_string().unwrap()
);
}
#[test]
fn licensed_article_deserialization() {
let json = json!({
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
});
let article: LicensedArticle = serde_json::from_value(json).unwrap();
assert_eq!(
"https://plu.me/~/Blog/my-article",
&article.object.object_props.id_string().unwrap()
);
}
}
+86 -49
View File
@@ -4,13 +4,13 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use notifications::*;
use plume_common::activity_pub::{
inbox::{Deletable, FromActivity, Notify},
Id, IntoId, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Id, IntoId, PUBLIC_VISIBILITY,
};
use posts::Post;
use schema::reshares;
use users::User;
use {Connection, Error, Result};
use {Connection, Error, PlumeRocket, Result};
#[derive(Clone, Queryable, Identifiable)]
pub struct Reshare {
@@ -69,37 +69,14 @@ impl Reshare {
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
act.object_props.set_id_string(self.ap_url.clone())?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
Ok(act)
}
}
impl FromActivity<Announce, Connection> for Reshare {
type Error = Error;
fn from_activity(conn: &Connection, announce: Announce, _actor: Id) -> Result<Reshare> {
let user = User::from_url(conn, announce.announce_props.actor_link::<Id>()?.as_ref())?;
let post =
Post::find_by_ap_url(conn, announce.announce_props.object_link::<Id>()?.as_ref())?;
let reshare = Reshare::insert(
conn,
NewReshare {
post_id: post.id,
user_id: user.id,
ap_url: announce.object_props.id_string().unwrap_or_default(),
},
)?;
reshare.notify(conn)?;
Ok(reshare)
}
}
impl Notify<Connection> for Reshare {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
pub fn notify(&self, conn: &Connection) -> Result<()> {
let post = self.get_post(conn)?;
for author in post.get_authors(conn)? {
Notification::insert(
@@ -113,19 +90,8 @@ impl Notify<Connection> for Reshare {
}
Ok(())
}
}
impl Deletable<Connection, Undo> for Reshare {
type Error = Error;
fn delete(&self, conn: &Connection) -> Result<Undo> {
diesel::delete(self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut act = Undo::default();
act.undo_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
@@ -133,17 +99,88 @@ impl Deletable<Connection, Undo> for Reshare {
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
Ok(act)
}
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<Undo> {
let reshare = Reshare::find_by_ap_url(conn, id)?;
let actor = User::find_by_ap_url(conn, actor_id)?;
if actor.id == reshare.user_id {
reshare.delete(conn)
impl AsObject<User, Announce, &PlumeRocket> for Post {
type Error = Error;
type Output = Reshare;
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Reshare> {
let conn = &*c.conn;
let reshare = Reshare::insert(
conn,
NewReshare {
post_id: self.id,
user_id: actor.id,
ap_url: id.to_string(),
},
)?;
reshare.notify(conn)?;
Ok(reshare)
}
}
impl FromId<PlumeRocket> for Reshare {
type Error = Error;
type Object = Announce;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Reshare::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, act: Announce) -> Result<Self> {
let res = Reshare::insert(
&c.conn,
NewReshare {
post_id: Post::from_id(
c,
&{
let res: String = act.announce_props.object_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(
c,
&{
let res: String = act.announce_props.actor_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
ap_url: act.object_props.id_string()?,
},
)?;
res.notify(&c.conn)?;
Ok(res)
}
}
impl AsObject<User, Undo, &PlumeRocket> for Reshare {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(&conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
Ok(())
} else {
Err(Error::Unauthorized)
}
+1 -2
View File
@@ -12,7 +12,6 @@ pub(crate) mod tests {
use std::str::FromStr;
use blogs::tests::fill_database;
use plume_common::activity_pub::inbox::Deletable;
use plume_common::utils::random_hex;
use post_authors::*;
use posts::{NewPost, Post};
@@ -171,7 +170,7 @@ pub(crate) mod tests {
.search_document(conn, Query::from_str(&title).unwrap(), (0, 1))
.is_empty());
post.delete(&(conn, &searcher)).unwrap();
post.delete(conn, &searcher).unwrap();
searcher.commit();
assert!(searcher
.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))
+141 -119
View File
@@ -12,7 +12,7 @@ use openssl::{
};
use plume_common::activity_pub::{
ap_accept_header,
inbox::{Deletable, WithInbox},
inbox::{AsActor, FromId},
sign::{gen_keypair, Signer},
ActivityStream, ApSignature, Id, IntoId, PublicKey,
};
@@ -43,7 +43,7 @@ use posts::Post;
use safe_string::SafeString;
use schema::users;
use search::Searcher;
use {ap_url, Connection, Error, Result, CONFIG};
use {ap_url, Connection, Error, PlumeRocket, Result};
pub type CustomPerson = CustomObject<ApSignature, Person>;
@@ -168,7 +168,7 @@ impl User {
.unwrap_or(&0)
> &0;
if !has_other_authors {
Post::get(conn, post_id)?.delete(&(conn, searcher))?;
Post::get(conn, post_id)?.delete(conn, searcher)?;
}
}
@@ -230,27 +230,27 @@ impl User {
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<User> {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<User> {
let from_db = users::table
.filter(users::fqn.eq(fqn))
.limit(1)
.load::<User>(conn)?
.load::<User>(&*c.conn)?
.into_iter()
.next();
if let Some(from_db) = from_db {
Ok(from_db)
} else {
User::fetch_from_webfinger(conn, fqn)
User::fetch_from_webfinger(c, fqn)
}
}
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<User> {
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<User> {
let link = resolve(acct.to_owned(), true)?
.links
.into_iter()
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
.ok_or(Error::Webfinger)?;
User::fetch_from_url(conn, link.href.as_ref()?)
User::from_id(c, link.href.as_ref()?, None).map_err(|(_, e)| e)
}
fn fetch(url: &str) -> Result<CustomPerson> {
@@ -274,97 +274,8 @@ impl User {
Ok(json)
}
pub fn fetch_from_url(conn: &Connection, url: &str) -> Result<User> {
User::fetch(url)
.and_then(|json| User::from_activity(conn, &json, Url::parse(url)?.host_str()?))
}
fn from_activity(conn: &Connection, acct: &CustomPerson, inst: &str) -> Result<User> {
let instance = Instance::find_by_domain(conn, inst).or_else(|_| {
Instance::insert(
conn,
NewInstance {
name: inst.to_owned(),
public_domain: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
if acct
.object
.ap_actor_props
.preferred_username_string()?
.contains(&['<', '>', '&', '@', '\'', '"'][..])
{
return Err(Error::InvalidValue);
}
let user = User::insert(
conn,
NewUser {
username: acct
.object
.ap_actor_props
.preferred_username_string()
.unwrap(),
display_name: acct.object.object_props.name_string()?,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
is_admin: false,
summary: acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
email: None,
hashed_password: None,
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
shared_inbox_url: acct
.object
.ap_actor_props
.endpoints_endpoint()
.and_then(|e| e.shared_inbox_string())
.ok(),
followers_endpoint: acct.object.ap_actor_props.followers_string()?,
avatar_id: None,
},
)?;
let avatar = Media::save_remote(
conn,
acct.object
.object_props
.icon_image()?
.object_props
.url_string()?,
&user,
);
if let Ok(avatar) = avatar {
user.set_avatar(conn, avatar.id)?;
}
Ok(user)
pub fn fetch_from_url(c: &PlumeRocket, url: &str) -> Result<User> {
User::fetch(url).and_then(|json| User::from_activity(c, json))
}
pub fn refetch(&self, conn: &Connection) -> Result<()> {
@@ -688,10 +599,11 @@ impl User {
.ap_actor_props
.set_followers_string(self.followers_endpoint.clone())?;
let mut endpoints = Endpoint::default();
endpoints
.set_shared_inbox_string(ap_url(&format!("{}/inbox/", CONFIG.base_url.as_str())))?;
actor.ap_actor_props.set_endpoints_endpoint(endpoints)?;
if let Some(shared_inbox_url) = self.shared_inbox_url.clone() {
let mut endpoints = Endpoint::default();
endpoints.set_shared_inbox_string(shared_inbox_url)?;
actor.ap_actor_props.set_endpoints_endpoint(endpoints)?;
}
let mut public_key = PublicKey::default();
public_key.set_id_string(format!("{}#main-key", self.ap_url))?;
@@ -752,18 +664,6 @@ impl User {
})
}
pub fn from_url(conn: &Connection, url: &str) -> Result<User> {
User::find_by_ap_url(conn, url).or_else(|_| {
// The requested user was not in the DB
// We try to fetch it if it is remote
if Url::parse(&url)?.host_str()? != CONFIG.base_url.as_str() {
User::fetch_from_url(conn, url)
} else {
Err(Error::NotFound)
}
})
}
pub fn set_avatar(&self, conn: &Connection, id: i32) -> Result<()> {
diesel::update(self)
.set(users::avatar_id.eq(id))
@@ -807,7 +707,100 @@ impl IntoId for User {
impl Eq for User {}
impl WithInbox for User {
impl FromId<PlumeRocket> for User {
type Error = Error;
type Object = CustomPerson;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, acct: CustomPerson) -> Result<Self> {
let url = Url::parse(&acct.object.object_props.id_string()?)?;
let inst = url.host_str()?;
let instance = Instance::find_by_domain(&c.conn, inst).or_else(|_| {
Instance::insert(
&c.conn,
NewInstance {
name: inst.to_owned(),
public_domain: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
let username = acct.object.ap_actor_props.preferred_username_string()?;
if username.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
return Err(Error::InvalidValue);
}
let user = User::insert(
&c.conn,
NewUser {
display_name: acct
.object
.object_props
.name_string()
.unwrap_or_else(|_| username.clone()),
username,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
is_admin: false,
summary: acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
email: None,
hashed_password: None,
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
shared_inbox_url: acct
.object
.ap_actor_props
.endpoints_endpoint()
.and_then(|e| e.shared_inbox_string())
.ok(),
followers_endpoint: acct.object.ap_actor_props.followers_string()?,
avatar_id: None,
},
)?;
if let Ok(icon) = acct.object.object_props.icon_image() {
if let Ok(url) = icon.object_props.url_string() {
let avatar = Media::save_remote(&c.conn, url, &user);
if let Ok(avatar) = avatar {
user.set_avatar(&c.conn, avatar.id)?;
}
}
}
Ok(user)
}
}
impl AsActor<&PlumeRocket> for User {
fn get_inbox_url(&self) -> String {
self.inbox_url.clone()
}
@@ -893,7 +886,7 @@ pub(crate) mod tests {
use diesel::Connection;
use instance::{tests as instance_tests, Instance};
use search::tests::get_searcher;
use tests::db;
use tests::{db, rockets};
use Connection as Conn;
pub(crate) fn fill_database(conn: &Conn) -> Vec<User> {
@@ -933,7 +926,8 @@ pub(crate) mod tests {
#[test]
fn find_by() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
fill_database(conn);
let test_user = NewUser::new_local(
@@ -955,7 +949,7 @@ pub(crate) mod tests {
);
assert_eq!(
test_user.id,
User::find_by_fqn(conn, &test_user.fqn).unwrap().id
User::find_by_fqn(&r, &test_user.fqn).unwrap().id
);
assert_eq!(
test_user.id,
@@ -1089,4 +1083,32 @@ pub(crate) mod tests {
Ok(())
});
}
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let users = fill_database(conn);
let ap_repr = users[0].to_activity(conn).unwrap();
users[0].delete(conn, &*r.searcher).unwrap();
let user = User::from_activity(&r, ap_repr).unwrap();
assert_eq!(user.username, users[0].username);
assert_eq!(user.display_name, users[0].display_name);
assert_eq!(user.outbox_url, users[0].outbox_url);
assert_eq!(user.inbox_url, users[0].inbox_url);
assert_eq!(user.instance_id, users[0].instance_id);
assert_eq!(user.ap_url, users[0].ap_url);
assert_eq!(user.public_key, users[0].public_key);
assert_eq!(user.shared_inbox_url, users[0].shared_inbox_url);
assert_eq!(user.followers_endpoint, users[0].followers_endpoint);
assert_eq!(user.avatar_url(conn), users[0].avatar_url(conn));
assert_eq!(user.fqn, users[0].fqn);
assert_eq!(user.summary_html, users[0].summary_html);
Ok(())
});
}
}