From 12efe721cc4616b6ece512de16c8c396a62f8614 Mon Sep 17 00:00:00 2001 From: Baptiste Gelez Date: Wed, 17 Apr 2019 18:31:47 +0100 Subject: [PATCH] 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 --- Cargo.lock | 4 +- Cargo.toml | 1 - plume-common/Cargo.toml | 2 - plume-common/src/activity_pub/inbox.rs | 616 ++++++++++++++++++++++-- plume-common/src/activity_pub/mod.rs | 13 +- plume-common/src/lib.rs | 5 +- plume-models/Cargo.toml | 1 + plume-models/src/blogs.rs | 299 +++++++----- plume-models/src/comments.rs | 226 ++++++--- plume-models/src/follows.rs | 167 ++++--- plume-models/src/inbox.rs | 487 +++++++++++++++++++ plume-models/src/lib.rs | 55 ++- plume-models/src/likes.rs | 135 ++++-- plume-models/src/medias.rs | 18 +- plume-models/src/mentions.rs | 9 +- plume-models/src/plume_rocket.rs | 80 ++++ plume-models/src/posts.rs | 619 ++++++++++++++++--------- plume-models/src/reshares.rs | 135 ++++-- plume-models/src/search/mod.rs | 3 +- plume-models/src/users.rs | 260 ++++++----- plume_test | Bin 0 -> 12288 bytes plume_tests | Bin 0 -> 12288 bytes po/plume/ar.po | 30 +- po/plume/de.po | 25 +- po/plume/en.po | 17 +- po/plume/es.po | 17 +- po/plume/fr.po | 31 +- po/plume/gl.po | 25 +- po/plume/it.po | 25 +- po/plume/ja.po | 25 +- po/plume/nb.po | 27 +- po/plume/pl.po | 29 +- po/plume/plume.pot | 45 +- po/plume/pt.po | 30 +- po/plume/ru.po | 25 +- src/api/mod.rs | 16 +- src/api/posts.rs | 51 +- src/inbox.rs | 213 +++------ src/main.rs | 4 +- src/routes/blogs.rs | 103 ++-- src/routes/comments.rs | 64 +-- src/routes/instance.rs | 56 +-- src/routes/likes.rs | 29 +- src/routes/mod.rs | 33 +- src/routes/posts.rs | 112 ++--- src/routes/reshares.rs | 29 +- src/routes/session.rs | 12 +- src/routes/user.rs | 178 +++---- src/routes/well_known.rs | 18 +- 49 files changed, 2883 insertions(+), 1521 deletions(-) create mode 100644 plume-models/src/inbox.rs create mode 100644 plume-models/src/plume_rocket.rs create mode 100644 plume_test create mode 100644 plume_tests diff --git a/Cargo.lock b/Cargo.lock index 8b00716b..61bad0d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1788,7 +1788,6 @@ dependencies = [ "ctrlc 3.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "diesel 1.4.1 (registry+https://github.com/rust-lang/crates.io-index)", "dotenv 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", - "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", "gettext 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "gettext-macros 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "gettext-utils 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1847,8 +1846,6 @@ dependencies = [ "array_tool 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)", "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", - "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", - "failure_derive 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", "heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "hex 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "hyper 0.12.25 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1893,6 +1890,7 @@ dependencies = [ "plume-common 0.2.0", "reqwest 0.9.11 (registry+https://github.com/rust-lang/crates.io-index)", "rocket 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rocket_i18n 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "scheduled-thread-pool 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.89 (registry+https://github.com/rust-lang/crates.io-index)", "serde_derive 1.0.89 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 316a1cb3..e81d5107 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ atom_syndication = "0.6" canapi = "0.2" colored = "1.7" dotenv = "0.13" -failure = "0.1" gettext = "0.3" gettext-macros = "0.4" gettext-utils = "0.1" diff --git a/plume-common/Cargo.toml b/plume-common/Cargo.toml index 3e399db4..0950e4bb 100644 --- a/plume-common/Cargo.toml +++ b/plume-common/Cargo.toml @@ -9,8 +9,6 @@ activitystreams-derive = "0.1.0" activitystreams-traits = "0.1.0" array_tool = "1.0" base64 = "0.10" -failure = "0.1" -failure_derive = "0.1" heck = "0.3.0" hex = "0.3" hyper = "0.12.20" diff --git a/plume-common/src/activity_pub/inbox.rs b/plume-common/src/activity_pub/inbox.rs index 9b3155e7..9dcc8ddd 100644 --- a/plume-common/src/activity_pub/inbox.rs +++ b/plume-common/src/activity_pub/inbox.rs @@ -1,48 +1,606 @@ -use activitypub::{activity::Create, Error as ApError, Object}; +use reqwest::header::{HeaderValue, ACCEPT}; +use std::fmt::Debug; -use activity_pub::Id; +/// Represents an ActivityPub inbox. +/// +/// It routes an incoming Activity through the registered handlers. +/// +/// # Example +/// +/// ```rust +/// # extern crate activitypub; +/// # use activitypub::{actor::Person, activity::{Announce, Create}, object::Note}; +/// # use plume_common::activity_pub::inbox::*; +/// # struct User; +/// # impl FromId<()> for User { +/// # type Error = (); +/// # type Object = Person; +/// # +/// # fn from_db(_: &(), _id: &str) -> Result { +/// # Ok(User) +/// # } +/// # +/// # fn from_activity(_: &(), obj: Person) -> Result { +/// # Ok(User) +/// # } +/// # } +/// # impl AsActor<&()> for User { +/// # fn get_inbox_url(&self) -> String { +/// # String::new() +/// # } +/// # fn is_local(&self) -> bool { false } +/// # } +/// # struct Message; +/// # impl FromId<()> for Message { +/// # type Error = (); +/// # type Object = Note; +/// # +/// # fn from_db(_: &(), _id: &str) -> Result { +/// # Ok(Message) +/// # } +/// # +/// # fn from_activity(_: &(), obj: Note) -> Result { +/// # Ok(Message) +/// # } +/// # } +/// # impl AsObject for Message { +/// # type Error = (); +/// # type Output = (); +/// # +/// # fn activity(self, _: &(), _actor: User, _id: &str) -> Result<(), ()> { +/// # Ok(()) +/// # } +/// # } +/// # impl AsObject for Message { +/// # type Error = (); +/// # type Output = (); +/// # +/// # fn activity(self, _: &(), _actor: User, _id: &str) -> Result<(), ()> { +/// # Ok(()) +/// # } +/// # } +/// # +/// # let mut act = Create::default(); +/// # act.object_props.set_id_string(String::from("https://test.ap/activity")).unwrap(); +/// # let mut person = Person::default(); +/// # person.object_props.set_id_string(String::from("https://test.ap/actor")).unwrap(); +/// # act.create_props.set_actor_object(person).unwrap(); +/// # act.create_props.set_object_object(Note::default()).unwrap(); +/// # let activity_json = serde_json::to_value(act).unwrap(); +/// # +/// # let conn = (); +/// # +/// let result: Result<(), ()> = Inbox::handle(&conn, activity_json) +/// .with::() +/// .with::() +/// .done(); +/// ``` +pub enum Inbox<'a, C, E, R> +where + E: From> + Debug, +{ + /// The activity has not been handled yet + /// + /// # Structure + /// + /// - the context to be passed to each handler. + /// - the activity + /// - the reason it has not been handled yet + NotHandled(&'a C, serde_json::Value, InboxError), -#[derive(Fail, Debug)] -pub enum InboxError { - #[fail(display = "The `type` property is required, but was not present")] - NoType, - #[fail(display = "Invalid activity type")] - InvalidType, - #[fail(display = "Couldn't undo activity")] - CantUndo, + /// A matching handler have been found but failed + /// + /// The wrapped value is the error returned by the handler + Failed(E), + + /// The activity was successfully handled + /// + /// The wrapped value is the value returned by the handler + Handled(R), } -pub trait FromActivity: Sized { - type Error: From; +/// Possible reasons of inbox failure +#[derive(Debug)] +pub enum InboxError { + /// None of the registered handlers matched + NoMatch, - fn from_activity(conn: &C, obj: T, actor: Id) -> Result; + /// No ID was provided for the incoming activity, or it was not a string + InvalidID, - fn try_from_activity(conn: &C, act: Create) -> Result { - Self::from_activity( - conn, - act.create_props.object_object()?, - act.create_props.actor_link::()?, - ) + /// The activity type matched for at least one handler, but then the actor was + /// not of the expected type + InvalidActor(Option), + + /// Activity and Actor types matched, but not the Object + InvalidObject(Option), + + /// Error while dereferencing the object + DerefError, +} + +impl From> for () { + fn from(_: InboxError) {} +} + +/* + Type arguments: + - C: Context + - E: Error + - R: Result +*/ +impl<'a, C, E, R> Inbox<'a, C, E, R> +where + E: From> + Debug, +{ + /// Creates a new `Inbox` to handle an incoming activity. + /// + /// # Parameters + /// + /// - `ctx`: the context to pass to each handler + /// - `json`: the JSON representation of the incoming activity + pub fn handle(ctx: &'a C, json: serde_json::Value) -> Inbox<'a, C, E, R> { + Inbox::NotHandled(ctx, json, InboxError::NoMatch) + } + + /// Registers an handler on this Inbox. + pub fn with(self) -> Inbox<'a, C, E, R> + where + A: AsActor<&'a C> + FromId, + V: activitypub::Activity, + M: AsObject + FromId, + M::Output: Into, + { + if let Inbox::NotHandled(ctx, mut act, e) = self { + if serde_json::from_value::(act.clone()).is_ok() { + let act_clone = act.clone(); + let act_id = match act_clone["id"].as_str() { + Some(x) => x, + None => return Inbox::NotHandled(ctx, act, InboxError::InvalidID), + }; + + // Get the actor ID + let actor_id = match get_id(act["actor"].clone()) { + Some(x) => x, + None => return Inbox::NotHandled(ctx, act, InboxError::InvalidActor(None)), + }; + // Transform this actor to a model (see FromId for details about the from_id function) + let actor = match A::from_id( + ctx, + &actor_id, + serde_json::from_value(act["actor"].clone()).ok(), + ) { + Ok(a) => a, + // If the actor was not found, go to the next handler + Err((json, e)) => { + if let Some(json) = json { + act["actor"] = json; + } + return Inbox::NotHandled(ctx, act, InboxError::InvalidActor(Some(e))); + } + }; + + // Same logic for "object" + let obj_id = match get_id(act["object"].clone()) { + Some(x) => x, + None => return Inbox::NotHandled(ctx, act, InboxError::InvalidObject(None)), + }; + let obj = match M::from_id( + ctx, + &obj_id, + serde_json::from_value(act["object"].clone()).ok(), + ) { + Ok(o) => o, + Err((json, e)) => { + if let Some(json) = json { + act["object"] = json; + } + return Inbox::NotHandled(ctx, act, InboxError::InvalidObject(Some(e))); + } + }; + + // Handle the activity + match obj.activity(ctx, actor, &act_id) { + Ok(res) => Inbox::Handled(res.into()), + Err(e) => Inbox::Failed(e), + } + } else { + // If the Activity type is not matching the expected one for + // this handler, try with the next one. + Inbox::NotHandled(ctx, act, e) + } + } else { + self + } + } + + /// Transforms the inbox in a `Result` + pub fn done(self) -> Result { + match self { + Inbox::Handled(res) => Ok(res), + Inbox::NotHandled(_, _, err) => Err(E::from(err)), + Inbox::Failed(err) => Err(err), + } } } -pub trait Notify { - type Error; - - fn notify(&self, conn: &C) -> Result<(), Self::Error>; +/// Get the ActivityPub ID of a JSON value. +/// +/// If the value is a string, its value is returned. +/// If it is an object, and that its `id` field is a string, we return it. +/// +/// Otherwise, `None` is returned. +fn get_id(json: serde_json::Value) -> Option { + match json { + serde_json::Value::String(s) => Some(s), + serde_json::Value::Object(map) => map.get("id")?.as_str().map(ToString::to_string), + _ => None, + } } -pub trait Deletable { - type Error; +/// A trait for ActivityPub objects that can be retrieved or constructed from ID. +/// +/// The two functions to implement are `from_activity` to create (and save) a new object +/// of this type from its AP representation, and `from_db` to try to find it in the database +/// using its ID. +/// +/// When dealing with the "object" field of incoming activities, `Inbox` will try to see if it is +/// a full object, and if so, save it with `from_activity`. If it is only an ID, it will try to find +/// it in the database with `from_db`, and otherwise dereference (fetch) the full object and parse it +/// with `from_activity`. +pub trait FromId: Sized { + /// The type representing a failure + type Error: From> + Debug; - fn delete(&self, conn: &C) -> Result; - fn delete_id(id: &str, actor_id: &str, conn: &C) -> Result; + /// The ActivityPub object type representing Self + type Object: activitypub::Object; + + /// Tries to get an instance of `Self` from an ActivityPub ID. + /// + /// # Parameters + /// + /// - `ctx`: a context to get this instance (= a database in which to search) + /// - `id`: the ActivityPub ID of the object to find + /// - `object`: optional object that will be used if the object was not found in the database + /// If absent, the ID will be dereferenced. + fn from_id( + ctx: &C, + id: &str, + object: Option, + ) -> Result, Self::Error)> { + match Self::from_db(ctx, id) { + Ok(x) => Ok(x), + _ => match object { + Some(o) => Self::from_activity(ctx, o).map_err(|e| (None, e)), + None => Self::from_activity(ctx, Self::deref(id)?).map_err(|e| (None, e)), + }, + } + } + + /// Dereferences an ID + fn deref(id: &str) -> Result, Self::Error)> { + reqwest::Client::new() + .get(id) + .header( + ACCEPT, + HeaderValue::from_str( + &super::ap_accept_header() + .into_iter() + .collect::>() + .join(", "), + ) + .map_err(|_| (None, InboxError::DerefError.into()))?, + ) + .send() + .map_err(|_| (None, InboxError::DerefError)) + .and_then(|mut r| { + let json: serde_json::Value = r + .json() + .map_err(|_| (None, InboxError::InvalidObject(None)))?; + serde_json::from_value(json.clone()) + .map_err(|_| (Some(json), InboxError::InvalidObject(None))) + }) + .map_err(|(json, e)| (json, e.into())) + } + + /// Builds a `Self` from its ActivityPub representation + fn from_activity(ctx: &C, activity: Self::Object) -> Result; + + /// Tries to find a `Self` with a given ID (`id`), using `ctx` (a database) + fn from_db(ctx: &C, id: &str) -> Result; } -pub trait WithInbox { +/// Should be implemented by anything representing an ActivityPub actor. +/// +/// # Type arguments +/// +/// - `C`: the context to be passed to this activity handler from the `Inbox` (usually a database connection) +pub trait AsActor { + /// Return the URL of this actor's inbox fn get_inbox_url(&self) -> String; - fn get_shared_inbox_url(&self) -> Option; + /// If this actor has shared inbox, its URL should be returned by this function + fn get_shared_inbox_url(&self) -> Option { + None + } + /// `true` if this actor comes from the running ActivityPub server/instance fn is_local(&self) -> bool; } + +/// Should be implemented by anything representing an ActivityPub object. +/// +/// # Type parameters +/// +/// - `A`: the actor type +/// - `V`: the ActivityPub verb/activity +/// - `O`: the ActivityPub type of the Object for this activity (usually the type corresponding to `Self`) +/// - `C`: the context needed to handle the activity (usually a database connection) +/// +/// # Example +/// +/// An implementation of AsObject that handles Note creation by an Account model, +/// representing the Note by a Message type, without any specific context. +/// +/// ```rust +/// # extern crate activitypub; +/// # use activitypub::{activity::Create, actor::Person, object::Note}; +/// # use plume_common::activity_pub::inbox::{AsActor, AsObject, FromId}; +/// # struct Account; +/// # impl FromId<()> for Account { +/// # type Error = (); +/// # type Object = Person; +/// # +/// # fn from_db(_: &(), _id: &str) -> Result { +/// # Ok(Account) +/// # } +/// # +/// # fn from_activity(_: &(), obj: Person) -> Result { +/// # Ok(Account) +/// # } +/// # } +/// # impl AsActor<()> for Account { +/// # fn get_inbox_url(&self) -> String { +/// # String::new() +/// # } +/// # fn is_local(&self) -> bool { false } +/// # } +/// #[derive(Debug)] +/// struct Message { +/// text: String, +/// } +/// +/// impl FromId<()> for Message { +/// type Error = (); +/// type Object = Note; +/// +/// fn from_db(_: &(), _id: &str) -> Result { +/// Ok(Message { text: "From DB".into() }) +/// } +/// +/// fn from_activity(_: &(), obj: Note) -> Result { +/// Ok(Message { text: obj.object_props.content_string().map_err(|_| ())? }) +/// } +/// } +/// +/// impl AsObject for Message { +/// type Error = (); +/// type Output = (); +/// +/// fn activity(self, _: (), _actor: Account, _id: &str) -> Result<(), ()> { +/// println!("New Note: {:?}", self); +/// Ok(()) +/// } +/// } +/// ``` +pub trait AsObject +where + V: activitypub::Activity, +{ + /// What kind of error is returned when something fails + type Error; + + /// What is returned by `AsObject::activity`, if anything is returned + type Output = (); + + /// Handle a specific type of activity dealing with this type of objects. + /// + /// The implementations should check that the actor is actually authorized + /// to perform this action. + /// + /// # Parameters + /// + /// - `self`: the object on which the activity acts + /// - `ctx`: the context passed to `Inbox::handle` + /// - `actor`: the actor who did this activity + /// - `id`: the ID of this activity + fn activity(self, ctx: C, actor: A, id: &str) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use activitypub::{activity::*, actor::Person, object::Note}; + + struct MyActor; + impl FromId<()> for MyActor { + type Error = (); + type Object = Person; + + fn from_db(_: &(), _id: &str) -> Result { + Ok(MyActor) + } + + fn from_activity(_: &(), _obj: Person) -> Result { + Ok(MyActor) + } + } + + impl AsActor<&()> for MyActor { + fn get_inbox_url(&self) -> String { + String::from("https://test.ap/my-actor/inbox") + } + + fn is_local(&self) -> bool { + false + } + } + + struct MyObject; + impl FromId<()> for MyObject { + type Error = (); + type Object = Note; + + fn from_db(_: &(), _id: &str) -> Result { + Ok(MyObject) + } + + fn from_activity(_: &(), _obj: Note) -> Result { + Ok(MyObject) + } + } + impl AsObject for MyObject { + type Error = (); + type Output = (); + + fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result { + println!("MyActor is creating a Note"); + Ok(()) + } + } + + impl AsObject for MyObject { + type Error = (); + type Output = (); + + fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result { + println!("MyActor is liking a Note"); + Ok(()) + } + } + + impl AsObject for MyObject { + type Error = (); + type Output = (); + + fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result { + println!("MyActor is deleting a Note"); + Ok(()) + } + } + + impl AsObject for MyObject { + type Error = (); + type Output = (); + + fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result { + println!("MyActor is announcing a Note"); + Ok(()) + } + } + + fn build_create() -> Create { + let mut act = Create::default(); + act.object_props + .set_id_string(String::from("https://test.ap/activity")) + .unwrap(); + let mut person = Person::default(); + person + .object_props + .set_id_string(String::from("https://test.ap/actor")) + .unwrap(); + act.create_props.set_actor_object(person).unwrap(); + let mut note = Note::default(); + note.object_props + .set_id_string(String::from("https://test.ap/note")) + .unwrap(); + act.create_props.set_object_object(note).unwrap(); + act + } + + #[test] + fn test_inbox_basic() { + let act = serde_json::to_value(build_create()).unwrap(); + let res: Result<(), ()> = Inbox::handle(&(), act) + .with::() + .done(); + assert!(res.is_ok()); + } + + #[test] + fn test_inbox_multi_handlers() { + let act = serde_json::to_value(build_create()).unwrap(); + let res: Result<(), ()> = Inbox::handle(&(), act) + .with::() + .with::() + .with::() + .with::() + .done(); + assert!(res.is_ok()); + } + + #[test] + fn test_inbox_failure() { + let act = serde_json::to_value(build_create()).unwrap(); + // Create is not handled by this inbox + let res: Result<(), ()> = Inbox::handle(&(), act) + .with::() + .with::() + .done(); + assert!(res.is_err()); + } + + struct FailingActor; + impl FromId<()> for FailingActor { + type Error = (); + type Object = Person; + + fn from_db(_: &(), _id: &str) -> Result { + Err(()) + } + + fn from_activity(_: &(), _obj: Person) -> Result { + Err(()) + } + } + impl AsActor<&()> for FailingActor { + fn get_inbox_url(&self) -> String { + String::from("https://test.ap/failing-actor/inbox") + } + + fn is_local(&self) -> bool { + false + } + } + + impl AsObject for MyObject { + type Error = (); + type Output = (); + + fn activity( + self, + _: &(), + _actor: FailingActor, + _id: &str, + ) -> Result { + println!("FailingActor is creating a Note"); + Ok(()) + } + } + + #[test] + fn test_inbox_actor_failure() { + let act = serde_json::to_value(build_create()).unwrap(); + + let res: Result<(), ()> = Inbox::handle(&(), act.clone()) + .with::() + .done(); + assert!(res.is_err()); + + let res: Result<(), ()> = Inbox::handle(&(), act.clone()) + .with::() + .with::() + .done(); + assert!(res.is_ok()); + } +} diff --git a/plume-common/src/activity_pub/mod.rs b/plume-common/src/activity_pub/mod.rs index 32c30f98..520f5026 100644 --- a/plume-common/src/activity_pub/mod.rs +++ b/plume-common/src/activity_pub/mod.rs @@ -16,7 +16,7 @@ pub mod request; pub mod sign; pub const CONTEXT_URL: &str = "https://www.w3.org/ns/activitystreams"; -pub const PUBLIC_VISIBILTY: &str = "https://www.w3.org/ns/activitystreams#Public"; +pub const PUBLIC_VISIBILITY: &str = "https://www.w3.org/ns/activitystreams#Public"; pub const AP_CONTENT_TYPE: &str = r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#; @@ -107,11 +107,12 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApRequest { .unwrap_or(Outcome::Forward(())) } } -pub fn broadcast( - sender: &S, - act: A, - to: Vec, -) { +pub fn broadcast(sender: &S, act: A, to: Vec) +where + S: sign::Signer, + A: Activity, + T: inbox::AsActor, +{ let boxes = to .into_iter() .filter(|u| !u.is_local()) diff --git a/plume-common/src/lib.rs b/plume-common/src/lib.rs index 30c5a356..68889bd4 100644 --- a/plume-common/src/lib.rs +++ b/plume-common/src/lib.rs @@ -1,4 +1,4 @@ -#![feature(custom_attribute)] +#![feature(custom_attribute, associated_type_defaults)] extern crate activitypub; #[macro_use] @@ -7,9 +7,6 @@ extern crate activitystreams_traits; extern crate array_tool; extern crate base64; extern crate chrono; -extern crate failure; -#[macro_use] -extern crate failure_derive; extern crate heck; extern crate hex; extern crate openssl; diff --git a/plume-models/Cargo.toml b/plume-models/Cargo.toml index 61832169..95f047d4 100644 --- a/plume-models/Cargo.toml +++ b/plume-models/Cargo.toml @@ -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" diff --git a/plume-models/src/blogs.rs b/plume-models/src/blogs.rs index e61e9d2c..0fd0e2c7 100644 --- a/plume-models/src/blogs.rs +++ b/plume-models/src/blogs.rs @@ -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; @@ -135,121 +130,27 @@ impl Blog { .map_err(Error::from) } - pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result { + pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result { let from_db = blogs::table .filter(blogs::fqn.eq(fqn)) .limit(1) - .load::(conn)? + .load::(&*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 { + fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result { 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 { - let mut res = Client::new() - .get(url) - .header( - ACCEPT, - HeaderValue::from_str( - &ap_accept_header() - .into_iter() - .collect::>() - .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 { - 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::().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::().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 { @@ -368,18 +269,6 @@ impl Blog { }) } - pub fn from_url(conn: &Connection, url: &str) -> Result { - 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 for Blog { + type Error = Error; + type Object = CustomGroup; + + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Self::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, acct: CustomGroup) -> Result { + 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::().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::().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::() + .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(()) + }); + } } diff --git a/plume-models/src/comments.rs b/plume-models/src/comments.rs index e9d5d89f..50ad9555 100644 --- a/plume-models/src/comments.rs +++ b/plume-models/src/comments.rs @@ -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 { - let author = User::get(conn, self.author_id)?; - + pub fn to_activity(&self, c: &PlumeRocket) -> Result { + 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, + || Ok(Post::get(&c.conn, self.post_id)?.ap_url), + |id| Ok(Comment::get(&c.conn, id)?.ap_url.unwrap_or_default()) as Result, )?))?; 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::>(), )?; Ok(note) } - pub fn create_activity(&self, conn: &Connection) -> Result { - let author = User::get(conn, self.author_id)?; + pub fn create_activity(&self, c: &PlumeRocket) -> Result { + 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::()?)?; - act.object_props.set_cc_link_vec::(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 { + 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 for Comment { +impl FromId for Comment { type Error = Error; + type Object = Note; - fn from_activity(conn: &Connection, note: Note, actor: Id) -> Result { + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Self::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, note: Note) -> Result { + 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 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 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 })?, - 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::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)? + .id, + sensitive: note.object_props.summary_string().is_ok(), public_visibility, }, )?; @@ -243,10 +289,10 @@ impl FromActivity for Comment { .chain(cc) .chain(bto) .chain(bcc) - .collect::>() //remove duplicates (don't do a query more than once) + .collect::>() // 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 for Comment { } } -impl Notify for Comment { +impl AsObject 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 { + // The actual creation takes place in the FromId impl + Ok(self) + } +} + +impl AsObject 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 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 { - 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 { - 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(()) + }); } } diff --git a/plume-models/src/follows.rs b/plume-models/src/follows.rs index 52c4b66f..47d1d1e7 100644 --- a/plume-models/src/follows.rs +++ b/plume-models/src/follows.rs @@ -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::(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::(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::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( + pub fn accept_follow + 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::(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::(target.clone().into_id())?; @@ -105,61 +118,8 @@ impl Follow { broadcast(&*target, accept, vec![from.clone()]); Ok(res) } -} - -impl FromActivity for Follow { - type Error = Error; - - fn from_activity(conn: &Connection, follow: FollowAct, _actor: Id) -> Result { - let from_id = follow - .follow_props - .actor_link::() - .map(Into::into) - .or_else(|_| { - Ok(follow - .follow_props - .actor_object::()? - .object_props - .id_string()?) as Result - })?; - 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 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 for Follow { - type Error = Error; - - fn delete(&self, conn: &Connection) -> Result { - diesel::delete(self).execute(conn)?; - - // delete associated notification if any - if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) { - diesel::delete(¬if).execute(conn)?; - } + pub fn build_undo(&self, conn: &Connection) -> Result { 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 for Follow { .set_id_string(format!("{}/undo", self.ap_url))?; undo.undo_props .set_object_link::(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 { - 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 for User { + type Error = Error; + type Output = Follow; + + fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result { + // 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::(actor.clone().into_id())?; + Follow::accept_follow(&c.conn, &actor, &self, follow, actor.id, self.id) + } +} + +impl FromId for Follow { + type Error = Error; + type Object = FollowAct; + + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Follow::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, follow: FollowAct) -> Result { + let actor = User::from_id( + c, + &{ + let res: String = follow.follow_props.actor_link::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)?; + + let target = User::from_id( + c, + &{ + let res: String = follow.follow_props.object_link::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)?; + Follow::accept_follow(&c.conn, &actor, &target, follow, actor.id, target.id) + } +} + +impl AsObject 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(¬if).execute(conn)?; + } + + Ok(()) } else { Err(Error::Unauthorized) } diff --git a/plume-models/src/inbox.rs b/plume-models/src/inbox.rs new file mode 100644 index 00000000..adcdb776 --- /dev/null +++ b/plume-models/src/inbox.rs @@ -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 { + Inbox::handle(ctx, act) + .with::() + .with::() + .with::() + .with::() + .with::() + .with::() + .with::() + .with::() + .with::() + .with::() + .with::() + .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, + Vec, + Vec, + ) { + 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": "Saluton, mi skribas testojn", + "source": { + "mediaType": "text/markdown", + "content": "**Saluton**, mi skribas testojn" + }, + }, + "type": "Update", + }); + + super::inbox(&r, act).unwrap(); + + Ok(()) + }); + } +} diff --git a/plume-models/src/lib.rs b/plume-models/src/lib.rs index adc887f4..2e77d276 100644 --- a/plume-models/src/lib.rs +++ b/plume-models/src/lib.rs @@ -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>), InvalidValue, Io(std::io::Error), MissingApProperty, @@ -139,6 +143,15 @@ impl From for Error { } } +impl From> for Error { + fn from(err: InboxError) -> Error { + match err { + InboxError::InvalidActor(Some(e)) | InboxError::InvalidObject(Some(e)) => e, + e => Error::Inbox(Box::new(e)), + } + } +} + pub type Result = std::result::Result; pub type ApiResult = std::result::Result; @@ -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::::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; diff --git a/plume-models/src/likes.rs b/plume-models/src/likes.rs index 555b8b8f..18678cc0 100644 --- a/plume-models/src/likes.rs +++ b/plume-models/src/likes.rs @@ -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::(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 for Like { - type Error = Error; - - fn from_activity(conn: &Connection, like: activity::Like, _actor: Id) -> Result { - 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 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 for Like { } Ok(()) } -} - -impl Deletable for Like { - type Error = Error; - - fn delete(&self, conn: &Connection) -> Result { - diesel::delete(self).execute(conn)?; - - // delete associated notification if any - if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) { - diesel::delete(¬if).execute(conn)?; - } + pub fn build_undo(&self, conn: &Connection) -> Result { 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 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::(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 { - 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 for Post { + type Error = Error; + type Output = Like; + + fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result { + 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 for Like { + type Error = Error; + type Object = activity::Like; + + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Like::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, act: activity::Like) -> Result { + let res = Like::insert( + &c.conn, + NewLike { + post_id: Post::from_id( + c, + &{ + let res: String = act.like_props.object_link::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)? + .id, + user_id: User::from_id( + c, + &{ + let res: String = act.like_props.actor_link::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)? + .id, + ap_url: act.object_props.id_string()?, + }, + )?; + res.notify(&c.conn)?; + Ok(res) + } +} + +impl AsObject 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(¬if).execute(conn)?; + } + Ok(()) } else { Err(Error::Unauthorized) } @@ -125,6 +163,7 @@ impl Deletable 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, diff --git a/plume-models/src/medias.rs b/plume-models/src/medias.rs index a1c3897a..d8be73ab 100644 --- a/plume-models/src/medias.rs +++ b/plume-models/src/medias.rs @@ -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 { + pub fn from_activity(c: &PlumeRocket, image: &Image) -> Result { + 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::() @@ -219,7 +223,9 @@ impl Media { .into_iter() .next()? .as_ref(), - )? + None, + ) + .map_err(|(_, e)| e)? .id, }, ) diff --git a/plume-models/src/mentions.rs b/plume-models/src/mentions.rs index 7fbbb1c2..520d8225 100644 --- a/plume-models/src/mentions.rs +++ b/plume-models/src/mentions.rs @@ -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 { - let user = User::find_by_fqn(conn, ment)?; + pub fn build_activity(c: &PlumeRocket, ment: &str) -> Result { + 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 for Mention { - type Error = Error; fn notify(&self, conn: &Connection) -> Result<()> { let m = self.get_mentioned(conn)?; Notification::insert( diff --git a/plume-models/src/plume_rocket.rs b/plume-models/src/plume_rocket.rs new file mode 100644 index 00000000..64d563c2 --- /dev/null +++ b/plume-models/src/plume_rocket.rs @@ -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, + pub searcher: Arc, + pub worker: Arc, + } + + impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket { + type Error = (); + + fn from_request(request: &'a Request<'r>) -> request::Outcome { + let conn = request.guard::()?; + let intl = request.guard::()?; + let user = request.guard::().succeeded(); + let worker = request.guard::>>()?; + let searcher = request.guard::>>()?; + 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, + pub searcher: Arc, + pub worker: Arc, + } + + impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket { + type Error = (); + + fn from_request(request: &'a Request<'r>) -> request::Outcome { + let conn = request.guard::()?; + let user = request.guard::().succeeded(); + let worker = request.guard::>>()?; + let searcher = request.guard::>>()?; + Outcome::Success(PlumeRocket { + conn, + user, + worker: worker.clone(), + searcher: searcher.clone(), + }) + } + } +} diff --git a/plume-models/src/posts.rs b/plume-models/src/posts.rs index 098f501c..3cc302b3 100644 --- a/plume-models/src/posts.rs +++ b/plume-models/src/posts.rs @@ -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; @@ -68,17 +67,17 @@ pub struct NewPost { pub cover_id: Option, } -impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option)> for Post { +impl Provider for Post { type Data = PostEndpoint; - fn get( - (conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option), - id: i32, - ) -> ApiResult { + fn get(rockets: &PlumeRocket, id: i32) -> ApiResult { + 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)> for P } } - fn list( - (conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option), - filter: PostEndpoint, - ) -> Vec { + fn list(rockets: &PlumeRocket, filter: PostEndpoint) -> Vec { + 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)> for P } query - .get_results::(*conn) + .get_results::(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)> for P } fn update( - (_conn, _worker, _search, _user_id): &(&Connection, &Worker, &Searcher, Option), + _rockets: &PlumeRocket, _id: i32, _new_data: PostEndpoint, ) -> ApiResult { unimplemented!() } - fn delete( - (conn, _worker, search, user_id): &(&Connection, &Worker, &Searcher, Option), - 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), - query: PostEndpoint, - ) -> ApiResult { - if user_id.is_none() { + fn create(rockets: &PlumeRocket, query: PostEndpoint) -> ApiResult { + 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)> 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("::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)> 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 { 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 { 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::() { - 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::>(); - 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::(tag.clone()) - .map(|m| mentions.push(m)) - .ok(); - - serde_json::from_value::(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) -> 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 for Post { - type Error = Error; - - fn from_activity( - (conn, searcher): &(&'a Connection, &'a Searcher), - article: LicensedArticle, - _actor: Id, - ) -> Result { - 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::()? - .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::() - .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::()?.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::>(); - if let Some(serde_json::Value::Array(tags)) = article.object_props.tag.clone() { - for tag in tags { - serde_json::from_value::(tag.clone()) - .map(|m| Mention::from_activity(conn, &m, post.id, true, true)) - .ok(); - - serde_json::from_value::(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 { + pub fn build_delete(&self, conn: &Connection) -> Result { 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 { - 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 for Post { + type Error = Error; + type Object = LicensedArticle; + + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Self::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, article: LicensedArticle) -> Result { + 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::()? + .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::() + .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::()?.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::>(); + if let Some(serde_json::Value::Array(tags)) = article.object_props.tag.clone() { + for tag in tags { + serde_json::from_value::(tag.clone()) + .map(|m| Mention::from_activity(conn, &m, post.id, true, true)) + .ok(); + + serde_json::from_value::(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 for Post { + type Error = Error; + type Output = Post; + + fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result { + // TODO: check that _actor is actually one of the author? + Ok(self) + } +} + +impl AsObject 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, + pub subtitle: Option, + pub content: Option, + pub cover: Option, + pub source: Option, + pub license: Option, + pub tags: Option, +} + +impl FromId for PostUpdate { + type Error = Error; + type Object = LicensedArticle; + + fn from_db(_: &PlumeRocket, _: &str) -> Result { + // Always fail because we always want to deserialize the AP object + Err(Error::NotFound) + } + + fn from_activity(c: &PlumeRocket, updated: LicensedArticle) -> Result { + 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::() + .ok() + .and_then(|img| Media::from_activity(&c, &img).ok().map(|m| m.id)), + source: updated + .object + .ap_object_props + .source_object::() + .ok() + .map(|x| x.content), + license: updated.custom_props.license_string().ok(), + tags: updated.object.object_props.tag.clone(), + }) + } +} + +impl AsObject 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::>(); + 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::(tag.clone()) + .map(|m| mentions.push(m)) + .ok(); + + serde_json::from_value::(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() + ); + } +} diff --git a/plume-models/src/reshares.rs b/plume-models/src/reshares.rs index cdf72d1e..bf8e5158 100644 --- a/plume-models/src/reshares.rs +++ b/plume-models/src/reshares.rs @@ -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::(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 for Reshare { - type Error = Error; - - fn from_activity(conn: &Connection, announce: Announce, _actor: Id) -> Result { - let user = User::from_url(conn, announce.announce_props.actor_link::()?.as_ref())?; - let post = - Post::find_by_ap_url(conn, announce.announce_props.object_link::()?.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 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 for Reshare { } Ok(()) } -} - -impl Deletable for Reshare { - type Error = Error; - - fn delete(&self, conn: &Connection) -> Result { - diesel::delete(self).execute(conn)?; - - // delete associated notification if any - if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) { - diesel::delete(¬if).execute(conn)?; - } + pub fn build_undo(&self, conn: &Connection) -> Result { 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 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::(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 { - 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 for Post { + type Error = Error; + type Output = Reshare; + + fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result { + 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 for Reshare { + type Error = Error; + type Object = Announce; + + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Reshare::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, act: Announce) -> Result { + let res = Reshare::insert( + &c.conn, + NewReshare { + post_id: Post::from_id( + c, + &{ + let res: String = act.announce_props.object_link::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)? + .id, + user_id: User::from_id( + c, + &{ + let res: String = act.announce_props.actor_link::()?.into(); + res + }, + None, + ) + .map_err(|(_, e)| e)? + .id, + ap_url: act.object_props.id_string()?, + }, + )?; + res.notify(&c.conn)?; + Ok(res) + } +} + +impl AsObject 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(¬if).execute(conn)?; + } + + Ok(()) } else { Err(Error::Unauthorized) } diff --git a/plume-models/src/search/mod.rs b/plume-models/src/search/mod.rs index 48e0dc1b..02485534 100644 --- a/plume-models/src/search/mod.rs +++ b/plume-models/src/search/mod.rs @@ -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)) diff --git a/plume-models/src/users.rs b/plume-models/src/users.rs index 32d162ca..52150a9f 100644 --- a/plume-models/src/users.rs +++ b/plume-models/src/users.rs @@ -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; @@ -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 { + pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result { let from_db = users::table .filter(users::fqn.eq(fqn)) .limit(1) - .load::(conn)? + .load::(&*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 { + fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result { 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 { @@ -274,97 +274,8 @@ impl User { Ok(json) } - pub fn fetch_from_url(conn: &Connection, url: &str) -> Result { - 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 { - 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::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::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 for User { + type Error = Error; + type Object = CustomPerson; + + fn from_db(c: &PlumeRocket, id: &str) -> Result { + Self::find_by_ap_url(&c.conn, id) + } + + fn from_activity(c: &PlumeRocket, acct: CustomPerson) -> Result { + 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 { @@ -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(()) + }); + } } diff --git a/plume_test b/plume_test new file mode 100644 index 0000000000000000000000000000000000000000..3e529496692702ea04e00773413d5bae20435384 GIT binary patch literal 12288 zcmeI#%}T>S5C`zxC@MlfZbk4g#}-5s@$OnzEhe>Yw?a=#v}+AC4JN60)mQNae4W0D zN3(_Y7K+#MAJ`4U%!ceQr|CcVQEuraN#ii5GgfDuvkM}|7%$dVv6kWT?PXFtUA`_j zJ3joW)P9*)owMq^_Py=^>JWec1Rwwb2tWV=5P$##AOL}X34Gr0-G&hS>mU!GCf1ly zWHUQ4+4HN7LlZ~iG|Z!9nw3|)wvs|i(&EyWRNhC;cbjI#yXaA<_N`FO^OF|!R3{K> zNOy8b-Fr>lf$txsv#BZibSIE~ErQ;vLRa!y417)Pfl{)o&8nr<_jVd%!C5pN*^lgP zQq1cnoaM6}Q#hhJXMBAOHafKmY;|fB*y_009U0 literal 0 HcmV?d00001 diff --git a/plume_tests b/plume_tests new file mode 100644 index 0000000000000000000000000000000000000000..3e529496692702ea04e00773413d5bae20435384 GIT binary patch literal 12288 zcmeI#%}T>S5C`zxC@MlfZbk4g#}-5s@$OnzEhe>Yw?a=#v}+AC4JN60)mQNae4W0D zN3(_Y7K+#MAJ`4U%!ceQr|CcVQEuraN#ii5GgfDuvkM}|7%$dVv6kWT?PXFtUA`_j zJ3joW)P9*)owMq^_Py=^>JWec1Rwwb2tWV=5P$##AOL}X34Gr0-G&hS>mU!GCf1ly zWHUQ4+4HN7LlZ~iG|Z!9nw3|)wvs|i(&EyWRNhC;cbjI#yXaA<_N`FO^OF|!R3{K> zNOy8b-Fr>lf$txsv#BZibSIE~ErQ;vLRa!y417)Pfl{)o&8nr<_jVd%!C5pN*^lgP zQq1cnoaM6}Q#hhJXMBAOHafKmY;|fB*y_009U0 literal 0 HcmV?d00001 diff --git a/po/plume/ar.po b/po/plume/ar.po index 241918ec..c563d14c 100644 --- a/po/plume/ar.po +++ b/po/plume/ar.po @@ -42,6 +42,9 @@ msgstr "الصورة الرمزية لـ {0}" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "هناك مدونة تحمل نفس التسمية." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "لست مِن محرري هذه المدونة." @@ -213,8 +216,8 @@ msgid "Allow anyone to register here" msgstr "السماح للجميع بتسجيل حساب" #, fuzzy -msgid "Short description - byline" -msgstr "وصف قصير" +msgid "Short description" +msgstr "الوصف الطويل" #, fuzzy msgid "Markdown syntax is supported" @@ -234,7 +237,8 @@ msgstr "حفظ التعديلات" msgid "About {0}" msgstr "عن {0}" -msgid "Home to {0} users" +#, fuzzy +msgid "Home to {0} people" msgstr "يستضيف {0} مستخدمين" msgid "Who wrote {0} articles" @@ -372,7 +376,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "الوصف" #, fuzzy @@ -396,7 +400,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "بلوم محرك لامركزي للمدونات." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "يمكن للمدونين إدارة عدة مدونات مِن موقع ويب وحيد." #, fuzzy @@ -407,10 +411,6 @@ msgstr "" "ستكون المقالات معروضة كذلك على مواقع بلوم الأخرى، و يمكنكم التفاعل معها " "مباشرة عبر أية منصة أخرى مثل ماستدون." -#, fuzzy -msgid "Home to {0} people" -msgstr "يستضيف {0} مستخدمين" - msgid "Read the detailed rules" msgstr "إقرأ القواعد بالتفصيل" @@ -647,7 +647,7 @@ msgstr "أعجبني" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "بدون مشاركة" msgstr[1] "مشاركة واحدة" msgstr[2] "مشاركتان" @@ -841,6 +841,13 @@ msgstr "قم بنسخه و إلصاقه في محتوى مقالك لعرض ال msgid "Use as an avatar" msgstr "استخدمها كصورة رمزية" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "وصف قصير" + +#~ msgid "Home to {0} users" +#~ msgstr "يستضيف {0} مستخدمين" + #~ msgid "Login" #~ msgstr "تسجيل الدخول" @@ -948,9 +955,6 @@ msgstr "استخدمها كصورة رمزية" #~ msgid "Invalid name" #~ msgstr "اسم غير صالح" -#~ msgid "A blog with the same name already exists." -#~ msgstr "هناك مدونة تحمل نفس التسمية." - #~ msgid "Your comment can't be empty" #~ msgstr "لا يمكن ترك التعليق فارغا" diff --git a/po/plume/de.po b/po/plume/de.po index 2c45a8ef..ee533aab 100644 --- a/po/plume/de.po +++ b/po/plume/de.po @@ -40,6 +40,9 @@ msgstr "{0}'s Avatar'" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "Ein Blog mit demselben Namen existiert bereits." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Du bist kein Autor in diesem Blog." @@ -217,8 +220,8 @@ msgid "Allow anyone to register here" msgstr "Erlaube jedem die Registrierung" #, fuzzy -msgid "Short description - byline" -msgstr "Kurze Beschreibung" +msgid "Short description" +msgstr "Lange Beschreibung" #, fuzzy msgid "Markdown syntax is supported" @@ -238,7 +241,7 @@ msgstr "Einstellungen speichern" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -384,7 +387,7 @@ msgid "Subscribe" msgstr "Abonieren" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Abonoment" #, fuzzy @@ -408,7 +411,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume ist eine dezentrale Blogging-Engine." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "Autoren können verschiedene Blogs von einer Website aus verwalten." #, fuzzy @@ -419,9 +422,6 @@ msgstr "" "Artikel sind auch auf anderen Plume-Websites sichtbar und es ist möglich aus " "anderen Plattformen wie Mastodon mit diesen zu interagieren." -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "Lies die detailierten Regeln" @@ -652,7 +652,7 @@ msgstr "" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "Ein Boost" msgstr[1] "{0} Boosts" @@ -849,6 +849,10 @@ msgstr "Um diese Mediendatei einzufügen, kopiere sie in deine Artikel." msgid "Use as an avatar" msgstr "Als Avatar verwenden" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Kurze Beschreibung" + #, fuzzy #~ msgid "You need to be logged in order to create a new blog" #~ msgstr "Du musst eingeloggt sein, um einen neuen Beitrag zu schreiben" @@ -1019,9 +1023,6 @@ msgstr "Als Avatar verwenden" #~ msgid "Invalid name" #~ msgstr "Ungültiger Name" -#~ msgid "A blog with the same name already exists." -#~ msgstr "Ein Blog mit demselben Namen existiert bereits." - #~ msgid "Your comment can't be empty" #~ msgstr "Dein Kommentar kann nicht leer sein" diff --git a/po/plume/en.po b/po/plume/en.po index fbaf12c0..8e3aec4d 100644 --- a/po/plume/en.po +++ b/po/plume/en.po @@ -42,6 +42,10 @@ msgstr "" msgid "To create a new blog, you need to be logged in" msgstr "" +# src/routes/blogs.rs:111 +msgid "A blog with the same name already exists." +msgstr "" + # src/routes/blogs.rs:136 msgid "You are not allowed to delete this blog." msgstr "" @@ -213,7 +217,7 @@ msgstr "" msgid "Allow anyone to register here" msgstr "" -msgid "Short description - byline" +msgid "Short description" msgstr "" msgid "Markdown syntax is supported" @@ -232,7 +236,7 @@ msgstr "" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -364,7 +368,7 @@ msgstr "" msgid "Subscribe" msgstr "" -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "" msgid "{0}'s subscribers" @@ -385,7 +389,7 @@ msgstr "" msgid "Plume is a decentralized blogging engine." msgstr "" -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "" msgid "" @@ -393,9 +397,6 @@ msgid "" "with them directly from other platforms like Mastodon." msgstr "" -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "" @@ -614,7 +615,7 @@ msgid "Add yours" msgstr "" msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "" msgstr[1] "" diff --git a/po/plume/es.po b/po/plume/es.po index 71f789a8..aab44b3b 100644 --- a/po/plume/es.po +++ b/po/plume/es.po @@ -41,6 +41,10 @@ msgstr "" msgid "To create a new blog, you need to be logged in" msgstr "" +# src/routes/blogs.rs:111 +msgid "A blog with the same name already exists." +msgstr "" + # src/routes/blogs.rs:136 msgid "You are not allowed to delete this blog." msgstr "" @@ -208,7 +212,7 @@ msgstr "" msgid "Allow anyone to register here" msgstr "" -msgid "Short description - byline" +msgid "Short description" msgstr "" msgid "Markdown syntax is supported" @@ -227,7 +231,7 @@ msgstr "" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -356,7 +360,7 @@ msgstr "" msgid "Subscribe" msgstr "" -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "" msgid "{0}'s subscribers" @@ -377,7 +381,7 @@ msgstr "" msgid "Plume is a decentralized blogging engine." msgstr "" -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "" msgid "" @@ -385,9 +389,6 @@ msgid "" "with them directly from other platforms like Mastodon." msgstr "" -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "" @@ -611,7 +612,7 @@ msgid "Add yours" msgstr "Agregue el suyo" msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "" msgstr[1] "" diff --git a/po/plume/fr.po b/po/plume/fr.po index 7cff9a0c..059ad66e 100644 --- a/po/plume/fr.po +++ b/po/plume/fr.po @@ -43,6 +43,10 @@ msgstr "Avatar de {0}" msgid "To create a new blog, you need to be logged in" msgstr "" +#, fuzzy +msgid "A blog with the same name already exists." +msgstr "Un article avec le même titre existe déjà." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Vous n’êtes pas auteur⋅ice dans ce blog." @@ -215,8 +219,8 @@ msgid "Allow anyone to register here" msgstr "Autoriser les inscriptions" #, fuzzy -msgid "Short description - byline" -msgstr "Description courte" +msgid "Short description" +msgstr "Description longue" #, fuzzy msgid "Markdown syntax is supported" @@ -236,7 +240,8 @@ msgstr "Enregistrer les paramètres" msgid "About {0}" msgstr "À propos de {0}" -msgid "Home to {0} users" +#, fuzzy +msgid "Home to {0} people" msgstr "Accueille {0} personnes" msgid "Who wrote {0} articles" @@ -384,7 +389,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Description" #, fuzzy @@ -408,7 +413,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume est un moteur de blog décentralisé." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "" "Les auteur⋅ice⋅s peuvent gérer différents blogs au sein d’un même site." @@ -421,10 +426,6 @@ msgstr "" "pouvez interagir avec directement depuis d’autres plateformes telles que " "Mastodon." -#, fuzzy -msgid "Home to {0} people" -msgstr "Accueille {0} personnes" - msgid "Read the detailed rules" msgstr "Lire les règles détaillées" @@ -646,7 +647,7 @@ msgstr "" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "{0} partage" msgstr[1] "{0} partages" @@ -845,6 +846,13 @@ msgstr "Copiez-le dans vos articles pour insérer ce média." msgid "Use as an avatar" msgstr "Utiliser comme avatar" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Description courte" + +#~ msgid "Home to {0} users" +#~ msgstr "Accueille {0} personnes" + #, fuzzy #~ msgid "You need to be logged in order to create a new blog" #~ msgstr "Vous devez vous connecter pour écrire un article" @@ -1019,9 +1027,6 @@ msgstr "Utiliser comme avatar" #~ msgid "Your comment can't be empty" #~ msgstr "Votre commentaire ne peut pas être vide." -#~ msgid "A post with the same title already exists." -#~ msgstr "Un article avec le même titre existe déjà." - #, fuzzy #~ msgid "We need an email, or a username to identify you" #~ msgstr "" diff --git a/po/plume/gl.po b/po/plume/gl.po index 98fa9fc9..10984893 100644 --- a/po/plume/gl.po +++ b/po/plume/gl.po @@ -39,6 +39,9 @@ msgstr "Avatar de {{ name}}" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "Xa existe un blog co mismo nome." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Vostede non é autora en este blog." @@ -216,8 +219,8 @@ msgid "Allow anyone to register here" msgstr "Permitir o rexistro aberto" #, fuzzy -msgid "Short description - byline" -msgstr "Descrición curta" +msgid "Short description" +msgstr "Descrición longa" #, fuzzy msgid "Markdown syntax is supported" @@ -237,7 +240,7 @@ msgstr "Gardar axustes" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -380,7 +383,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Descrición" #, fuzzy @@ -404,7 +407,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume é un motor de publicación descentralizada." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "As autoras poden xestionar varios blogs desde un único sitio web." #, fuzzy @@ -415,9 +418,6 @@ msgstr "" "Os artigos son visibles tamén en outros sitios Plume, e pode interactuar " "coneles desde outras plataformas como Mastadon." -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "Lea o detalle das normas" @@ -646,7 +646,7 @@ msgstr "" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "Unha promoción" msgstr[1] "{0} promocións" @@ -840,6 +840,10 @@ msgstr "Copie para incrustar este contido nos seus artigos" msgid "Use as an avatar" msgstr "Utilizar como avatar" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Descrición curta" + #, fuzzy #~ msgid "You need to be logged in order to create a new blog" #~ msgstr "Debe estar conectada para escribir un novo artigo" @@ -1014,9 +1018,6 @@ msgstr "Utilizar como avatar" #~ msgid "Invalid name" #~ msgstr "Nome non válido" -#~ msgid "A blog with the same name already exists." -#~ msgstr "Xa existe un blog co mismo nome." - #~ msgid "Your comment can't be empty" #~ msgstr "O seu comentario non pode estar baldeiro" diff --git a/po/plume/it.po b/po/plume/it.po index 59b215c9..aee462e0 100644 --- a/po/plume/it.po +++ b/po/plume/it.po @@ -39,6 +39,9 @@ msgstr "Avatar di {0}" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "Un blog con lo stesso nome esiste già." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Non sei l'autore di questo blog." @@ -216,8 +219,8 @@ msgid "Allow anyone to register here" msgstr "Consenti a chiunque di registrarsi" #, fuzzy -msgid "Short description - byline" -msgstr "Breve descrizione" +msgid "Short description" +msgstr "Descrizione lunga" #, fuzzy msgid "Markdown syntax is supported" @@ -237,7 +240,7 @@ msgstr "Salva impostazioni" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -383,7 +386,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Descrizione" #, fuzzy @@ -407,7 +410,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume è un motore di blog decentralizzato." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "Gli autori possono gestire vari blog da un unico sito." #, fuzzy @@ -418,9 +421,6 @@ msgstr "" "Gli articoli sono visibili anche da altri siti Plume, e puoi interagire con " "loro direttamente da altre piattaforme come Mastodon." -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "Leggi le regole dettagliate" @@ -651,7 +651,7 @@ msgstr "" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "Un Boost" msgstr[1] "{0} Boost" @@ -847,6 +847,10 @@ msgstr "Copialo nei tuoi articoli per inserire questo media." msgid "Use as an avatar" msgstr "Usa come avatar" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Breve descrizione" + #, fuzzy #~ msgid "You need to be logged in order to create a new blog" #~ msgstr "Devi effettuare l'accesso per scrivere un post" @@ -1017,9 +1021,6 @@ msgstr "Usa come avatar" #~ msgid "Invalid name" #~ msgstr "Nome non valido" -#~ msgid "A blog with the same name already exists." -#~ msgstr "Un blog con lo stesso nome esiste già." - #~ msgid "Your comment can't be empty" #~ msgstr "Il tuo commento non può essere vuoto" diff --git a/po/plume/ja.po b/po/plume/ja.po index 1eb603be..b917a391 100644 --- a/po/plume/ja.po +++ b/po/plume/ja.po @@ -42,6 +42,9 @@ msgstr "{{ name}} のアバター" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "同じ名前のブログがすでに存在します。" + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "あなたはこのブログの作者ではありません。" @@ -216,8 +219,8 @@ msgid "Allow anyone to register here" msgstr "不特定多数に登録を許可" #, fuzzy -msgid "Short description - byline" -msgstr "短い説明" +msgid "Short description" +msgstr "長い説明" #, fuzzy msgid "Markdown syntax is supported" @@ -237,7 +240,7 @@ msgstr "設定を保存" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -379,7 +382,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "説明" #, fuzzy @@ -403,7 +406,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume は分散型ブログエンジンです。" #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "作成者は、ある固有の Web サイトから、さまざまなブログを管理できます。" #, fuzzy @@ -414,9 +417,6 @@ msgstr "" "記事は他の Plume Web サイトからも閲覧可能であり、Mastdon のように他のプラット" "フォームから直接記事と関わることができます。" -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "詳細な規則を読む" @@ -649,7 +649,7 @@ msgstr "いいねする" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "{0} ブースト" #, fuzzy @@ -837,6 +837,10 @@ msgstr "このメディアを記事に挿入するには、自分の記事にコ msgid "Use as an avatar" msgstr "アバターとして使う" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "短い説明" + #~ msgid "Login" #~ msgstr "ログイン" @@ -943,9 +947,6 @@ msgstr "アバターとして使う" #~ msgid "Invalid name" #~ msgstr "無効な名前" -#~ msgid "A blog with the same name already exists." -#~ msgstr "同じ名前のブログがすでに存在します。" - #~ msgid "Your comment can't be empty" #~ msgstr "コメントは空にできません" diff --git a/po/plume/nb.po b/po/plume/nb.po index 90c46ddb..eb38088f 100644 --- a/po/plume/nb.po +++ b/po/plume/nb.po @@ -42,6 +42,10 @@ msgstr "" msgid "To create a new blog, you need to be logged in" msgstr "" +#, fuzzy +msgid "A blog with the same name already exists." +msgstr "Et innlegg med samme navn finnes allerede." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Du er ikke denne bloggens forfatter." @@ -222,8 +226,8 @@ msgid "Allow anyone to register here" msgstr "Tillat at hvem som helst registrerer seg" #, fuzzy -msgid "Short description - byline" -msgstr "Kort beskrivelse" +msgid "Short description" +msgstr "Lang beskrivelse" #, fuzzy msgid "Markdown syntax is supported" @@ -243,7 +247,7 @@ msgstr "Lagre innstillingene" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -386,7 +390,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Lang beskrivelse" #, fuzzy @@ -411,7 +415,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume er et desentralisert bloggsystem." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "Forfattere kan administrere forskjellige blogger fra en unik webside." #, fuzzy @@ -422,9 +426,6 @@ msgstr "" "Artiklene er også synlige på andre websider som kjører Plume, og du kan " "interagere med dem direkte fra andre plattformer som f.eks. Mastodon." -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "Les reglene" @@ -673,7 +674,7 @@ msgstr "Legg til din" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "Én fremhevning" msgstr[1] "{0} fremhevninger" @@ -857,6 +858,10 @@ msgstr "" msgid "Use as an avatar" msgstr "" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Kort beskrivelse" + #~ msgid "Login" #~ msgstr "Logg inn" @@ -977,10 +982,6 @@ msgstr "" #~ msgid "The comment field can't be left empty" #~ msgstr "Kommentaren din kan ikke være tom" -#, fuzzy -#~ msgid "An article with the same title already exists." -#~ msgstr "Et innlegg med samme navn finnes allerede." - #, fuzzy #~ msgid "Your password field can't be left empty" #~ msgstr "Kommentaren din kan ikke være tom" diff --git a/po/plume/pl.po b/po/plume/pl.po index 4d1209f9..c694d1a6 100644 --- a/po/plume/pl.po +++ b/po/plume/pl.po @@ -38,6 +38,9 @@ msgstr "Awatar {0}" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "Blog o tej nazwie już istnieje." + msgid "You are not allowed to delete this blog." msgstr "Nie masz uprawnień do usunięcia tego bloga." @@ -204,8 +207,9 @@ msgstr "Nieobowiązkowe" msgid "Allow anyone to register here" msgstr "Pozwól każdemu na rejestrację" -msgid "Short description - byline" -msgstr "Krótki opis" +#, fuzzy +msgid "Short description" +msgstr "Szczegółowy opis" msgid "Markdown syntax is supported" msgstr "Składnia Markdown jest obsługiwana" @@ -222,7 +226,7 @@ msgstr "Zapisz te ustawienia" msgid "About {0}" msgstr "O {0}" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "Używana przez {0} użytkowników" msgid "Who wrote {0} articles" @@ -358,7 +362,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Opis" #, fuzzy @@ -381,7 +385,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume jest zdecentralizowanym silnikiem blogowym." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "Autorzy mogą zarządzać blogami ze specjalnej strony." #, fuzzy @@ -392,9 +396,6 @@ msgstr "" "Artykuły są widoczne na innych stronach Plume, możesz też wejść w interakcje " "z nimi na platformach takich jak Mastodon." -msgid "Home to {0} people" -msgstr "Używana przez {0} użytkowników" - msgid "Read the detailed rules" msgstr "Przeczytaj szczegółowe zasady" @@ -620,8 +621,9 @@ msgstr "Już tego nie lubię" msgid "Add yours" msgstr "Dodaj swoje" +#, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "Jedno podbicie" msgstr[1] "{0} podbicia" msgstr[2] "{0} podbić" @@ -805,6 +807,12 @@ msgstr "Skopiuj do swoich artykułów, aby wstawić tę zawartość multimedialn msgid "Use as an avatar" msgstr "Użyj jako awataru" +#~ msgid "Short description - byline" +#~ msgstr "Krótki opis" + +#~ msgid "Home to {0} users" +#~ msgstr "Używana przez {0} użytkowników" + #~ msgid "Login" #~ msgstr "Zaloguj się" @@ -969,9 +977,6 @@ msgstr "Użyj jako awataru" #~ msgid "Invalid name" #~ msgstr "Nieprawidłowa nazwa" -#~ msgid "A blog with the same name already exists." -#~ msgstr "Blog o tej nazwie już istnieje." - #~ msgid "Your comment can't be empty" #~ msgstr "Twój komentarz nie może być pusty" diff --git a/po/plume/plume.pot b/po/plume/plume.pot index 0830db4c..c5ac3e3d 100644 --- a/po/plume/plume.pot +++ b/po/plume/plume.pot @@ -40,23 +40,27 @@ msgstr "" msgid "To create a new blog, you need to be logged in" msgstr "" -# src/routes/blogs.rs:169 +# src/routes/blogs.rs:109 +msgid "A blog with the same name already exists." +msgstr "" + +# src/routes/blogs.rs:172 msgid "You are not allowed to delete this blog." msgstr "" -# src/routes/blogs.rs:214 +# src/routes/blogs.rs:217 msgid "You are not allowed to edit this blog." msgstr "" -# src/routes/blogs.rs:253 +# src/routes/blogs.rs:262 msgid "You can't use this media as a blog icon." msgstr "" -# src/routes/blogs.rs:271 +# src/routes/blogs.rs:280 msgid "You can't use this media as a blog banner." msgstr "" -# src/routes/likes.rs:47 +# src/routes/likes.rs:51 msgid "To like a post, you need to be logged in" msgstr "" @@ -64,27 +68,27 @@ msgstr "" msgid "To see your notifications, you need to be logged in" msgstr "" -# src/routes/posts.rs:92 +# src/routes/posts.rs:93 msgid "This post isn't published yet." msgstr "" -# src/routes/posts.rs:120 +# src/routes/posts.rs:122 msgid "To write a new post, you need to be logged in" msgstr "" -# src/routes/posts.rs:138 +# src/routes/posts.rs:140 msgid "You are not an author of this blog." msgstr "" -# src/routes/posts.rs:145 +# src/routes/posts.rs:147 msgid "New post" msgstr "" -# src/routes/posts.rs:190 +# src/routes/posts.rs:192 msgid "Edit {0}" msgstr "" -# src/routes/reshares.rs:47 +# src/routes/reshares.rs:51 msgid "To reshare a post, you need to be logged in" msgstr "" @@ -104,15 +108,15 @@ msgstr "" msgid "Sorry, but the link expired. Try again" msgstr "" -# src/routes/user.rs:148 +# src/routes/user.rs:136 msgid "To access your dashboard, you need to be logged in" msgstr "" -# src/routes/user.rs:187 +# src/routes/user.rs:180 msgid "To subscribe to someone, you need to be logged in" msgstr "" -# src/routes/user.rs:287 +# src/routes/user.rs:280 msgid "To edit your profile, you need to be logged in" msgstr "" @@ -211,7 +215,7 @@ msgstr "" msgid "Allow anyone to register here" msgstr "" -msgid "Short description - byline" +msgid "Short description" msgstr "" msgid "Markdown syntax is supported" @@ -230,7 +234,7 @@ msgstr "" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -358,7 +362,7 @@ msgstr "" msgid "Subscribe" msgstr "" -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "" msgid "{0}'s subscribers" @@ -379,15 +383,12 @@ msgstr "" msgid "Plume is a decentralized blogging engine." msgstr "" -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "" msgid "Articles are also visible on other Plume instances, and you can interact with them directly from other platforms like Mastodon." msgstr "" -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "" @@ -601,7 +602,7 @@ msgid "Add yours" msgstr "" msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "" msgid "I don't want to boost this anymore" diff --git a/po/plume/pt.po b/po/plume/pt.po index b8b159cf..9f577996 100644 --- a/po/plume/pt.po +++ b/po/plume/pt.po @@ -41,6 +41,9 @@ msgstr "Avatar de {0}" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "Um blog com o mesmo nome já existe." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Você não é autor neste blog." @@ -212,8 +215,8 @@ msgid "Allow anyone to register here" msgstr "Permitir que qualquer pessoa se registre" #, fuzzy -msgid "Short description - byline" -msgstr "Descrição breve" +msgid "Short description" +msgstr "Descrição longa" #, fuzzy msgid "Markdown syntax is supported" @@ -233,7 +236,8 @@ msgstr "Salvar configurações" msgid "About {0}" msgstr "Sobre {0}" -msgid "Home to {0} users" +#, fuzzy +msgid "Home to {0} people" msgstr "Acolhe {0} pessoas" msgid "Who wrote {0} articles" @@ -372,7 +376,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Descrição" #, fuzzy @@ -396,7 +400,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume é um motor de blogs descentralizado." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "Os autores podem gerenciar vários blogs a partir de um único site." #, fuzzy @@ -407,10 +411,6 @@ msgstr "" "Os artigos também são visíveis em outros sites Plume, e você pode interagir " "com eles diretamente de outras plataformas como Mastodon." -#, fuzzy -msgid "Home to {0} people" -msgstr "Acolhe {0} pessoas" - msgid "Read the detailed rules" msgstr "Leia as regras detalhadas" @@ -642,7 +642,7 @@ msgid "Add yours" msgstr "Eu gosto" msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "" msgstr[1] "" @@ -826,6 +826,13 @@ msgstr "Copie-o em seus artigos para inserir esta mídia." msgid "Use as an avatar" msgstr "Utilizar como avatar" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Descrição breve" + +#~ msgid "Home to {0} users" +#~ msgstr "Acolhe {0} pessoas" + #~ msgid "Login" #~ msgstr "Entrar" @@ -922,9 +929,6 @@ msgstr "Utilizar como avatar" #~ msgid "Unknown error" #~ msgstr "Erro desconhecido" -#~ msgid "A blog with the same name already exists." -#~ msgstr "Um blog com o mesmo nome já existe." - #~ msgid "Your comment can't be empty" #~ msgstr "O seu comentário não pode estar vazio" diff --git a/po/plume/ru.po b/po/plume/ru.po index 72b2b7ef..fbad79e6 100644 --- a/po/plume/ru.po +++ b/po/plume/ru.po @@ -41,6 +41,9 @@ msgstr "Аватар {0}" msgid "To create a new blog, you need to be logged in" msgstr "" +msgid "A blog with the same name already exists." +msgstr "Блог с таким же названием уже существует." + #, fuzzy msgid "You are not allowed to delete this blog." msgstr "Вы не автор этого блога." @@ -221,8 +224,8 @@ msgid "Allow anyone to register here" msgstr "Позволить регистрироваться кому угодно" #, fuzzy -msgid "Short description - byline" -msgstr "Краткое описание" +msgid "Short description" +msgstr "Длинное описание" #, fuzzy msgid "Markdown syntax is supported" @@ -242,7 +245,7 @@ msgstr "Сохранить настройки" msgid "About {0}" msgstr "" -msgid "Home to {0} users" +msgid "Home to {0} people" msgstr "" msgid "Who wrote {0} articles" @@ -387,7 +390,7 @@ msgid "Subscribe" msgstr "" #, fuzzy -msgid "{0}'s subscriptions'" +msgid "{0}'s subscriptions" msgstr "Описание" #, fuzzy @@ -411,7 +414,7 @@ msgid "Plume is a decentralized blogging engine." msgstr "Plume это децентрализованный движок для блоггинга." #, fuzzy -msgid "Authors can manage various blogs, each as an unique website." +msgid "Authors can manage multiple blogs, each as its own website." msgstr "Авторы могут управлять различными блогами с одного сайта." #, fuzzy @@ -422,9 +425,6 @@ msgstr "" "Статьи также видны на других сайтах Plume и вы можете взаимодействовать с " "ними напрямую из других платформ, таких как Mastodon." -msgid "Home to {0} people" -msgstr "" - msgid "Read the detailed rules" msgstr "Прочитать подробные правила" @@ -652,7 +652,7 @@ msgstr "" #, fuzzy msgid "One boost" -msgid_plural "{0} boost" +msgid_plural "{0} boosts" msgstr[0] "Одно продвижение" msgstr[1] "{0} продвижения" msgstr[2] "{0} продвижений" @@ -852,6 +852,10 @@ msgstr "" msgid "Use as an avatar" msgstr "Использовать как аватар" +#, fuzzy +#~ msgid "Short description - byline" +#~ msgstr "Краткое описание" + #, fuzzy #~ msgid "You need to be logged in order to create a new blog" #~ msgstr "Вы должны войти чтобы написать новый пост" @@ -1033,9 +1037,6 @@ msgstr "Использовать как аватар" #~ msgid "Invalid name" #~ msgstr "Неправильное имя" -#~ msgid "A blog with the same name already exists." -#~ msgstr "Блог с таким же названием уже существует." - #~ msgid "Your comment can't be empty" #~ msgstr "Ваш комментарий не может быть пустым" diff --git a/src/api/mod.rs b/src/api/mod.rs index bc83ff99..410fdaa4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,7 +7,7 @@ use rocket_contrib::json::Json; use serde_json; use plume_common::utils::random_hex; -use plume_models::{api_tokens::*, apps::App, db_conn::DbConn, users::User, Error}; +use plume_models::{api_tokens::*, apps::App, users::User, Error, PlumeRocket}; #[derive(Debug)] pub struct ApiError(Error); @@ -47,13 +47,17 @@ pub struct OAuthRequest { } #[get("/oauth2?")] -pub fn oauth(query: Form, conn: DbConn) -> Result, ApiError> { - let app = App::find_by_client_id(&*conn, &query.client_id)?; +pub fn oauth( + query: Form, + rockets: PlumeRocket, +) -> Result, ApiError> { + let conn = &*rockets.conn; + let app = App::find_by_client_id(conn, &query.client_id)?; if app.client_secret == query.client_secret { - if let Ok(user) = User::find_by_fqn(&*conn, &query.username) { + if let Ok(user) = User::find_by_fqn(&rockets, &query.username) { if user.auth(&query.password) { let token = ApiToken::insert( - &*conn, + conn, NewApiToken { app_id: app.id, user_id: user.id, @@ -73,7 +77,7 @@ pub fn oauth(query: Form, conn: DbConn) -> Result")] pub fn get( id: i32, - conn: DbConn, - worker: Worker, auth: Option>, - search: Searcher, + mut rockets: PlumeRocket, ) -> Json { - let post = , - )>>::get(&(&*conn, &worker, &search, auth.map(|a| a.0.user_id)), id) - .ok(); + rockets.user = auth.and_then(|a| User::get(&*rockets.conn, a.0.user_id).ok()); + let post = >::get(&rockets, id).ok(); Json(json!(post)) } #[get("/posts")] pub fn list( - conn: DbConn, uri: &Origin, - worker: Worker, auth: Option>, - search: Searcher, + mut rockets: PlumeRocket, ) -> Json { + rockets.user = auth.and_then(|a| User::get(&*rockets.conn, a.0.user_id).ok()); let query: PostEndpoint = serde_qs::from_str(uri.query().unwrap_or("")).expect("api::list: invalid query error"); - let post = , - )>>::list( - &(&*conn, &worker, &search, auth.map(|a| a.0.user_id)), - query, - ); + let post = >::list(&rockets, query); Json(json!(post)) } #[post("/posts", data = "")] pub fn create( - conn: DbConn, - payload: Json, - worker: Worker, auth: Authorization, - search: Searcher, + payload: Json, + mut rockets: PlumeRocket, ) -> Json { - let new_post = , - )>>::create( - &(&*conn, &worker, &search, Some(auth.0.user_id)), - (*payload).clone(), - ); + rockets.user = User::get(&*rockets.conn, auth.0.user_id).ok(); + let new_post = >::create(&rockets, (*payload).clone()); Json(new_post.map(|p| json!(p)).unwrap_or_else(|e| { json!({ "error": "Invalid data, couldn't create new post", diff --git a/src/inbox.rs b/src/inbox.rs index e61e2801..451b1db4 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -1,171 +1,68 @@ -#![warn(clippy::too_many_arguments)] -use activitypub::{ - activity::{Announce, Create, Delete, Follow as FollowAct, Like, Undo, Update}, - object::Tombstone, -}; -use failure::Error; -use rocket::{data::*, http::Status, Outcome::*, Request}; -use rocket_contrib::json::*; -use serde::Deserialize; -use serde_json; - -use std::io::Read; - use plume_common::activity_pub::{ - inbox::{Deletable, FromActivity, InboxError, Notify}, + inbox::FromId, request::Digest, - Id, + sign::{verify_http_headers, Signable}, }; use plume_models::{ - comments::Comment, follows::Follow, instance::Instance, likes, posts::Post, reshares::Reshare, - search::Searcher, users::User, Connection, + headers::Headers, inbox::inbox, instance::Instance, users::User, Error, PlumeRocket, }; +use rocket::{data::*, http::Status, response::status, Outcome::*, Request}; +use rocket_contrib::json::*; +use serde::Deserialize; +use std::io::Read; -pub trait Inbox { - fn received( - &self, - conn: &Connection, - searcher: &Searcher, - act: serde_json::Value, - ) -> Result<(), Error> { - let actor_id = Id::new(act["actor"].as_str().unwrap_or_else(|| { - act["actor"]["id"] - .as_str() - .expect("Inbox::received: actor_id missing error") - })); - match act["type"].as_str() { - Some(t) => match t { - "Announce" => { - Reshare::from_activity(conn, serde_json::from_value(act.clone())?, actor_id) - .expect("Inbox::received: Announce error");; +pub fn handle_incoming( + rockets: PlumeRocket, + data: SignedJson, + headers: Headers, +) -> Result> { + let conn = &*rockets.conn; + let act = data.1.into_inner(); + let sig = data.0; + + let activity = act.clone(); + let actor_id = activity["actor"] + .as_str() + .or_else(|| activity["actor"]["id"].as_str()) + .ok_or(status::BadRequest(Some("Missing actor id for activity")))?; + + let actor = + User::from_id(&rockets, actor_id, None).expect("instance::shared_inbox: user error"); + if !verify_http_headers(&actor, &headers.0, &sig).is_secure() && !act.clone().verify(&actor) { + // maybe we just know an old key? + actor + .refetch(conn) + .and_then(|_| User::get(conn, actor.id)) + .and_then(|u| { + if verify_http_headers(&u, &headers.0, &sig).is_secure() || act.clone().verify(&u) { Ok(()) + } else { + Err(Error::Signature) } - "Create" => { - let act: Create = serde_json::from_value(act.clone())?; - if Post::try_from_activity(&(conn, searcher), act.clone()).is_ok() - || Comment::try_from_activity(conn, act).is_ok() - { - Ok(()) - } else { - Err(InboxError::InvalidType)? - } - } - "Delete" => { - let act: Delete = serde_json::from_value(act.clone())?; - Post::delete_id( - &act.delete_props - .object_object::()? - .object_props - .id_string()?, - actor_id.as_ref(), - &(conn, searcher), - ) - .ok(); - Comment::delete_id( - &act.delete_props - .object_object::()? - .object_props - .id_string()?, - actor_id.as_ref(), - conn, - ) - .ok(); - Ok(()) - } - "Follow" => { - Follow::from_activity(conn, serde_json::from_value(act.clone())?, actor_id) - .and_then(|f| f.notify(conn)) - .expect("Inbox::received: follow from activity error");; - Ok(()) - } - "Like" => { - likes::Like::from_activity( - conn, - serde_json::from_value(act.clone())?, - actor_id, - ) - .expect("Inbox::received: like from activity error");; - Ok(()) - } - "Undo" => { - let act: Undo = serde_json::from_value(act.clone())?; - if let Some(t) = act.undo_props.object["type"].as_str() { - match t { - "Like" => { - likes::Like::delete_id( - &act.undo_props - .object_object::()? - .object_props - .id_string()?, - actor_id.as_ref(), - conn, - ) - .expect("Inbox::received: undo like fail");; - Ok(()) - } - "Announce" => { - Reshare::delete_id( - &act.undo_props - .object_object::()? - .object_props - .id_string()?, - actor_id.as_ref(), - conn, - ) - .expect("Inbox::received: undo reshare fail");; - Ok(()) - } - "Follow" => { - Follow::delete_id( - &act.undo_props - .object_object::()? - .object_props - .id_string()?, - actor_id.as_ref(), - conn, - ) - .expect("Inbox::received: undo follow error");; - Ok(()) - } - _ => Err(InboxError::CantUndo)?, - } - } else { - let link = - act.undo_props.object.as_str().expect( - "Inbox::received: undo doesn't contain a type and isn't Link", - ); - if let Ok(like) = likes::Like::find_by_ap_url(conn, link) { - likes::Like::delete_id(&like.ap_url, actor_id.as_ref(), conn) - .expect("Inbox::received: delete Like error"); - Ok(()) - } else if let Ok(reshare) = Reshare::find_by_ap_url(conn, link) { - Reshare::delete_id(&reshare.ap_url, actor_id.as_ref(), conn) - .expect("Inbox::received: delete Announce error"); - Ok(()) - } else if let Ok(follow) = Follow::find_by_ap_url(conn, link) { - Follow::delete_id(&follow.ap_url, actor_id.as_ref(), conn) - .expect("Inbox::received: delete Follow error"); - Ok(()) - } else { - Err(InboxError::NoType)? - } - } - } - "Update" => { - let act: Update = serde_json::from_value(act.clone())?; - Post::handle_update(conn, &act.update_props.object_object()?, searcher) - .expect("Inbox::received: post update error"); - Ok(()) - } - _ => Err(InboxError::InvalidType)?, - }, - None => Err(InboxError::NoType)?, - } + }) + .map_err(|_| { + println!( + "Rejected invalid activity supposedly from {}, with headers {:?}", + actor.username, headers.0 + ); + status::BadRequest(Some("Invalid signature")) + })?; } -} -impl Inbox for Instance {} -impl Inbox for User {} + if Instance::is_blocked(conn, actor_id) + .map_err(|_| status::BadRequest(Some("Can't tell if instance is blocked")))? + { + return Ok(String::new()); + } + + Ok(match inbox(&rockets, act) { + Ok(_) => String::new(), + Err(e) => { + println!("Shared inbox error: {:?}", e); + format!("Error: {:?}", e) + } + }) +} const JSON_LIMIT: u64 = 1 << 20; diff --git a/src/main.rs b/src/main.rs index 659fc6f5..43795896 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,6 @@ extern crate colored; extern crate ctrlc; extern crate diesel; extern crate dotenv; -extern crate failure; #[macro_use] extern crate gettext_macros; extern crate gettext_utils; @@ -66,7 +65,6 @@ include!(concat!(env!("OUT_DIR"), "/templates.rs")); compile_i18n!(); -type Worker<'a> = State<'a, ScheduledThreadPool>; type Searcher<'a> = State<'a, Arc>; /// Initializes a database pool. @@ -241,7 +239,7 @@ Then try to restart Plume .manage(Arc::new(Mutex::new(mail))) .manage::>>>(Arc::new(Mutex::new(vec![]))) .manage(dbpool) - .manage(workpool) + .manage(Arc::new(workpool)) .manage(searcher) .manage(include_i18n!()) .attach( diff --git a/src/routes/blogs.rs b/src/routes/blogs.rs index 588450ea..8615b85d 100644 --- a/src/routes/blogs.rs +++ b/src/routes/blogs.rs @@ -13,17 +13,17 @@ use validator::{Validate, ValidationError, ValidationErrors}; use plume_common::activity_pub::{ActivityStream, ApRequest}; use plume_common::utils; use plume_models::{ - blog_authors::*, blogs::*, db_conn::DbConn, instance::Instance, medias::*, posts::Post, - safe_string::SafeString, users::User, Connection, + blog_authors::*, blogs::*, instance::Instance, medias::*, posts::Post, safe_string::SafeString, + users::User, Connection, PlumeRocket, }; -use routes::{errors::ErrorPage, Page, PlumeRocket}; +use routes::{errors::ErrorPage, Page}; use template_utils::Ructe; #[get("/~/?", rank = 2)] pub fn details(name: String, page: Option, rockets: PlumeRocket) -> Result { let page = page.unwrap_or_default(); - let conn = rockets.conn; - let blog = Blog::find_by_fqn(&*conn, &name)?; + let conn = &*rockets.conn; + let blog = Blog::find_by_fqn(&rockets, &name)?; let posts = Post::blog_page(&*conn, &blog, page.limits())?; let articles_count = Post::count_for_blog(&*conn, &blog)?; let authors = &blog.list_authors(&*conn)?; @@ -43,18 +43,18 @@ pub fn details(name: String, page: Option, rockets: PlumeRocket) -> Result #[get("/~/", rank = 1)] pub fn activity_details( name: String, - conn: DbConn, + rockets: PlumeRocket, _ap: ApRequest, ) -> Option> { - let blog = Blog::find_by_fqn(&*conn, &name).ok()?; - Some(ActivityStream::new(blog.to_activity(&*conn).ok()?)) + let blog = Blog::find_by_fqn(&rockets, &name).ok()?; + Some(ActivityStream::new(blog.to_activity(&*rockets.conn).ok()?)) } #[get("/blogs/new")] pub fn new(rockets: PlumeRocket) -> Ructe { let user = rockets.user.unwrap(); let intl = rockets.intl; - let conn = rockets.conn; + let conn = &*rockets.conn; render!(blogs::new( &(&*conn, &intl.catalog, Some(user)), @@ -92,20 +92,23 @@ fn valid_slug(title: &str) -> Result<(), ValidationError> { #[post("/blogs/new", data = "
")] pub fn create(form: LenientForm, rockets: PlumeRocket) -> Result { let slug = utils::make_actor_id(&form.title); - let conn = rockets.conn; - let intl = rockets.intl; - let user = rockets.user.unwrap(); + let conn = &*rockets.conn; + let intl = &rockets.intl.catalog; + let user = rockets.user.clone().unwrap(); let mut errors = match form.validate() { Ok(_) => ValidationErrors::new(), Err(e) => e, }; - if Blog::find_by_fqn(&*conn, &slug).is_ok() { + if Blog::find_by_fqn(&rockets, &slug).is_ok() { errors.add( "title", ValidationError { code: Cow::from("existing_slug"), - message: Some(Cow::from("A blog with the same name already exists.")), + message: Some(Cow::from(i18n!( + intl, + "A blog with the same name already exists." + ))), params: HashMap::new(), }, ); @@ -139,7 +142,7 @@ pub fn create(form: LenientForm, rockets: PlumeRocket) -> Result, rockets: PlumeRocket) -> Result/delete")] pub fn delete(name: String, rockets: PlumeRocket) -> Result { - let conn = rockets.conn; - let blog = Blog::find_by_fqn(&*conn, &name).expect("blog::delete: blog not found"); + let conn = &*rockets.conn; + let blog = Blog::find_by_fqn(&rockets, &name).expect("blog::delete: blog not found"); let user = rockets.user; let intl = rockets.intl; let searcher = rockets.searcher; @@ -181,22 +184,21 @@ pub struct EditForm { } #[get("/~//edit")] -pub fn edit( - conn: DbConn, - name: String, - user: Option, - intl: I18n, -) -> Result { - let blog = Blog::find_by_fqn(&*conn, &name)?; - if user +pub fn edit(name: String, rockets: PlumeRocket) -> Result { + let conn = &*rockets.conn; + let blog = Blog::find_by_fqn(&rockets, &name)?; + if rockets + .user .clone() .and_then(|u| u.is_author_in(&*conn, &blog).ok()) .unwrap_or(false) { - let user = user.expect("blogs::edit: User was None while it shouldn't"); + let user = rockets + .user + .expect("blogs::edit: User was None while it shouldn't"); let medias = Media::for_user(&*conn, user.id).expect("Couldn't list media"); Ok(render!(blogs::edit( - &(&*conn, &intl.catalog, Some(user)), + &(&*conn, &rockets.intl.catalog, Some(user)), &blog, medias, &EditForm { @@ -210,8 +212,11 @@ pub fn edit( } else { // TODO actually return 403 error code Ok(render!(errors::not_authorized( - &(&*conn, &intl.catalog, user), - i18n!(intl.catalog, "You are not allowed to edit this blog.") + &(&*conn, &rockets.intl.catalog, rockets.user), + i18n!( + rockets.intl.catalog, + "You are not allowed to edit this blog." + ) ))) } } @@ -227,19 +232,23 @@ fn check_media(conn: &Connection, id: i32, user: &User) -> bool { #[put("/~//edit", data = "")] pub fn update( - conn: DbConn, name: String, - user: Option, - intl: I18n, form: LenientForm, + rockets: PlumeRocket, ) -> Result { - let mut blog = Blog::find_by_fqn(&*conn, &name).expect("blog::update: blog not found"); - if user + let conn = &*rockets.conn; + let intl = &rockets.intl.catalog; + let mut blog = Blog::find_by_fqn(&rockets, &name).expect("blog::update: blog not found"); + if rockets + .user .clone() .and_then(|u| u.is_author_in(&*conn, &blog).ok()) .unwrap_or(false) { - let user = user.expect("blogs::edit: User was None while it shouldn't"); + let user = rockets + .user + .clone() + .expect("blogs::edit: User was None while it shouldn't"); form.validate() .and_then(|_| { if let Some(icon) = form.icon { @@ -250,7 +259,7 @@ pub fn update( ValidationError { code: Cow::from("icon"), message: Some(Cow::from(i18n!( - intl.catalog, + intl, "You can't use this media as a blog icon." ))), params: HashMap::new(), @@ -268,7 +277,7 @@ pub fn update( ValidationError { code: Cow::from("banner"), message: Some(Cow::from(i18n!( - intl.catalog, + intl, "You can't use this media as a blog banner." ))), params: HashMap::new(), @@ -304,7 +313,7 @@ pub fn update( .map_err(|err| { let medias = Media::for_user(&*conn, user.id).expect("Couldn't list media"); render!(blogs::edit( - &(&*conn, &intl.catalog, Some(user)), + &(&*conn, intl, Some(user)), &blog, medias, &*form, @@ -314,21 +323,25 @@ pub fn update( } else { // TODO actually return 403 error code Err(render!(errors::not_authorized( - &(&*conn, &intl.catalog, user), - i18n!(intl.catalog, "You are not allowed to edit this blog.") + &(&*conn, &rockets.intl.catalog, rockets.user), + i18n!( + rockets.intl.catalog, + "You are not allowed to edit this blog." + ) ))) } } #[get("/~//outbox")] -pub fn outbox(name: String, conn: DbConn) -> Option> { - let blog = Blog::find_by_fqn(&*conn, &name).ok()?; - Some(blog.outbox(&*conn).ok()?) +pub fn outbox(name: String, rockets: PlumeRocket) -> Option> { + let blog = Blog::find_by_fqn(&rockets, &name).ok()?; + Some(blog.outbox(&*rockets.conn).ok()?) } #[get("/~//atom.xml")] -pub fn atom_feed(name: String, conn: DbConn) -> Option> { - let blog = Blog::find_by_fqn(&*conn, &name).ok()?; +pub fn atom_feed(name: String, rockets: PlumeRocket) -> Option> { + let blog = Blog::find_by_fqn(&rockets, &name).ok()?; + let conn = &*rockets.conn; let feed = FeedBuilder::default() .title(blog.title.clone()) .id(Instance::get_local(&*conn) diff --git a/src/routes/comments.rs b/src/routes/comments.rs index 24e679b9..6fc13107 100644 --- a/src/routes/comments.rs +++ b/src/routes/comments.rs @@ -1,25 +1,19 @@ use activitypub::object::Note; use rocket::{request::LenientForm, response::Redirect}; -use rocket_i18n::I18n; use template_utils::Ructe; use validator::Validate; use std::time::Duration; use plume_common::{ - activity_pub::{ - broadcast, - inbox::{Deletable, Notify}, - ActivityStream, ApRequest, - }, + activity_pub::{broadcast, ActivityStream, ApRequest}, utils, }; use plume_models::{ - blogs::Blog, comments::*, db_conn::DbConn, instance::Instance, medias::Media, - mentions::Mention, posts::Post, safe_string::SafeString, tags::Tag, users::User, + blogs::Blog, comments::*, inbox::inbox, instance::Instance, medias::Media, mentions::Mention, + posts::Post, safe_string::SafeString, tags::Tag, users::User, Error, PlumeRocket, }; use routes::errors::ErrorPage; -use Worker; #[derive(Default, FromForm, Debug, Validate)] pub struct NewCommentForm { @@ -35,11 +29,10 @@ pub fn create( slug: String, form: LenientForm, user: User, - conn: DbConn, - worker: Worker, - intl: I18n, + rockets: PlumeRocket, ) -> Result { - let blog = Blog::find_by_fqn(&*conn, &blog_name).expect("comments::create: blog error"); + let conn = &*rockets.conn; + let blog = Blog::find_by_fqn(&rockets, &blog_name).expect("comments::create: blog error"); let post = Post::find_by_slug(&*conn, &slug, blog.id).expect("comments::create: post error"); form.validate() .map(|_| { @@ -67,14 +60,14 @@ pub fn create( .expect("comments::create: insert error"); comm.notify(&*conn).expect("comments::create: notify error"); let new_comment = comm - .create_activity(&*conn) + .create_activity(&rockets) .expect("comments::create: activity error"); // save mentions for ment in mentions { Mention::from_activity( &*conn, - &Mention::build_activity(&*conn, &ment) + &Mention::build_activity(&rockets, &ment) .expect("comments::create: build mention error"), comm.id, false, @@ -86,7 +79,9 @@ pub fn create( // federate let dest = User::one_by_instance(&*conn).expect("comments::create: dest error"); let user_clone = user.clone(); - worker.execute(move || broadcast(&user_clone, new_comment, dest)); + rockets + .worker + .execute(move || broadcast(&user_clone, new_comment, dest)); Redirect::to( uri!(super::posts::details: blog = blog_name, slug = slug, responding_to = _), @@ -102,7 +97,7 @@ pub fn create( .and_then(|r| Comment::get(&*conn, r).ok()); render!(posts::details( - &(&*conn, &intl.catalog, Some(user.clone())), + &(&*conn, &rockets.intl.catalog, Some(user.clone())), post.clone(), blog, &*form, @@ -138,19 +133,28 @@ pub fn delete( slug: String, id: i32, user: User, - conn: DbConn, - worker: Worker, + rockets: PlumeRocket, ) -> Result { - if let Ok(comment) = Comment::get(&*conn, id) { + if let Ok(comment) = Comment::get(&*rockets.conn, id) { if comment.author_id == user.id { - let dest = User::one_by_instance(&*conn)?; - let delete_activity = comment.delete(&*conn)?; + let dest = User::one_by_instance(&*rockets.conn)?; + let delete_activity = comment.build_delete(&*rockets.conn)?; + inbox( + &rockets, + serde_json::to_value(&delete_activity).map_err(Error::from)?, + )?; + let user_c = user.clone(); - worker.execute(move || broadcast(&user_c, delete_activity, dest)); - worker.execute_after(Duration::from_secs(10 * 60), move || { - user.rotate_keypair(&conn) - .expect("Failed to rotate keypair"); - }); + rockets + .worker + .execute(move || broadcast(&user_c, delete_activity, dest)); + let conn = rockets.conn; + rockets + .worker + .execute_after(Duration::from_secs(10 * 60), move || { + user.rotate_keypair(&conn) + .expect("Failed to rotate keypair"); + }); } } Ok(Redirect::to( @@ -164,10 +168,10 @@ pub fn activity_pub( _slug: String, id: i32, _ap: ApRequest, - conn: DbConn, + rockets: PlumeRocket, ) -> Option> { - Comment::get(&*conn, id) - .and_then(|c| c.to_activity(&*conn)) + Comment::get(&*rockets.conn, id) + .and_then(|c| c.to_activity(&rockets)) .ok() .map(ActivityStream::new) } diff --git a/src/routes/instance.rs b/src/routes/instance.rs index c68ec717..e002fe38 100644 --- a/src/routes/instance.rs +++ b/src/routes/instance.rs @@ -7,11 +7,10 @@ use rocket_i18n::I18n; use serde_json; use validator::{Validate, ValidationErrors}; -use inbox::{Inbox, SignedJson}; -use plume_common::activity_pub::sign::{verify_http_headers, Signable}; +use inbox; use plume_models::{ admin::Admin, comments::Comment, db_conn::DbConn, headers::Headers, instance::*, posts::Post, - safe_string::SafeString, users::User, Error, CONFIG, + safe_string::SafeString, users::User, Error, PlumeRocket, CONFIG, }; use routes::{errors::ErrorPage, rocket_uri_macro_static_files, Page}; use template_utils::Ructe; @@ -211,56 +210,11 @@ pub fn ban( #[post("/inbox", data = "")] pub fn shared_inbox( - conn: DbConn, - data: SignedJson, + rockets: PlumeRocket, + data: inbox::SignedJson, headers: Headers, - searcher: Searcher, ) -> Result> { - let act = data.1.into_inner(); - let sig = data.0; - - let activity = act.clone(); - let actor_id = activity["actor"] - .as_str() - .or_else(|| activity["actor"]["id"].as_str()) - .ok_or(status::BadRequest(Some("Missing actor id for activity")))?; - - let actor = User::from_url(&conn, actor_id).expect("instance::shared_inbox: user error"); - if !verify_http_headers(&actor, &headers.0, &sig).is_secure() && !act.clone().verify(&actor) { - // maybe we just know an old key? - actor - .refetch(&conn) - .and_then(|_| User::get(&conn, actor.id)) - .and_then(|u| { - if verify_http_headers(&u, &headers.0, &sig).is_secure() || act.clone().verify(&u) { - Ok(()) - } else { - Err(Error::Signature) - } - }) - .map_err(|_| { - println!( - "Rejected invalid activity supposedly from {}, with headers {:?}", - actor.username, headers.0 - ); - status::BadRequest(Some("Invalid signature")) - })?; - } - - if Instance::is_blocked(&*conn, actor_id) - .map_err(|_| status::BadRequest(Some("Can't tell if instance is blocked")))? - { - return Ok(String::new()); - } - let instance = Instance::get_local(&*conn) - .expect("instance::shared_inbox: local instance not found error"); - Ok(match instance.received(&*conn, &searcher, act) { - Ok(_) => String::new(), - Err(e) => { - println!("Shared inbox error: {}\n{}", e.as_fail(), e.backtrace()); - format!("Error: {}", e.as_fail()) - } - }) + inbox::handle_incoming(rockets, data, headers) } #[get("/nodeinfo/")] diff --git a/src/routes/likes.rs b/src/routes/likes.rs index a0f7e337..bf1b7747 100644 --- a/src/routes/likes.rs +++ b/src/routes/likes.rs @@ -1,24 +1,22 @@ use rocket::response::{Flash, Redirect}; use rocket_i18n::I18n; -use plume_common::activity_pub::{ - broadcast, - inbox::{Deletable, Notify}, -}; +use plume_common::activity_pub::broadcast; use plume_common::utils; -use plume_models::{blogs::Blog, db_conn::DbConn, likes, posts::Post, users::User}; +use plume_models::{ + blogs::Blog, inbox::inbox, likes, posts::Post, users::User, Error, PlumeRocket, +}; use routes::errors::ErrorPage; -use Worker; #[post("/~///like")] pub fn create( blog: String, slug: String, user: User, - conn: DbConn, - worker: Worker, + rockets: PlumeRocket, ) -> Result { - let b = Blog::find_by_fqn(&*conn, &blog)?; + let conn = &*rockets.conn; + let b = Blog::find_by_fqn(&rockets, &blog)?; let post = Post::find_by_slug(&*conn, &slug, b.id)?; if !user.has_liked(&*conn, &post)? { @@ -27,12 +25,19 @@ pub fn create( let dest = User::one_by_instance(&*conn)?; let act = like.to_activity(&*conn)?; - worker.execute(move || broadcast(&user, act, dest)); + rockets.worker.execute(move || broadcast(&user, act, dest)); } else { let like = likes::Like::find_by_user_on_post(&*conn, user.id, post.id)?; - let delete_act = like.delete(&*conn)?; + let delete_act = like.build_undo(&*conn)?; + inbox( + &rockets, + serde_json::to_value(&delete_act).map_err(Error::from)?, + )?; + let dest = User::one_by_instance(&*conn)?; - worker.execute(move || broadcast(&user, delete_act, dest)); + rockets + .worker + .execute(move || broadcast(&user, delete_act, dest)); } Ok(Redirect::to( diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 6e17736c..36ab73cd 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -10,40 +10,9 @@ use rocket::{ response::NamedFile, Outcome, }; -use rocket_i18n::I18n; use std::path::{Path, PathBuf}; -use plume_models::{db_conn::DbConn, posts::Post, users::User, Connection}; - -use Searcher; -use Worker; - -pub struct PlumeRocket<'a> { - conn: DbConn, - intl: I18n, - user: Option, - searcher: Searcher<'a>, - worker: Worker<'a>, -} - -impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket<'a> { - type Error = (); - - fn from_request(request: &'a Request<'r>) -> request::Outcome, ()> { - let conn = request.guard::()?; - let intl = request.guard::()?; - let user = request.guard::().succeeded(); - let worker = request.guard::()?; - let searcher = request.guard::()?; - rocket::Outcome::Success(PlumeRocket { - conn, - intl, - user, - worker, - searcher, - }) - } -} +use plume_models::{posts::Post, Connection}; const ITEMS_PER_PAGE: i32 = 12; diff --git a/src/routes/posts.rs b/src/routes/posts.rs index c6264711..73097378 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -10,12 +10,12 @@ use std::{ }; use validator::{Validate, ValidationError, ValidationErrors}; -use plume_common::activity_pub::{broadcast, inbox::Deletable, ActivityStream, ApRequest}; +use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest}; use plume_common::utils; use plume_models::{ blogs::*, comments::{Comment, CommentTree}, - db_conn::DbConn, + inbox::inbox, instance::Instance, medias::Media, mentions::Mention, @@ -24,20 +24,21 @@ use plume_models::{ safe_string::SafeString, tags::*, users::User, + Error, PlumeRocket, }; -use routes::{comments::NewCommentForm, errors::ErrorPage, ContentLen, PlumeRocket}; +use routes::{comments::NewCommentForm, errors::ErrorPage, ContentLen}; use template_utils::Ructe; #[get("/~//?", rank = 4)] pub fn details( blog: String, slug: String, - conn: DbConn, - user: Option, responding_to: Option, - intl: I18n, + rockets: PlumeRocket, ) -> Result { - let blog = Blog::find_by_fqn(&*conn, &blog)?; + let conn = &*rockets.conn; + let user = rockets.user.clone(); + let blog = Blog::find_by_fqn(&rockets, &blog)?; let post = Post::find_by_slug(&*conn, &slug, blog.id)?; if post.published || post @@ -50,7 +51,7 @@ pub fn details( let previous = responding_to.and_then(|r| Comment::get(&*conn, r).ok()); Ok(render!(posts::details( - &(&*conn, &intl.catalog, user.clone()), + &(&*conn, &rockets.intl.catalog, user.clone()), post.clone(), blog, &NewCommentForm { @@ -88,8 +89,8 @@ pub fn details( ))) } else { Ok(render!(errors::not_authorized( - &(&*conn, &intl.catalog, user.clone()), - i18n!(intl.catalog, "This post isn't published yet.") + &(&*conn, &rockets.intl.catalog, user.clone()), + i18n!(rockets.intl.catalog, "This post isn't published yet.") ))) } } @@ -98,10 +99,11 @@ pub fn details( pub fn activity_details( blog: String, slug: String, - conn: DbConn, _ap: ApRequest, + rockets: PlumeRocket, ) -> Result, Option> { - let blog = Blog::find_by_fqn(&*conn, &blog).map_err(|_| None)?; + let conn = &*rockets.conn; + let blog = Blog::find_by_fqn(&rockets, &blog).map_err(|_| None)?; let post = Post::find_by_slug(&*conn, &slug, blog.id).map_err(|_| None)?; if post.published { Ok(ActivityStream::new( @@ -126,8 +128,8 @@ pub fn new_auth(blog: String, i18n: I18n) -> Flash { #[get("/~//new", rank = 1)] pub fn new(blog: String, cl: ContentLen, rockets: PlumeRocket) -> Result { - let conn = rockets.conn; - let b = Blog::find_by_fqn(&*conn, &blog)?; + let conn = &*rockets.conn; + let b = Blog::find_by_fqn(&rockets, &blog)?; let user = rockets.user.unwrap(); let intl = rockets.intl; @@ -164,16 +166,16 @@ pub fn edit( cl: ContentLen, rockets: PlumeRocket, ) -> Result { - let conn = rockets.conn; - let intl = rockets.intl; - let b = Blog::find_by_fqn(&*conn, &blog)?; + let conn = &*rockets.conn; + let intl = &rockets.intl.catalog; + let b = Blog::find_by_fqn(&rockets, &blog)?; let post = Post::find_by_slug(&*conn, &slug, b.id)?; let user = rockets.user.unwrap(); if !user.is_author_in(&*conn, &b)? { return Ok(render!(errors::not_authorized( - &(&*conn, &intl.catalog, Some(user)), - i18n!(intl.catalog, "You are not an author of this blog.") + &(&*conn, intl, Some(user)), + i18n!(intl, "You are not an author of this blog.") ))); } @@ -186,8 +188,8 @@ pub fn edit( let medias = Media::for_user(&*conn, user.id)?; let title = post.title.clone(); Ok(render!(posts::new( - &(&*conn, &intl.catalog, Some(user)), - i18n!(intl.catalog, "Edit {0}"; &title), + &(&*conn, intl, Some(user)), + i18n!(intl, "Edit {0}"; &title), b, true, &NewPostForm { @@ -219,12 +221,12 @@ pub fn update( form: LenientForm, rockets: PlumeRocket, ) -> Result { - let conn = rockets.conn; - let b = Blog::find_by_fqn(&*conn, &blog).expect("post::update: blog error"); + let conn = &*rockets.conn; + let b = Blog::find_by_fqn(&rockets, &blog).expect("post::update: blog error"); let mut post = Post::find_by_slug(&*conn, &slug, b.id).expect("post::update: find by slug error"); - let user = rockets.user.unwrap(); - let intl = rockets.intl; + let user = rockets.user.clone().unwrap(); + let intl = &rockets.intl.catalog; let new_slug = if !post.published { form.title.to_string().to_kebab_case() @@ -282,8 +284,6 @@ pub fn update( false }; - let searcher = rockets.searcher; - let worker = rockets.worker; post.slug = new_slug.clone(); post.title = form.title.clone(); post.subtitle = form.subtitle.clone(); @@ -291,7 +291,7 @@ pub fn update( post.source = form.content.clone(); post.license = form.license.clone(); post.cover_id = form.cover; - post.update(&*conn, &searcher) + post.update(&*conn, &rockets.searcher) .expect("post::update: update error");; if post.published { @@ -299,7 +299,7 @@ pub fn update( &conn, mentions .into_iter() - .filter_map(|m| Mention::build_activity(&conn, &m).ok()) + .filter_map(|m| Mention::build_activity(&rockets, &m).ok()) .collect(), ) .expect("post::update: mentions error");; @@ -333,13 +333,13 @@ pub fn update( .create_activity(&conn) .expect("post::update: act error"); let dest = User::one_by_instance(&*conn).expect("post::update: dest error"); - worker.execute(move || broadcast(&user, act, dest)); + rockets.worker.execute(move || broadcast(&user, act, dest)); } else { let act = post .update_activity(&*conn) .expect("post::update: act error"); let dest = User::one_by_instance(&*conn).expect("posts::update: dest error"); - worker.execute(move || broadcast(&user, act, dest)); + rockets.worker.execute(move || broadcast(&user, act, dest)); } } @@ -350,8 +350,8 @@ pub fn update( } else { let medias = Media::for_user(&*conn, user.id).expect("posts:update: medias error"); Err(render!(posts::new( - &(&*conn, &intl.catalog, Some(user)), - i18n!(intl.catalog, "Edit {0}"; &form.title), + &(&*conn, intl, Some(user)), + i18n!(intl, "Edit {0}"; &form.title), b, true, &*form, @@ -394,10 +394,10 @@ pub fn create( cl: ContentLen, rockets: PlumeRocket, ) -> Result> { - let conn = rockets.conn; - let blog = Blog::find_by_fqn(&*conn, &blog_name).expect("post::create: blog error");; + let conn = &*rockets.conn; + let blog = Blog::find_by_fqn(&rockets, &blog_name).expect("post::create: blog error");; let slug = form.title.to_string().to_kebab_case(); - let user = rockets.user.unwrap(); + let user = rockets.user.clone().unwrap(); let mut errors = match form.validate() { Ok(_) => ValidationErrors::new(), @@ -440,7 +440,6 @@ pub fn create( )), ); - let searcher = rockets.searcher; let post = Post::insert( &*conn, NewPost { @@ -456,7 +455,7 @@ pub fn create( source: form.content.clone(), cover_id: form.cover, }, - &searcher, + &rockets.searcher, ) .expect("post::create: post save error"); @@ -502,7 +501,7 @@ pub fn create( for m in mentions { Mention::from_activity( &*conn, - &Mention::build_activity(&*conn, &m) + &Mention::build_activity(&rockets, &m) .expect("post::create: mention build error"), post.id, true, @@ -546,14 +545,13 @@ pub fn delete( slug: String, rockets: PlumeRocket, ) -> Result { - let conn = rockets.conn; - let user = rockets.user.unwrap(); - let post = Blog::find_by_fqn(&*conn, &blog_name) - .and_then(|blog| Post::find_by_slug(&*conn, &slug, blog.id)); + let user = rockets.user.clone().unwrap(); + let post = Blog::find_by_fqn(&rockets, &blog_name) + .and_then(|blog| Post::find_by_slug(&*rockets.conn, &slug, blog.id)); if let Ok(post) = post { if !post - .get_authors(&*conn)? + .get_authors(&*rockets.conn)? .into_iter() .any(|a| a.id == user.id) { @@ -562,18 +560,24 @@ pub fn delete( )); } - let searcher = rockets.searcher; - let worker = rockets.worker; + let dest = User::one_by_instance(&*rockets.conn)?; + let delete_activity = post.build_delete(&*rockets.conn)?; + inbox( + &rockets, + serde_json::to_value(&delete_activity).map_err(Error::from)?, + )?; - let dest = User::one_by_instance(&*conn)?; - let delete_activity = post.delete(&(&conn, &searcher))?; let user_c = user.clone(); - - worker.execute(move || broadcast(&user_c, delete_activity, dest)); - worker.execute_after(Duration::from_secs(10 * 60), move || { - user.rotate_keypair(&conn) - .expect("Failed to rotate keypair"); - }); + rockets + .worker + .execute(move || broadcast(&user_c, delete_activity, dest)); + let conn = rockets.conn; + rockets + .worker + .execute_after(Duration::from_secs(10 * 60), move || { + user.rotate_keypair(&*conn) + .expect("Failed to rotate keypair"); + }); Ok(Redirect::to( uri!(super::blogs::details: name = blog_name, page = _), diff --git a/src/routes/reshares.rs b/src/routes/reshares.rs index 15c94818..fc45227b 100644 --- a/src/routes/reshares.rs +++ b/src/routes/reshares.rs @@ -1,24 +1,22 @@ use rocket::response::{Flash, Redirect}; use rocket_i18n::I18n; -use plume_common::activity_pub::{ - broadcast, - inbox::{Deletable, Notify}, -}; +use plume_common::activity_pub::broadcast; use plume_common::utils; -use plume_models::{blogs::Blog, db_conn::DbConn, posts::Post, reshares::*, users::User}; +use plume_models::{ + blogs::Blog, inbox::inbox, posts::Post, reshares::*, users::User, Error, PlumeRocket, +}; use routes::errors::ErrorPage; -use Worker; #[post("/~///reshare")] pub fn create( blog: String, slug: String, user: User, - conn: DbConn, - worker: Worker, + rockets: PlumeRocket, ) -> Result { - let b = Blog::find_by_fqn(&*conn, &blog)?; + let conn = &*rockets.conn; + let b = Blog::find_by_fqn(&rockets, &blog)?; let post = Post::find_by_slug(&*conn, &slug, b.id)?; if !user.has_reshared(&*conn, &post)? { @@ -27,12 +25,19 @@ pub fn create( let dest = User::one_by_instance(&*conn)?; let act = reshare.to_activity(&*conn)?; - worker.execute(move || broadcast(&user, act, dest)); + rockets.worker.execute(move || broadcast(&user, act, dest)); } else { let reshare = Reshare::find_by_user_on_post(&*conn, user.id, post.id)?; - let delete_act = reshare.delete(&*conn)?; + let delete_act = reshare.build_undo(&*conn)?; + inbox( + &rockets, + serde_json::to_value(&delete_act).map_err(Error::from)?, + )?; + let dest = User::one_by_instance(&*conn)?; - worker.execute(move || broadcast(&user, delete_act, dest)); + rockets + .worker + .execute(move || broadcast(&user, delete_act, dest)); } Ok(Redirect::to( diff --git a/src/routes/session.rs b/src/routes/session.rs index f547b543..086aec1e 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -19,7 +19,7 @@ use mail::{build_mail, Mailer}; use plume_models::{ db_conn::DbConn, users::{User, AUTH_COOKIE}, - Error, CONFIG, + Error, PlumeRocket, CONFIG, }; use routes::errors::ErrorPage; @@ -43,14 +43,14 @@ pub struct LoginForm { #[post("/login", data = "")] pub fn create( - conn: DbConn, form: LenientForm, flash: Option, mut cookies: Cookies, - intl: I18n, + rockets: PlumeRocket, ) -> Result { + let conn = &*rockets.conn; let user = User::find_by_email(&*conn, &form.email_or_name) - .or_else(|_| User::find_by_fqn(&*conn, &form.email_or_name)); + .or_else(|_| User::find_by_fqn(&rockets, &form.email_or_name)); let mut errors = match form.validate() { Ok(_) => ValidationErrors::new(), Err(e) => e, @@ -98,7 +98,7 @@ pub fn create( .map(IntoOwned::into_owned) .map_err(|_| { render!(session::login( - &(&*conn, &intl.catalog, None), + &(&*conn, &rockets.intl.catalog, None), None, &*form, errors @@ -108,7 +108,7 @@ pub fn create( Ok(Redirect::to(uri)) } else { Err(render!(session::login( - &(&*conn, &intl.catalog, None), + &(&*conn, &rockets.intl.catalog, None), None, &*form, errors diff --git a/src/routes/user.rs b/src/routes/user.rs index af2da23d..4a884e87 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -10,29 +10,23 @@ use serde_json; use std::{borrow::Cow, collections::HashMap}; use validator::{Validate, ValidationError, ValidationErrors}; -use inbox::{Inbox, SignedJson}; -use plume_common::activity_pub::{ - broadcast, - inbox::{Deletable, FromActivity, Notify}, - sign::{verify_http_headers, Signable}, - ActivityStream, ApRequest, Id, IntoId, -}; +use inbox; +use plume_common::activity_pub::{broadcast, inbox::FromId, ActivityStream, ApRequest, Id}; use plume_common::utils; use plume_models::{ blogs::Blog, db_conn::DbConn, follows, headers::Headers, + inbox::inbox as local_inbox, instance::Instance, posts::{LicensedArticle, Post}, reshares::Reshare, users::*, - Error, + Error, PlumeRocket, }; -use routes::{errors::ErrorPage, Page, PlumeRocket}; +use routes::{errors::ErrorPage, Page}; use template_utils::Ructe; -use Searcher; -use Worker; #[get("/me")] pub fn me(user: Option) -> Result> { @@ -46,21 +40,19 @@ pub fn me(user: Option) -> Result> { pub fn details( name: String, rockets: PlumeRocket, - fetch_articles_conn: DbConn, - fetch_followers_conn: DbConn, + fetch_rockets: PlumeRocket, + fetch_followers_rockets: PlumeRocket, update_conn: DbConn, ) -> Result { - let conn = rockets.conn; - let user = User::find_by_fqn(&*conn, &name)?; + let conn = &*rockets.conn; + let user = User::find_by_fqn(&rockets, &name)?; let recents = Post::get_recents_for_author(&*conn, &user, 6)?; let reshares = Reshare::get_recents_for_author(&*conn, &user, 6)?; - let searcher = rockets.searcher; let worker = rockets.worker; if !user.get_instance(&*conn)?.local { // Fetch new articles let user_clone = user.clone(); - let searcher = searcher.clone(); worker.execute(move || { for create_act in user_clone .fetch_outbox::() @@ -68,12 +60,8 @@ pub fn details( { match create_act.create_props.object_object::() { Ok(article) => { - Post::from_activity( - &(&*fetch_articles_conn, &searcher), - article, - user_clone.clone().into_id(), - ) - .expect("Article from remote user couldn't be saved"); + Post::from_activity(&fetch_rockets, article) + .expect("Article from remote user couldn't be saved"); println!("Fetched article from remote user"); } Err(e) => println!("Error while fetching articles in background: {:?}", e), @@ -88,10 +76,10 @@ pub fn details( .fetch_followers_ids() .expect("Remote user: fetching followers error") { - let follower = User::from_url(&*fetch_followers_conn, &user_id) + let follower = User::from_id(&fetch_followers_rockets, &user_id, None) .expect("user::details: Couldn't fetch follower"); follows::Follow::insert( - &*fetch_followers_conn, + &*fetch_followers_rockets.conn, follows::NewFollow { follower_id: follower.id, following_id: user_clone.id, @@ -153,16 +141,19 @@ pub fn dashboard_auth(i18n: I18n) -> Flash { } #[post("/@//follow")] -pub fn follow( - name: String, - conn: DbConn, - user: User, - worker: Worker, -) -> Result { - let target = User::find_by_fqn(&*conn, &name)?; +pub fn follow(name: String, user: User, rockets: PlumeRocket) -> Result { + let conn = &*rockets.conn; + let target = User::find_by_fqn(&rockets, &name)?; if let Ok(follow) = follows::Follow::find(&*conn, user.id, target.id) { - let delete_act = follow.delete(&*conn)?; - worker.execute(move || broadcast(&user, delete_act, vec![target])); + let delete_act = follow.build_undo(&*conn)?; + local_inbox( + &rockets, + serde_json::to_value(&delete_act).map_err(Error::from)?, + )?; + + rockets + .worker + .execute(move || broadcast(&user, delete_act, vec![target])); } else { let f = follows::Follow::insert( &*conn, @@ -175,7 +166,9 @@ pub fn follow( f.notify(&*conn)?; let act = f.to_activity(&*conn)?; - worker.execute(move || broadcast(&user, act, vec![target])); + rockets + .worker + .execute(move || broadcast(&user, act, vec![target])); } Ok(Redirect::to(uri!(details: name = name))) } @@ -194,19 +187,19 @@ pub fn follow_auth(name: String, i18n: I18n) -> Flash { #[get("/@//followers?", rank = 2)] pub fn followers( name: String, - conn: DbConn, - account: Option, page: Option, - intl: I18n, + rockets: PlumeRocket, ) -> Result { + let conn = &*rockets.conn; let page = page.unwrap_or_default(); - let user = User::find_by_fqn(&*conn, &name)?; + let user = User::find_by_fqn(&rockets, &name)?; let followers_count = user.count_followers(&*conn)?; Ok(render!(users::followers( - &(&*conn, &intl.catalog, account.clone()), + &(&*conn, &rockets.intl.catalog, rockets.user.clone()), user.clone(), - account + rockets + .user .and_then(|x| x.is_following(&*conn, user.id).ok()) .unwrap_or(false), user.instance_id != Instance::get_local(&*conn)?.id, @@ -220,19 +213,19 @@ pub fn followers( #[get("/@//followed?", rank = 2)] pub fn followed( name: String, - conn: DbConn, - account: Option, page: Option, - intl: I18n, + rockets: PlumeRocket, ) -> Result { + let conn = &*rockets.conn; let page = page.unwrap_or_default(); - let user = User::find_by_fqn(&*conn, &name)?; + let user = User::find_by_fqn(&rockets, &name)?; let followed_count = user.count_followed(&*conn)?; Ok(render!(users::followed( - &(&*conn, &intl.catalog, account.clone()), + &(&*conn, &rockets.intl.catalog, rockets.user.clone()), user.clone(), - account + rockets + .user .and_then(|x| x.is_following(&*conn, user.id).ok()) .unwrap_or(false), user.instance_id != Instance::get_local(&*conn)?.id, @@ -246,11 +239,11 @@ pub fn followed( #[get("/@/", rank = 1)] pub fn activity_details( name: String, - conn: DbConn, + rockets: PlumeRocket, _ap: ApRequest, ) -> Option> { - let user = User::find_by_fqn(&*conn, &name).ok()?; - Some(ActivityStream::new(user.to_activity(&*conn).ok()?)) + let user = User::find_by_fqn(&rockets, &name).ok()?; + Some(ActivityStream::new(user.to_activity(&*rockets.conn).ok()?)) } #[get("/users/new")] @@ -329,14 +322,13 @@ pub fn update( #[post("/@//delete")] pub fn delete( name: String, - conn: DbConn, user: User, mut cookies: Cookies, - searcher: Searcher, + rockets: PlumeRocket, ) -> Result { - let account = User::find_by_fqn(&*conn, &name)?; + let account = User::find_by_fqn(&rockets, &name)?; if user.id == account.id { - account.delete(&*conn, &searcher)?; + account.delete(&*rockets.conn, &rockets.searcher)?; if let Some(cookie) = cookies.get_private(AUTH_COOKIE) { cookies.remove_private(cookie); @@ -439,76 +431,31 @@ pub fn create(conn: DbConn, form: LenientForm, intl: I18n) -> Resul } #[get("/@//outbox")] -pub fn outbox(name: String, conn: DbConn) -> Option> { - let user = User::find_by_fqn(&*conn, &name).ok()?; - user.outbox(&*conn).ok() +pub fn outbox(name: String, rockets: PlumeRocket) -> Option> { + let user = User::find_by_fqn(&rockets, &name).ok()?; + user.outbox(&*rockets.conn).ok() } #[post("/@//inbox", data = "")] pub fn inbox( name: String, - conn: DbConn, - data: SignedJson, + data: inbox::SignedJson, headers: Headers, - searcher: Searcher, -) -> Result>> { - let user = User::find_by_fqn(&*conn, &name).map_err(|_| None)?; - let act = data.1.into_inner(); - let sig = data.0; - - let activity = act.clone(); - let actor_id = activity["actor"] - .as_str() - .or_else(|| activity["actor"]["id"].as_str()) - .ok_or(Some(status::BadRequest(Some( - "Missing actor id for activity", - ))))?; - - let actor = User::from_url(&conn, actor_id).expect("user::inbox: user error"); - if !verify_http_headers(&actor, &headers.0, &sig).is_secure() && !act.clone().verify(&actor) { - // maybe we just know an old key? - actor - .refetch(&conn) - .and_then(|_| User::get(&conn, actor.id)) - .and_then(|actor| { - if verify_http_headers(&actor, &headers.0, &sig).is_secure() - || act.clone().verify(&actor) - { - Ok(()) - } else { - Err(Error::Signature) - } - }) - .map_err(|_| { - println!( - "Rejected invalid activity supposedly from {}, with headers {:?}", - actor.username, headers.0 - ); - status::BadRequest(Some("Invalid signature")) - })?; - } - - if Instance::is_blocked(&*conn, actor_id).map_err(|_| None)? { - return Ok(String::new()); - } - Ok(match user.received(&*conn, &searcher, act) { - Ok(_) => String::new(), - Err(e) => { - println!("User inbox error: {}\n{}", e.as_fail(), e.backtrace()); - format!("Error: {}", e.as_fail()) - } - }) + rockets: PlumeRocket, +) -> Result> { + User::find_by_fqn(&rockets, &name).map_err(|_| status::BadRequest(Some("User not found")))?; + inbox::handle_incoming(rockets, data, headers) } #[get("/@//followers", rank = 1)] pub fn ap_followers( name: String, - conn: DbConn, + rockets: PlumeRocket, _ap: ApRequest, ) -> Option> { - let user = User::find_by_fqn(&*conn, &name).ok()?; + let user = User::find_by_fqn(&rockets, &name).ok()?; let followers = user - .get_followers(&*conn) + .get_followers(&*rockets.conn) .ok()? .into_iter() .map(|f| Id::new(f.ap_url)) @@ -526,18 +473,19 @@ pub fn ap_followers( } #[get("/@//atom.xml")] -pub fn atom_feed(name: String, conn: DbConn) -> Option> { - let author = User::find_by_fqn(&*conn, &name).ok()?; +pub fn atom_feed(name: String, rockets: PlumeRocket) -> Option> { + let conn = &*rockets.conn; + let author = User::find_by_fqn(&rockets, &name).ok()?; let feed = FeedBuilder::default() .title(author.display_name.clone()) - .id(Instance::get_local(&*conn) + .id(Instance::get_local(conn) .unwrap() .compute_box("@", &name, "atom.xml")) .entries( - Post::get_recents_for_author(&*conn, &author, 15) + Post::get_recents_for_author(conn, &author, 15) .ok()? .into_iter() - .map(|p| super::post_to_atom(p, &*conn)) + .map(|p| super::post_to_atom(p, conn)) .collect::>(), ) .build() diff --git a/src/routes/well_known.rs b/src/routes/well_known.rs index 8689d387..56fffa99 100644 --- a/src/routes/well_known.rs +++ b/src/routes/well_known.rs @@ -3,7 +3,7 @@ use rocket::response::Content; use serde_json; use webfinger::*; -use plume_models::{ap_url, blogs::Blog, db_conn::DbConn, users::User, CONFIG}; +use plume_models::{ap_url, blogs::Blog, users::User, PlumeRocket, CONFIG}; #[get("/.well-known/nodeinfo")] pub fn nodeinfo() -> Content { @@ -43,25 +43,25 @@ pub fn host_meta() -> String { struct WebfingerResolver; -impl Resolver for WebfingerResolver { +impl Resolver for WebfingerResolver { fn instance_domain<'a>() -> &'a str { CONFIG.base_url.as_str() } - fn find(acct: String, conn: DbConn) -> Result { - User::find_by_fqn(&*conn, &acct) - .and_then(|usr| usr.webfinger(&*conn)) + fn find(acct: String, ctx: PlumeRocket) -> Result { + User::find_by_fqn(&ctx, &acct) + .and_then(|usr| usr.webfinger(&*ctx.conn)) .or_else(|_| { - Blog::find_by_fqn(&*conn, &acct) - .and_then(|blog| blog.webfinger(&*conn)) + Blog::find_by_fqn(&ctx, &acct) + .and_then(|blog| blog.webfinger(&*ctx.conn)) .or(Err(ResolverError::NotFound)) }) } } #[get("/.well-known/webfinger?")] -pub fn webfinger(resource: String, conn: DbConn) -> Content { - match WebfingerResolver::endpoint(resource, conn) +pub fn webfinger(resource: String, rockets: PlumeRocket) -> Content { + match WebfingerResolver::endpoint(resource, rockets) .and_then(|wf| serde_json::to_string(&wf).map_err(|_| ResolverError::NotFound)) { Ok(wf) => Content(ContentType::new("application", "jrd+json"), wf),