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
fix-mobile-margin
Baptiste Gelez 5 years ago committed by GitHub
parent c19c094e0c
commit 12efe721cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

4
Cargo.lock generated

@ -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)",

@ -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"

@ -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"

@ -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<Self, Self::Error> {
/// # Ok(User)
/// # }
/// #
/// # fn from_activity(_: &(), obj: Person) -> Result<Self, Self::Error> {
/// # 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<Self, Self::Error> {
/// # Ok(Message)
/// # }
/// #
/// # fn from_activity(_: &(), obj: Note) -> Result<Self, Self::Error> {
/// # Ok(Message)
/// # }
/// # }
/// # impl AsObject<User, Create, &()> for Message {
/// # type Error = ();
/// # type Output = ();
/// #
/// # fn activity(self, _: &(), _actor: User, _id: &str) -> Result<(), ()> {
/// # Ok(())
/// # }
/// # }
/// # impl AsObject<User, Announce, &()> 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::<User, Announce, Message>()
/// .with::<User, Create, Message>()
/// .done();
/// ```
pub enum Inbox<'a, C, E, R>
where
E: From<InboxError<E>> + 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<E>),
#[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<T: Object, C>: Sized {
type Error: From<ApError>;
/// Possible reasons of inbox failure
#[derive(Debug)]
pub enum InboxError<E: Debug> {
/// None of the registered handlers matched
NoMatch,
fn from_activity(conn: &C, obj: T, actor: Id) -> Result<Self, Self::Error>;
/// 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, Self::Error> {
Self::from_activity(
conn,
act.create_props.object_object()?,
act.create_props.actor_link::<Id>()?,
)
}
/// The activity type matched for at least one handler, but then the actor was
/// not of the expected type
InvalidActor(Option<E>),
/// Activity and Actor types matched, but not the Object
InvalidObject(Option<E>),
/// Error while dereferencing the object
DerefError,
}
pub trait Notify<C> {
type Error;
impl<T: Debug> From<InboxError<T>> for () {
fn from(_: InboxError<T>) {}
}
/*
Type arguments:
- C: Context
- E: Error
- R: Result
*/
impl<'a, C, E, R> Inbox<'a, C, E, R>
where
E: From<InboxError<E>> + 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<A, V, M>(self) -> Inbox<'a, C, E, R>
where
A: AsActor<&'a C> + FromId<C, Error = E>,
V: activitypub::Activity,
M: AsObject<A, V, &'a C, Error = E> + FromId<C, Error = E>,
M::Output: Into<R>,
{
if let Inbox::NotHandled(ctx, mut act, e) = self {
if serde_json::from_value::<V>(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)));
}
};
fn notify(&self, conn: &C) -> Result<(), Self::Error>;
// 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<R, E> {
match self {
Inbox::Handled(res) => Ok(res),
Inbox::NotHandled(_, _, err) => Err(E::from(err)),
Inbox::Failed(err) => Err(err),
}
}
}
pub trait Deletable<C, A> {
type 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<String> {
match json {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Object(map) => map.get("id")?.as_str().map(ToString::to_string),
_ => None,
}
}
/// 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<C>: Sized {
/// The type representing a failure
type Error: From<InboxError<Self::Error>> + Debug;
/// The ActivityPub object type representing Self
type Object: activitypub::Object;
fn delete(&self, conn: &C) -> Result<A, Self::Error>;
fn delete_id(id: &str, actor_id: &str, conn: &C) -> Result<A, Self::Error>;
/// 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<Self::Object>,
) -> Result<Self, (Option<serde_json::Value>, 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::Object, (Option<serde_json::Value>, Self::Error)> {
reqwest::Client::new()
.get(id)
.header(
ACCEPT,
HeaderValue::from_str(
&super::ap_accept_header()
.into_iter()
.collect::<Vec<_>>()
.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<Self, Self::Error>;
/// Tries to find a `Self` with a given ID (`id`), using `ctx` (a database)
fn from_db(ctx: &C, id: &str) -> Result<Self, Self::Error>;
}
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<C> {
/// Return the URL of this actor's inbox
fn get_inbox_url(&self) -> String;
fn get_shared_inbox_url(&self) -> Option<String>;
/// If this actor has shared inbox, its URL should be returned by this function
fn get_shared_inbox_url(&self) -> Option<String> {
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<Self, Self::Error> {
/// # Ok(Account)
/// # }
/// #
/// # fn from_activity(_: &(), obj: Person) -> Result<Self, Self::Error> {
/// # 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<Self, Self::Error> {
/// Ok(Message { text: "From DB".into() })
/// }
///
/// fn from_activity(_: &(), obj: Note) -> Result<Self, Self::Error> {
/// Ok(Message { text: obj.object_props.content_string().map_err(|_| ())? })
/// }
/// }
///
/// impl AsObject<Account, Create, ()> for Message {
/// type Error = ();
/// type Output = ();
///
/// fn activity(self, _: (), _actor: Account, _id: &str) -> Result<(), ()> {
/// println!("New Note: {:?}", self);
/// Ok(())
/// }
/// }
/// ```
pub trait AsObject<A, V, C>
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<Self::Output, Self::Error>;
}
#[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<Self, Self::Error> {
Ok(MyActor)
}
fn from_activity(_: &(), _obj: Person) -> Result<Self, Self::Error> {
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<Self, Self::Error> {
Ok(MyObject)
}
fn from_activity(_: &(), _obj: Note) -> Result<Self, Self::Error> {
Ok(MyObject)
}
}
impl AsObject<MyActor, Create, &()> for MyObject {
type Error = ();
type Output = ();
fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result<Self::Output, Self::Error> {
println!("MyActor is creating a Note");
Ok(())
}
}
impl AsObject<MyActor, Like, &()> for MyObject {
type Error = ();
type Output = ();
fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result<Self::Output, Self::Error> {
println!("MyActor is liking a Note");
Ok(())
}
}
impl AsObject<MyActor, Delete, &()> for MyObject {
type Error = ();
type Output = ();
fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result<Self::Output, Self::Error> {
println!("MyActor is deleting a Note");
Ok(())
}
}
impl AsObject<MyActor, Announce, &()> for MyObject {
type Error = ();
type Output = ();
fn activity(self, _: &(), _actor: MyActor, _id: &str) -> Result<Self::Output, Self::Error> {
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::<MyActor, Create, MyObject>()
.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::<MyActor, Announce, MyObject>()
.with::<MyActor, Delete, MyObject>()
.with::<MyActor, Create, MyObject>()
.with::<MyActor, Like, MyObject>()
.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::<MyActor, Announce, MyObject>()
.with::<MyActor, Like, MyObject>()
.done();
assert!(res.is_err());
}
struct FailingActor;
impl FromId<()> for FailingActor {
type Error = ();
type Object = Person;
fn from_db(_: &(), _id: &str) -> Result<Self, Self::Error> {
Err(())
}
fn from_activity(_: &(), _obj: Person) -> Result<Self, Self::Error> {
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<FailingActor, Create, &()> for MyObject {
type Error = ();
type Output = ();
fn activity(
self,
_: &(),
_actor: FailingActor,
_id: &str,
) -> Result<Self::Output, Self::Error> {
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::<FailingActor, Create, MyObject>()
.done();
assert!(res.is_err());
let res: Result<(), ()> = Inbox::handle(&(), act.clone())
.with::<FailingActor, Create, MyObject>()
.with::<MyActor, Create, MyObject>()
.done();
assert!(res.is_ok());
}
}

@ -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<S: sign::Signer, A: Activity, T: inbox::WithInbox>(
sender: &S,
act: A,
to: Vec<T>,
) {
pub fn broadcast<S, A, T, C>(sender: &S, act: A, to: Vec<T>)
where
S: sign::Signer,
A: Activity,
T: inbox::AsActor<C>,
{
let boxes = to
.into_iter()
.filter(|u| !u.is_local())

@ -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;

@ -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"

@ -7,10 +7,6 @@ use openssl::{
rsa::Rsa,
sign::{Signer, Verifier},
};
use reqwest::{
header::{HeaderValue, ACCEPT},
Client,
};
use serde_json;
use url::Url;
use webfinger::*;
@ -18,8 +14,7 @@ use webfinger::*;
use instance::*;
use medias::Media;
use plume_common::activity_pub::{
ap_accept_header,
inbox::{Deletable, WithInbox},
inbox::{AsActor, FromId},
sign, ActivityStream, ApSignature, Id, IntoId, PublicKey, Source,
};
use posts::Post;
@ -27,7 +22,7 @@ use safe_string::SafeString;
use schema::blogs;
use search::Searcher;
use users::User;
use {Connection, Error, Result, CONFIG};
use {Connection, Error, PlumeRocket, Result};
pub type CustomGroup = CustomObject<ApSignature, Group>;
@ -135,121 +130,27 @@ impl Blog {
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<Blog> {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<Blog> {
let from_db = blogs::table
.filter(blogs::fqn.eq(fqn))
.limit(1)
.load::<Blog>(conn)?
.load::<Blog>(&*c.conn)?
.into_iter()
.next();
if let Some(from_db) = from_db {
Ok(from_db)
} else {
Blog::fetch_from_webfinger(conn, fqn)
Blog::fetch_from_webfinger(c, fqn)
}
}
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<Blog> {
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
resolve(acct.to_owned(), true)?
.links
.into_iter()
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
.ok_or(Error::Webfinger)
.and_then(|l| Blog::fetch_from_url(conn, &l.href?))
}
fn fetch_from_url(conn: &Connection, url: &str) -> Result<Blog> {
let mut res = Client::new()
.get(url)
.header(
ACCEPT,
HeaderValue::from_str(
&ap_accept_header()
.into_iter()
.collect::<Vec<_>>()
.join(", "),
)?,
)
.send()?;
let text = &res.text()?;
let ap_sign: ApSignature = serde_json::from_str(text)?;
let mut json: CustomGroup = serde_json::from_str(text)?;
json.custom_props = ap_sign; // without this workaround, publicKey is not correctly deserialized
Blog::from_activity(conn, &json, Url::parse(url)?.host_str()?)
}
fn from_activity(conn: &Connection, acct: &CustomGroup, inst: &str) -> Result<Blog> {
let instance = Instance::find_by_domain(conn, inst).or_else(|_| {
Instance::insert(
conn,
NewInstance {
public_domain: inst.to_owned(),
name: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
let icon_id = acct
.object
.object_props
.icon_image()
.ok()
.and_then(|icon| {
let owner: String = icon.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
conn,
icon.object_props.url_string().ok()?,
&User::from_url(conn, &owner).ok()?,
)
.ok()
})
.map(|m| m.id);
let banner_id = acct
.object
.object_props
.image_image()
.ok()
.and_then(|banner| {
let owner: String = banner.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
conn,
banner.object_props.url_string().ok()?,
&User::from_url(conn, &owner).ok()?,
)
.ok()
})
.map(|m| m.id);
Blog::insert(
conn,
NewBlog {
actor_id: acct.object.ap_actor_props.preferred_username_string()?,
title: acct.object.object_props.name_string()?,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
summary: acct.object.object_props.summary_string()?,
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
banner_id,
icon_id,
summary_html: SafeString::new(&acct.object.object_props.summary_string()?),
},
)
.and_then(|l| Blog::from_id(c, &l.href?, None).map_err(|(_, e)| e))
}
pub fn to_activity(&self, conn: &Connection) -> Result<CustomGroup> {
@ -368,18 +269,6 @@ impl Blog {
})
}
pub fn from_url(conn: &Connection, url: &str) -> Result<Blog> {
Blog::find_by_ap_url(conn, url).or_else(|_| {
// The requested blog was not in the DB
// We try to fetch it if it is remote
if Url::parse(url)?.host_str()? != CONFIG.base_url.as_str() {
Blog::fetch_from_url(conn, url)
} else {
Err(Error::NotFound)
}
})
}
pub fn icon_url(&self, conn: &Connection) -> String {
self.icon_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url(conn)).ok())
@ -394,7 +283,7 @@ impl Blog {
pub fn delete(&self, conn: &Connection, searcher: &Searcher) -> Result<()> {
for post in Post::get_for_blog(conn, &self)? {
post.delete(&(conn, searcher))?;
post.delete(conn, searcher)?;
}
diesel::delete(self)
.execute(conn)
@ -409,7 +298,106 @@ impl IntoId for Blog {
}
}
impl WithInbox for Blog {
impl FromId<PlumeRocket> for Blog {
type Error = Error;
type Object = CustomGroup;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, acct: CustomGroup) -> Result<Self> {
let url = Url::parse(&acct.object.object_props.id_string()?)?;
let inst = url.host_str()?;
let instance = Instance::find_by_domain(&c.conn, inst).or_else(|_| {
Instance::insert(
&c.conn,
NewInstance {
public_domain: inst.to_owned(),
name: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
let icon_id = acct
.object
.object_props
.icon_image()
.ok()
.and_then(|icon| {
let owner: String = icon.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
&c.conn,
icon.object_props.url_string().ok()?,
&User::from_id(c, &owner, None).ok()?,
)
.ok()
})
.map(|m| m.id);
let banner_id = acct
.object
.object_props
.image_image()
.ok()
.and_then(|banner| {
let owner: String = banner.object_props.attributed_to_link::<Id>().ok()?.into();
Media::save_remote(
&c.conn,
banner.object_props.url_string().ok()?,
&User::from_id(c, &owner, None).ok()?,
)
.ok()
})
.map(|m| m.id);
let name = acct.object.ap_actor_props.preferred_username_string()?;
if name.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
return Err(Error::InvalidValue);
}
Blog::insert(
&c.conn,
NewBlog {
actor_id: name.clone(),
title: acct.object.object_props.name_string().unwrap_or(name),
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
summary: acct
.object
.ap_object_props
.source_object::<Source>()
.map(|s| s.content)
.unwrap_or_default(),
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
banner_id,
icon_id,
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
},
)
}
}
impl AsActor<&PlumeRocket> for Blog {
fn get_inbox_url(&self) -> String {
self.inbox_url.clone()
}
@ -419,7 +407,7 @@ impl WithInbox for Blog {
}
fn is_local(&self) -> bool {
self.instance_id == 0
self.instance_id == 1 // TODO: this is not always true
}
}
@ -471,8 +459,9 @@ pub(crate) mod tests {
use blog_authors::*;
use diesel::Connection;
use instance::tests as instance_tests;
use medias::NewMedia;
use search::tests::get_searcher;
use tests::db;
use tests::{db, rockets};
use users::tests as usersTests;
use Connection as Conn;
@ -687,7 +676,8 @@ pub(crate) mod tests {
#[test]
fn find_local() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
fill_database(conn);
@ -703,7 +693,7 @@ pub(crate) mod tests {
)
.unwrap();
assert_eq!(Blog::find_by_fqn(conn, "SomeName").unwrap().id, blog.id);
assert_eq!(Blog::find_by_fqn(&r, "SomeName").unwrap().id, blog.id);
Ok(())
});
@ -816,4 +806,65 @@ pub(crate) mod tests {
Ok(())
});
}
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, mut blogs) = fill_database(conn);
blogs[0].icon_id = Some(
Media::insert(
conn,
NewMedia {
file_path: "aaa.png".into(),
alt_text: String::new(),
is_remote: false,
remote_url: None,
sensitive: false,
content_warning: None,
owner_id: users[0].id,
},
)
.unwrap()
.id,
);
blogs[0].banner_id = Some(
Media::insert(
conn,
NewMedia {
file_path: "bbb.png".into(),
alt_text: String::new(),
is_remote: false,
remote_url: None,
sensitive: false,
content_warning: None,
owner_id: users[0].id,
},
)
.unwrap()
.id,
);
let _: Blog = blogs[0].save_changes(conn).unwrap();
let ap_repr = blogs[0].to_activity(conn).unwrap();
blogs[0].delete(conn, &*r.searcher).unwrap();
let blog = Blog::from_activity(&r, ap_repr).unwrap();
assert_eq!(blog.actor_id, blogs[0].actor_id);
assert_eq!(blog.title, blogs[0].title);
assert_eq!(blog.summary, blogs[0].summary);
assert_eq!(blog.outbox_url, blogs[0].outbox_url);
assert_eq!(blog.inbox_url, blogs[0].inbox_url);
assert_eq!(blog.instance_id, blogs[0].instance_id);
assert_eq!(blog.ap_url, blogs[0].ap_url);
assert_eq!(blog.public_key, blogs[0].public_key);
assert_eq!(blog.fqn, blogs[0].fqn);
assert_eq!(blog.summary_html, blogs[0].summary_html);
assert_eq!(blog.icon_url(conn), blogs[0].icon_url(conn));
assert_eq!(blog.banner_url(conn), blogs[0].banner_url(conn));
Ok(())
});
}
}

@ -15,15 +15,15 @@ use medias::Media;
use mentions::Mention;
use notifications::*;
use plume_common::activity_pub::{
inbox::{Deletable, FromActivity, Notify},
Id, IntoId, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Id, IntoId, PUBLIC_VISIBILITY,
};
use plume_common::utils;
use posts::Post;
use safe_string::SafeString;
use schema::comments;
use users::User;
use {Connection, Error, Result};
use {Connection, Error, PlumeRocket, Result};
#[derive(Queryable, Identifiable, Clone, AsChangeset)]
pub struct Comment {
@ -103,18 +103,17 @@ impl Comment {
.unwrap_or(false)
}
pub fn to_activity<'b>(&self, conn: &'b Connection) -> Result<Note> {
let author = User::get(conn, self.author_id)?;
pub fn to_activity(&self, c: &PlumeRocket) -> Result<Note> {
let author = User::get(&c.conn, self.author_id)?;
let (html, mentions, _hashtags) = utils::md_to_html(
self.content.get().as_ref(),
&Instance::get_local(conn)?.public_domain,
&Instance::get_local(&c.conn)?.public_domain,
true,
Some(Media::get_media_processor(conn, vec![&author])),
Some(Media::get_media_processor(&c.conn, vec![&author])),
);
let mut note = Note::default();
let to = vec![Id::new(PUBLIC_VISIBILTY.to_string())];
let to = vec![Id::new(PUBLIC_VISIBILITY.to_string())];
note.object_props
.set_id_string(self.ap_url.clone().unwrap_or_default())?;
@ -123,8 +122,8 @@ impl Comment {
note.object_props.set_content_string(html)?;
note.object_props
.set_in_reply_to_link(Id::new(self.in_response_to_id.map_or_else(
|| Ok(Post::get(conn, self.post_id)?.ap_url),
|id| Ok(Comment::get(conn, id)?.ap_url.unwrap_or_default()) as Result<String>,
|| Ok(Post::get(&c.conn, self.post_id)?.ap_url),
|id| Ok(Comment::get(&c.conn, id)?.ap_url.unwrap_or_default()) as Result<String>,
)?))?;
note.object_props
.set_published_string(chrono::Utc::now().to_rfc3339())?;
@ -134,16 +133,16 @@ impl Comment {
note.object_props.set_tag_link_vec(
mentions
.into_iter()
.filter_map(|m| Mention::build_activity(conn, &m).ok())
.filter_map(|m| Mention::build_activity(c, &m).ok())
.collect::<Vec<link::Mention>>(),
)?;
Ok(note)
}
pub fn create_activity(&self, conn: &Connection) -> Result<Create> {
let author = User::get(conn, self.author_id)?;
pub fn create_activity(&self, c: &PlumeRocket) -> Result<Create> {
let author = User::get(&c.conn, self.author_id)?;
let note = self.to_activity(conn)?;
let note = self.to_activity(c)?;
let mut act = Create::default();
act.create_props.set_actor_link(author.into_id())?;
act.create_props.set_object_object(note.clone())?;
@ -151,15 +150,53 @@ impl Comment {
.set_id_string(format!("{}/activity", self.ap_url.clone()?,))?;
act.object_props
.set_to_link_vec(note.object_props.to_link_vec::<Id>()?)?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_author(&c.conn)?.followers_endpoint)])?;
Ok(act)
}
pub fn notify(&self, conn: &Connection) -> Result<()> {
for author in self.get_post(conn)?.get_authors(conn)? {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::COMMENT.to_string(),
object_id: self.id,
user_id: author.id,
},
)?;
}
Ok(())
}
pub fn build_delete(&self, conn: &Connection) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_author(conn)?.into_id())?;
let mut tombstone = Tombstone::default();
tombstone.object_props.set_id_string(self.ap_url.clone()?)?;
act.delete_props.set_object_object(tombstone)?;
act.object_props
.set_id_string(format!("{}#delete", self.ap_url.clone().unwrap()))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY)])?;
Ok(act)
}
}
impl FromActivity<Note, Connection> for Comment {
impl FromId<PlumeRocket> for Comment {
type Error = Error;
type Object = Note;
fn from_activity(conn: &Connection, note: Note, actor: Id) -> Result<Comment> {
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, note: Note) -> Result<Self> {
let conn = &*c.conn;
let comm = {
let previous_url = note.object_props.in_reply_to.as_ref()?.as_str()?;
let previous_comment = Comment::find_by_ap_url(conn, previous_url);
@ -171,8 +208,8 @@ impl FromActivity<Note, Connection> for Comment {
serde_json::Value::Array(v) => v
.iter()
.filter_map(serde_json::Value::as_str)
.any(|s| s == PUBLIC_VISIBILTY),
serde_json::Value::String(s) => s == PUBLIC_VISIBILTY,
.any(|s| s == PUBLIC_VISIBILITY),
serde_json::Value::String(s) => s == PUBLIC_VISIBILITY,
_ => false,
};
@ -191,8 +228,17 @@ impl FromActivity<Note, Connection> for Comment {
post_id: previous_comment.map(|c| c.post_id).or_else(|_| {
Ok(Post::find_by_ap_url(conn, previous_url)?.id) as Result<i32>
})?,
author_id: User::from_url(conn, actor.as_ref())?.id,
sensitive: false, // "sensitive" is not a standard property, we need to think about how to support it with the activitypub crate
author_id: User::from_id(
c,
&{
let res: String = note.object_props.attributed_to_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
sensitive: note.object_props.summary_string().is_ok(),
public_visibility,
},
)?;
@ -243,10 +289,10 @@ impl FromActivity<Note, Connection> for Comment {
.chain(cc)
.chain(bto)
.chain(bcc)
.collect::<HashSet<_>>() //remove duplicates (don't do a query more than once)
.collect::<HashSet<_>>() // remove duplicates (don't do a query more than once)
.into_iter()
.map(|v| {
if let Ok(user) = User::from_url(conn, &v) {
if let Ok(user) = User::from_id(c, &v, None) {
vec![user]
} else {
vec![] // TODO try to fetch collection
@ -272,20 +318,41 @@ impl FromActivity<Note, Connection> for Comment {
}
}
impl Notify<Connection> for Comment {
impl AsObject<User, Create, &PlumeRocket> for Comment {
type Error = Error;
type Output = Self;
fn notify(&self, conn: &Connection) -> Result<()> {
for author in self.get_post(conn)?.get_authors(conn)? {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::COMMENT.to_string(),
object_id: self.id,
user_id: author.id,
},
)?;
fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result<Self> {
// The actual creation takes place in the FromId impl
Ok(self)
}
}
impl AsObject<User, Delete, &PlumeRocket> for Comment {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
if self.author_id != actor.id {
return Err(Error::Unauthorized);
}
for m in Mention::list_for_comment(&c.conn, self.id)? {
for n in Notification::find_for_mention(&c.conn, &m)? {
n.delete(&c.conn)?;
}
m.delete(&c.conn)?;
}
for n in Notification::find_for_comment(&c.conn, &self)? {
n.delete(&c.conn)?;
}
diesel::update(comments::table)
.filter(comments::in_response_to_id.eq(self.id))
.set(comments::in_response_to_id.eq(self.in_response_to_id))
.execute(&*c.conn)?;
diesel::delete(&self).execute(&*c.conn)?;
Ok(())
}
}
@ -316,49 +383,58 @@ impl CommentTree {
}
}
impl<'a> Deletable<Connection, Delete> for Comment {
type Error = Error;
fn delete(&self, conn: &Connection) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_author(conn)?.into_id())?;
let mut tombstone = Tombstone::default();
tombstone.object_props.set_id_string(self.ap_url.clone()?)?;
act.delete_props.set_object_object(tombstone)?;
act.object_props
.set_id_string(format!("{}#delete", self.ap_url.clone().unwrap()))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILTY)])?;
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)
}
#[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 (posts, users, _) = fill_database(&r);
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();
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"),
};
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<Delete> {
let actor = User::find_by_ap_url(conn, actor_id)?;
let comment = Comment::find_by_ap_url(conn, id)?;
if comment.author_id == actor.id {
comment.delete(conn)
} else {
Err(Error::Unauthorized)
}
Ok(())
});
}
}

@ -1,20 +1,16 @@
use activitypub::{
activity::{Accept, Follow as FollowAct, Undo},
actor::Person,
};
use activitypub::activity::{Accept, Follow as FollowAct, Undo};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use blogs::Blog;
use notifications::*;
use plume_common::activity_pub::{
broadcast,
inbox::{Deletable, FromActivity, Notify, WithInbox},
inbox::{AsActor, AsObject, FromId},
sign::Signer,
Id, IntoId,
Id, IntoId, PUBLIC_VISIBILITY,
};
use schema::follows;
use users::User;
use {ap_url, Connection, Error, Result, CONFIG};
use {ap_url, Connection, Error, PlumeRocket, Result, CONFIG};
#[derive(Clone, Queryable, Identifiable, Associations, AsChangeset)]
#[belongs_to(User, foreign_key = "following_id")]
@ -65,14 +61,26 @@ impl Follow {
act.follow_props
.set_object_link::<Id>(target.clone().into_id())?;
act.object_props.set_id_string(self.ap_url.clone())?;
act.object_props.set_to_link(target.into_id())?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
act.object_props.set_to_link_vec(vec![target.into_id()])?;
act.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
Ok(act)
}
pub fn notify(&self, conn: &Connection) -> Result<Notification> {
Notification::insert(
conn,
NewNotification {
kind: notification_kind::FOLLOW.to_string(),
object_id: self.id,
user_id: self.following_id,
},
)
}
/// from -> The one sending the follow request
/// target -> The target of the request, responding with Accept
pub fn accept_follow<A: Signer + IntoId + Clone, B: Clone + WithInbox + IntoId>(
pub fn accept_follow<A: Signer + IntoId + Clone, B: Clone + AsActor<T> + IntoId, T>(
conn: &Connection,
from: &B,
target: &A,
@ -88,6 +96,7 @@ impl Follow {
ap_url: follow.object_props.id_string()?,
},
)?;
res.notify(conn)?;
let mut accept = Accept::default();
let accept_id = ap_url(&format!(
@ -96,8 +105,12 @@ impl Follow {
&res.id
));
accept.object_props.set_id_string(accept_id)?;
accept.object_props.set_to_link(from.clone().into_id())?;
accept.object_props.set_cc_link_vec::<Id>(vec![])?;
accept
.object_props
.set_to_link_vec(vec![from.clone().into_id()])?;
accept
.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
accept
.accept_props
.set_actor_link::<Id>(target.clone().into_id())?;
@ -105,76 +118,86 @@ impl Follow {
broadcast(&*target, accept, vec![from.clone()]);
Ok(res)
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut undo = Undo::default();
undo.undo_props
.set_actor_link(User::get(conn, self.follower_id)?.into_id())?;
undo.object_props
.set_id_string(format!("{}/undo", self.ap_url))?;
undo.undo_props
.set_object_link::<Id>(self.clone().into_id())?;
undo.object_props
.set_to_link_vec(vec![User::get(conn, self.following_id)?.into_id()])?;
undo.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
Ok(undo)
}
}
impl FromActivity<FollowAct, Connection> for Follow {
impl AsObject<User, FollowAct, &PlumeRocket> for User {
type Error = Error;
fn from_activity(conn: &Connection, follow: FollowAct, _actor: Id) -> Result<Follow> {
let from_id = follow
type Output = Follow;
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Follow> {
// Mastodon (at least) requires the full Follow object when accepting it,
// so we rebuilt it here
let mut follow = FollowAct::default();
follow.object_props.set_id_string(id.to_string())?;
follow
.follow_props
.actor_link::<Id>()
.map(Into::into)
.or_else(|_| {
Ok(follow
.follow_props
.actor_object::<Person>()?
.object_props
.id_string()?) as Result<String>
})?;
let from = User::from_url(conn, &from_id)?;
match User::from_url(conn, follow.follow_props.object.as_str()?) {
Ok(user) => Follow::accept_follow(conn, &from, &user, follow, from.id, user.id),
Err(_) => {
let blog = Blog::from_url(conn, follow.follow_props.object.as_str()?)?;
Follow::accept_follow(conn, &from, &blog, follow, from.id, blog.id)
}
}
.set_actor_link::<Id>(actor.clone().into_id())?;
Follow::accept_follow(&c.conn, &actor, &self, follow, actor.id, self.id)
}
}
impl Notify<Connection> for Follow {
impl FromId<PlumeRocket> for Follow {
type Error = Error;
type Object = FollowAct;
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,
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Follow::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, follow: FollowAct) -> Result<Self> {
let actor = User::from_id(
c,
&{
let res: String = follow.follow_props.actor_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?;
let target = User::from_id(
c,
&{
let res: String = follow.follow_props.object_link::<Id>()?.into();
res
},
None,
)
.map(|_| ())
.map_err(|(_, e)| e)?;
Follow::accept_follow(&c.conn, &actor, &target, follow, actor.id, target.id)
}
}
impl Deletable<Connection, Undo> for Follow {
impl AsObject<User, Undo, &PlumeRocket> for Follow {
type Error = Error;
type Output = ();
fn delete(&self, conn: &Connection) -> Result<Undo> {
diesel::delete(self).execute(conn)?;
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if self.follower_id == actor.id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) {
diesel::delete(&notif).execute(conn)?;
}
let mut undo = Undo::default();
undo.undo_props
.set_actor_link(User::get(conn, self.follower_id)?.into_id())?;
undo.object_props
.set_id_string(format!("{}/undo", self.ap_url))?;
undo.undo_props
.set_object_link::<Id>(self.clone().into_id())?;
Ok(undo)
}
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) {
diesel::delete(&notif).execute(conn)?;
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<Undo> {
let follow = Follow::find_by_ap_url(conn, id)?;
let user = User::find_by_ap_url(conn, actor_id)?;
if user.id == follow.follower_id {
follow.delete(conn)
Ok(())
} else {
Err(Error::Unauthorized)
}

@ -0,0 +1,487 @@
use activitypub::activity::*;
use serde_json;
use crate::{
comments::Comment,
follows, likes,
posts::{Post, PostUpdate},
reshares::Reshare,
users::User,
Error, PlumeRocket,
};
use plume_common::activity_pub::inbox::Inbox;
macro_rules! impl_into_inbox_result {
( $( $t:ty => $variant:ident ),+ ) => {
$(
impl From<$t> for InboxResult {
fn from(x: $t) -> InboxResult {
InboxResult::$variant(x)
}
}
)+
}
}
pub enum InboxResult {
Commented(Comment),
Followed(follows::Follow),
Liked(likes::Like),
Other,
Post(Post),
Reshared(Reshare),
}
impl From<()> for InboxResult {
fn from(_: ()) -> InboxResult {
InboxResult::Other
}
}
impl_into_inbox_result! {
Comment => Commented,
follows::Follow => Followed,
likes::Like => Liked,
Post => Post,
Reshare => Reshared
}
pub fn inbox(ctx: &PlumeRocket, act: serde_json::Value) -> Result<InboxResult, Error> {
Inbox::handle(ctx, act)
.with::<User, Announce, Post>()
.with::<User, Create, Comment>()
.with::<User, Create, Post>()
.with::<User, Delete, Comment>()
.with::<User, Delete, Post>()
.with::<User, Follow, User>()
.with::<User, Like, Post>()
.with::<User, Undo, Reshare>()
.with::<User, Undo, follows::Follow>()
.with::<User, Undo, likes::Like>()
.with::<User, Update, PostUpdate>()
.done()
}
#[cfg(test)]
pub(crate) mod tests {
use super::InboxResult;
use crate::blogs::tests::fill_database as blog_fill_db;
use crate::safe_string::SafeString;
use crate::tests::rockets;
use crate::PlumeRocket;
use diesel::Connection;
pub fn fill_database(
rockets: &PlumeRocket,
) -> (
Vec<crate::posts::Post>,
Vec<crate::users::User>,
Vec<crate::blogs::Blog>,
) {
use crate::post_authors::*;
use crate::posts::*;
let (users, blogs) = blog_fill_db(&rockets.conn);
let post = Post::insert(
&rockets.conn,
NewPost {
blog_id: blogs[0].id,
slug: "testing".to_owned(),
title: "Testing".to_owned(),
content: crate::safe_string::SafeString::new("Hello"),
published: true,
license: "WTFPL".to_owned(),
creation_date: None,
ap_url: format!("https://plu.me/~/{}/testing", blogs[0].actor_id),
subtitle: String::new(),
source: String::new(),
cover_id: None,
},
&rockets.searcher,
)
.unwrap();
PostAuthor::insert(
&rockets.conn,
NewPostAuthor {
post_id: post.id,
author_id: users[0].id,
},
)
.unwrap();
(vec![post], users, blogs)
}
#[test]
fn announce_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/announce/1",
"actor": users[0].ap_url,
"object": posts[0].ap_url,
"type": "Announce",
});
match super::inbox(&r, act).unwrap() {
super::InboxResult::Reshared(r) => {
assert_eq!(r.post_id, posts[0].id);
assert_eq!(r.user_id, users[0].id);
assert_eq!(r.ap_url, "https://plu.me/announce/1".to_owned());
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn create_comment() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Note",
"id": "https://plu.me/comment/1",
"attributedTo": users[0].ap_url,
"inReplyTo": posts[0].ap_url,
"content": "Hello.",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
match super::inbox(&r, act).unwrap() {
super::InboxResult::Commented(c) => {
assert_eq!(c.author_id, users[0].id);
assert_eq!(c.post_id, posts[0].id);
assert_eq!(c.in_response_to_id, None);
assert_eq!(c.content, SafeString::new("Hello."));
assert!(c.public_visibility);
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn create_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&r);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": [users[0].ap_url, blogs[0].ap_url],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
match super::inbox(&r, act).unwrap() {
super::InboxResult::Post(p) => {
assert!(p.is_author(conn, users[0].id).unwrap());
assert_eq!(p.source, "Hello.".to_owned());
assert_eq!(p.blog_id, blogs[0].id);
assert_eq!(p.content, SafeString::new("Hello."));
assert_eq!(p.subtitle, "Bye.".to_owned());
assert_eq!(p.title, "My Article".to_owned());
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn delete_comment() {
use crate::comments::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
Comment::insert(
conn,
NewComment {
content: SafeString::new("My comment"),
in_response_to_id: None,
post_id: posts[0].id,
author_id: users[0].id,
ap_url: Some("https://plu.me/comment/1".to_owned()),
sensitive: false,
spoiler_text: "spoiler".to_owned(),
public_visibility: true,
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[1].ap_url, // Not the author of the comment, it should fail
"object": "https://plu.me/comment/1",
"type": "Delete",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[0].ap_url,
"object": "https://plu.me/comment/1",
"type": "Delete",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn delete_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let fail_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[1].ap_url, // Not the author of the post, it should fail
"object": posts[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/comment/1/delete",
"actor": users[0].ap_url,
"object": posts[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn follow() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/follow/1",
"actor": users[0].ap_url,
"object": users[1].ap_url,
"type": "Follow",
});
match super::inbox(&r, act).unwrap() {
InboxResult::Followed(f) => {
assert_eq!(f.follower_id, users[0].id);
assert_eq!(f.following_id, users[1].id);
assert_eq!(f.ap_url, "https://plu.me/follow/1".to_owned());
}
_ => panic!("Unexpected result"),
}
Ok(())
});
}
#[test]
fn like() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/like/1",
"actor": users[1].ap_url,
"object": posts[0].ap_url,
"type": "Like",
});
match super::inbox(&r, act).unwrap() {
InboxResult::Liked(l) => {
assert_eq!(l.user_id, users[1].id);
assert_eq!(l.post_id, posts[0].id);
assert_eq!(l.ap_url, "https://plu.me/like/1".to_owned());
}
_ => panic!("Unexpected result"),
}
Ok(())
});
}
#[test]
fn undo_reshare() {
use crate::reshares::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let announce = Reshare::insert(
conn,
NewReshare {
post_id: posts[0].id,
user_id: users[1].id,
ap_url: "https://plu.me/announce/1".to_owned(),
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[0].ap_url,
"object": announce.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[1].ap_url,
"object": announce.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn undo_follow() {
use crate::follows::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&r);
let follow = Follow::insert(
conn,
NewFollow {
follower_id: users[0].id,
following_id: users[1].id,
ap_url: "https://plu.me/follow/1".to_owned(),
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[2].ap_url,
"object": follow.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[0].ap_url,
"object": follow.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn undo_like() {
use crate::likes::*;
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let like = Like::insert(
conn,
NewLike {
post_id: posts[0].id,
user_id: users[1].id,
ap_url: "https://plu.me/like/1".to_owned(),
},
)
.unwrap();
let fail_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[0].ap_url,
"object": like.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
"actor": users[1].ap_url,
"object": like.ap_url,
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn update_post() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/update/1",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": posts[0].ap_url,
"name": "Mia Artikolo",
"summary": "Jes, mi parolas esperanton nun",
"content": "<b>Saluton</b>, mi skribas testojn",
"source": {
"mediaType": "text/markdown",
"content": "**Saluton**, mi skribas testojn"
},
},
"type": "Update",
});
super::inbox(&r, act).unwrap();
Ok(())
});
}
}

@ -20,6 +20,7 @@ extern crate plume_api;
extern crate plume_common;
extern crate reqwest;
extern crate rocket;
extern crate rocket_i18n;
extern crate scheduled_thread_pool;
extern crate serde;
#[macro_use]
@ -36,6 +37,8 @@ extern crate whatlang;
#[macro_use]
extern crate diesel_migrations;
use plume_common::activity_pub::inbox::InboxError;
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("Either feature \"sqlite\" or \"postgres\" must be enabled for this crate.");
#[cfg(all(feature = "sqlite", feature = "postgres"))]
@ -51,6 +54,7 @@ pub type Connection = diesel::PgConnection;
#[derive(Debug)]
pub enum Error {
Db(diesel::result::Error),
Inbox(Box<InboxError<Error>>),
InvalidValue,
Io(std::io::Error),
MissingApProperty,
@ -139,6 +143,15 @@ impl From<std::io::Error> for Error {
}
}
impl From<InboxError<Error>> for Error {
fn from(err: InboxError<Error>) -> Error {
match err {
InboxError::InvalidActor(Some(e)) | InboxError::InvalidObject(Some(e)) => e,
e => Error::Inbox(Box::new(e)),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub type ApiResult<T> = std::result::Result<T, canapi::Error>;
@ -288,7 +301,13 @@ pub fn ap_url(url: &str) -> String {
#[cfg(test)]
#[macro_use]
mod tests {
use diesel::{dsl::sql_query, Connection, RunQueryDsl};
use db_conn;
use diesel::r2d2::ConnectionManager;
#[cfg(feature = "sqlite")]
use diesel::{dsl::sql_query, RunQueryDsl};
use scheduled_thread_pool::ScheduledThreadPool;
use search;
use std::sync::Arc;
use Connection as Conn;
use CONFIG;
@ -309,15 +328,28 @@ mod tests {
};
}
pub fn db() -> Conn {
let conn = Conn::establish(CONFIG.database_url.as_str())
.expect("Couldn't connect to the database");
embedded_migrations::run(&conn).expect("Couldn't run migrations");
#[cfg(feature = "sqlite")]
sql_query("PRAGMA foreign_keys = on;")
.execute(&conn)
.expect("PRAGMA foreign_keys fail");
conn
pub fn db<'a>() -> db_conn::DbConn {
db_conn::DbConn((*DB_POOL).get().unwrap())
}
lazy_static! {
static ref DB_POOL: db_conn::DbPool = {
let pool = db_conn::DbPool::builder()
.connection_customizer(Box::new(db_conn::PragmaForeignKey))
.build(ConnectionManager::<Conn>::new(CONFIG.database_url.as_str()))
.unwrap();
embedded_migrations::run(&*pool.get().unwrap()).expect("Migrations error");
pool
};
}
pub fn rockets() -> super::PlumeRocket {
super::PlumeRocket {
conn: db_conn::DbConn((*DB_POOL).get().unwrap()),
searcher: Arc::new(search::tests::get_searcher()),
worker: Arc::new(ScheduledThreadPool::new(2)),
user: None,
}
}
}
@ -331,11 +363,13 @@ pub mod comments;
pub mod db_conn;
pub mod follows;
pub mod headers;
pub mod inbox;
pub mod instance;
pub mod likes;
pub mod medias;
pub mod mentions;
pub mod notifications;
pub mod plume_rocket;
pub mod post_authors;
pub mod posts;
pub mod reshares;
@ -344,3 +378,4 @@ pub mod schema;
pub mod search;
pub mod tags;
pub mod users;
pub use plume_rocket::PlumeRocket;

@ -4,13 +4,13 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use notifications::*;
use plume_common::activity_pub::{
inbox::{Deletable, FromActivity, Notify},
Id, IntoId, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Id, IntoId, PUBLIC_VISIBILITY,
};
use posts::Post;
use schema::likes;
use users::User;
use {Connection, Error, Result};
use {Connection, Error, PlumeRocket, Result};
#[derive(Clone, Queryable, Identifiable)]
pub struct Like {
@ -42,37 +42,16 @@ impl Like {
act.like_props
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props.set_cc_link_vec(vec![Id::new(
User::get(conn, self.user_id)?.followers_endpoint,
)])?;
act.object_props.set_id_string(self.ap_url.clone())?;
Ok(act)
}
}
impl FromActivity<activity::Like, Connection> for Like {
type Error = Error;
fn from_activity(conn: &Connection, like: activity::Like, _actor: Id) -> Result<Like> {
let liker = User::from_url(conn, like.like_props.actor.as_str()?)?;
let post = Post::find_by_ap_url(conn, like.like_props.object.as_str()?)?;
let res = Like::insert(
conn,
NewLike {
post_id: post.id,
user_id: liker.id,
ap_url: like.object_props.id_string()?,
},
)?;
res.notify(conn)?;
Ok(res)
}
}
impl Notify<Connection> for Like {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
pub fn notify(&self, conn: &Connection) -> Result<()> {
let post = Post::get(conn, self.post_id)?;
for author in post.get_authors(conn)? {
Notification::insert(
@ -86,19 +65,8 @@ impl Notify<Connection> for Like {
}
Ok(())
}
}
impl Deletable<Connection, activity::Undo> for Like {
type Error = Error;
fn delete(&self, conn: &Connection) -> Result<activity::Undo> {
diesel::delete(self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
pub fn build_undo(&self, conn: &Connection) -> Result<activity::Undo> {
let mut act = activity::Undo::default();
act.undo_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
@ -106,17 +74,87 @@ impl Deletable<Connection, activity::Undo> for Like {
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props.set_cc_link_vec(vec![Id::new(
User::get(conn, self.user_id)?.followers_endpoint,
)])?;
Ok(act)
}
}
impl AsObject<User, activity::Like, &PlumeRocket> for Post {
type Error = Error;
type Output = Like;
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<activity::Undo> {
let like = Like::find_by_ap_url(conn, id)?;
let user = User::find_by_ap_url(conn, actor_id)?;
if user.id == like.user_id {
like.delete(conn)
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Like> {
let res = Like::insert(
&c.conn,
NewLike {
post_id: self.id,
user_id: actor.id,
ap_url: id.to_string(),
},
)?;
res.notify(&c.conn)?;
Ok(res)
}
}
impl FromId<PlumeRocket> for Like {
type Error = Error;
type Object = activity::Like;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Like::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, act: activity::Like) -> Result<Self> {
let res = Like::insert(
&c.conn,
NewLike {
post_id: Post::from_id(
c,
&{
let res: String = act.like_props.object_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(
c,
&{
let res: String = act.like_props.actor_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
ap_url: act.object_props.id_string()?,
},
)?;
res.notify(&c.conn)?;
Ok(res)
}
}
impl AsObject<User, activity::Undo, &PlumeRocket> for Like {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
Ok(())
} else {
Err(Error::Unauthorized)
}
@ -125,6 +163,7 @@ impl Deletable<Connection, activity::Undo> for Like {
impl NewLike {
pub fn new(p: &Post, u: &User) -> Self {
// TODO: this URL is not valid
let ap_url = format!("{}/like/{}", u.ap_url, p.ap_url);
NewLike {
post_id: p.id,

@ -5,13 +5,16 @@ use guid_create::GUID;
use reqwest;
use std::{fs, path::Path};
use plume_common::{activity_pub::Id, utils::MediaProcessor};
use plume_common::{
activity_pub::{inbox::FromId, Id},
utils::MediaProcessor,
};
use instance::Instance;
use safe_string::SafeString;
use schema::medias;
use users::User;
use {ap_url, Connection, Error, Result};
use {ap_url, Connection, Error, PlumeRocket, Result};
#[derive(Clone, Identifiable, Queryable)]
pub struct Media {
@ -183,7 +186,8 @@ impl Media {
}
// TODO: merge with save_remote?
pub fn from_activity(conn: &Connection, image: &Image) -> Result<Media> {
pub fn from_activity(c: &PlumeRocket, image: &Image) -> Result<Media> {
let conn = &*c.conn;
let remote_url = image.object_props.url_string().ok()?;
let ext = remote_url
.rsplit('.')
@ -210,8 +214,8 @@ impl Media {
remote_url: None,
sensitive: image.object_props.summary_string().is_ok(),
content_warning: image.object_props.summary_string().ok(),
owner_id: User::from_url(
conn,
owner_id: User::from_id(
c,
image
.object_props
.attributed_to_link_vec::<Id>()
@ -219,7 +223,9 @@ impl Media {
.into_iter()
.next()?
.as_ref(),
)?
None,
)
.map_err(|(_, e)| e)?
.id,
},
)

@ -3,10 +3,10 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use comments::Comment;
use notifications::*;
use plume_common::activity_pub::inbox::Notify;
use posts::Post;
use schema::mentions;
use users::User;
use PlumeRocket;
use {Connection, Error, Result};
#[derive(Clone, Queryable, Identifiable)]
@ -55,8 +55,8 @@ impl Mention {
}
}
pub fn build_activity(conn: &Connection, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(conn, ment)?;
pub fn build_activity(c: &PlumeRocket, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(c, ment)?;
let mut mention = link::Mention::default();
mention.link_props.set_href_string(user.ap_url)?;
mention.link_props.set_name_string(format!("@{}", ment))?;
@ -126,10 +126,7 @@ impl Mention {
.map(|_| ())
.map_err(Error::from)
}
}
impl Notify<Connection> for Mention {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
let m = self.get_mentioned(conn)?;
Notification::insert(

@ -0,0 +1,80 @@
pub use self::module::PlumeRocket;
#[cfg(not(test))]
mod module {
use crate::db_conn::DbConn;
use crate::search;
use crate::users;
use rocket::{
request::{self, FromRequest, Request},
Outcome, State,
};
use scheduled_thread_pool::ScheduledThreadPool;
use std::sync::Arc;
/// Common context needed by most routes and operations on models
pub struct PlumeRocket {
pub conn: DbConn,
pub intl: rocket_i18n::I18n,
pub user: Option<users::User>,
pub searcher: Arc<search::Searcher>,
pub worker: Arc<ScheduledThreadPool>,
}
impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
let conn = request.guard::<DbConn>()?;
let intl = request.guard::<rocket_i18n::I18n>()?;
let user = request.guard::<users::User>().succeeded();
let worker = request.guard::<State<Arc<ScheduledThreadPool>>>()?;
let searcher = request.guard::<State<Arc<search::Searcher>>>()?;
Outcome::Success(PlumeRocket {
conn,
intl,
user,
worker: worker.clone(),
searcher: searcher.clone(),
})
}
}
}
#[cfg(test)]
mod module {
use crate::db_conn::DbConn;
use crate::search;
use crate::users;
use rocket::{
request::{self, FromRequest, Request},
Outcome, State,
};
use scheduled_thread_pool::ScheduledThreadPool;
use std::sync::Arc;
/// Common context needed by most routes and operations on models
pub struct PlumeRocket {
pub conn: DbConn,
pub user: Option<users::User>,
pub searcher: Arc<search::Searcher>,
pub worker: Arc<ScheduledThreadPool>,
}
impl<'a, 'r> FromRequest<'a, 'r> for PlumeRocket {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
let conn = request.guard::<DbConn>()?;
let user = request.guard::<users::User>().succeeded();
let worker = request.guard::<State<Arc<ScheduledThreadPool>>>()?;
let searcher = request.guard::<State<Arc<search::Searcher>>>()?;
Outcome::Success(PlumeRocket {
conn,
user,
worker: worker.clone(),
searcher: searcher.clone(),
})
}
}
}

@ -8,7 +8,6 @@ use canapi::{Error as ApiError, Provider};
use chrono::{NaiveDateTime, TimeZone, Utc};
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use heck::{CamelCase, KebabCase};
use scheduled_thread_pool::ScheduledThreadPool as Worker;
use serde_json;
use std::collections::HashSet;
@ -20,8 +19,8 @@ use plume_api::posts::PostEndpoint;
use plume_common::{
activity_pub::{
broadcast,
inbox::{Deletable, FromActivity},
Hashtag, Id, IntoId, Licensed, Source, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Hashtag, Id, IntoId, Licensed, Source, PUBLIC_VISIBILITY,
},
utils::md_to_html,
};
@ -31,7 +30,7 @@ use schema::posts;
use search::Searcher;
use tags::*;
use users::User;
use {ap_url, ApiResult, Connection, Error, Result, CONFIG};
use {ap_url, ApiResult, Connection, Error, PlumeRocket, Result, CONFIG};
pub type LicensedArticle = CustomObject<Licensed, Article>;
@ -68,17 +67,17 @@ pub struct NewPost {
pub cover_id: Option<i32>,
}
impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for Post {
impl Provider<PlumeRocket> for Post {
type Data = PostEndpoint;
fn get(
(conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
id: i32,
) -> ApiResult<PostEndpoint> {
fn get(rockets: &PlumeRocket, id: i32) -> ApiResult<PostEndpoint> {
let conn = &*rockets.conn;
if let Ok(post) = Post::get(conn, id) {
if !post.published
&& !user_id
.map(|u| post.is_author(conn, u).unwrap_or(false))
&& !rockets
.user
.as_ref()
.and_then(|u| post.is_author(conn, u.id).ok())
.unwrap_or(false)
{
return Err(ApiError::Authorization(
@ -115,10 +114,8 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
}
}
fn list(
(conn, _worker, _search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
filter: PostEndpoint,
) -> Vec<PostEndpoint> {
fn list(rockets: &PlumeRocket, filter: PostEndpoint) -> Vec<PostEndpoint> {
let conn = &*rockets.conn;
let mut query = posts::table.into_boxed();
if let Some(title) = filter.title {
query = query.filter(posts::title.eq(title));
@ -131,13 +128,15 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
}
query
.get_results::<Post>(*conn)
.get_results::<Post>(conn)
.map(|ps| {
ps.into_iter()
.filter(|p| {
p.published
|| user_id
.map(|u| p.is_author(conn, u).unwrap_or(false))
|| rockets
.user
.as_ref()
.and_then(|u| p.is_author(conn, u.id).ok())
.unwrap_or(false)
})
.map(|p| PostEndpoint {
@ -166,31 +165,33 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
}
fn update(
(_conn, _worker, _search, _user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
_rockets: &PlumeRocket,
_id: i32,
_new_data: PostEndpoint,
) -> ApiResult<PostEndpoint> {
unimplemented!()
}
fn delete(
(conn, _worker, search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
id: i32,
) {
let user_id = user_id.expect("Post as Provider::delete: not authenticated");
fn delete(rockets: &PlumeRocket, id: i32) {
let conn = &*rockets.conn;
let user_id = rockets
.user
.as_ref()
.expect("Post as Provider::delete: not authenticated")
.id;
if let Ok(post) = Post::get(conn, id) {
if post.is_author(conn, user_id).unwrap_or(false) {
post.delete(&(conn, search))
post.delete(conn, &rockets.searcher)
.expect("Post as Provider::delete: delete error");
}
}
}
fn create(
(conn, worker, search, user_id): &(&Connection, &Worker, &Searcher, Option<i32>),
query: PostEndpoint,
) -> ApiResult<PostEndpoint> {
if user_id.is_none() {
fn create(rockets: &PlumeRocket, query: PostEndpoint) -> ApiResult<PostEndpoint> {
let conn = &*rockets.conn;
let search = &rockets.searcher;
let worker = &rockets.worker;
if rockets.user.is_none() {
return Err(ApiError::Authorization(
"You are not authorized to create new articles.".to_string(),
));
@ -207,11 +208,10 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
let domain = &Instance::get_local(&conn)
.map_err(|_| ApiError::NotFound("posts::update: Error getting local instance".into()))?
.public_domain;
let author = User::get(
conn,
user_id.expect("<Post as Provider>::create: no user_id error"),
)
.map_err(|_| ApiError::NotFound("Author not found".into()))?;
let author = rockets
.user
.clone()
.ok_or_else(|| ApiError::NotFound("Author not found".into()))?;
let (content, mentions, hashtags) = md_to_html(
query.source.clone().unwrap_or_default().clone().as_ref(),
@ -298,7 +298,7 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option<i32>)> for P
for m in mentions.into_iter() {
Mention::from_activity(
&*conn,
&Mention::build_activity(&*conn, &m)
&Mention::build_activity(&rockets, &m)
.map_err(|_| ApiError::NotFound("Couldn't build mentions".into()))?,
post.id,
true,
@ -367,6 +367,7 @@ impl Post {
searcher.add_document(conn, &post)?;
Ok(post)
}
pub fn update(&self, conn: &Connection, searcher: &Searcher) -> Result<Self> {
diesel::update(self).set(self).execute(conn)?;
let post = Self::get(conn, self.id)?;
@ -374,6 +375,15 @@ impl Post {
Ok(post)
}
pub fn delete(&self, conn: &Connection, searcher: &Searcher) -> Result<()> {
for m in Mention::list_for_post(&conn, self.id)? {
m.delete(conn)?;
}
diesel::delete(self).execute(conn)?;
searcher.delete_document(self);
Ok(())
}
pub fn list_by_tag(
conn: &Connection,
tag: String,
@ -625,7 +635,7 @@ impl Post {
pub fn to_activity(&self, conn: &Connection) -> Result<LicensedArticle> {
let cc = self.get_receivers_urls(conn)?;
let to = vec![PUBLIC_VISIBILTY.to_string()];
let to = vec![PUBLIC_VISIBILITY.to_string()];
let mut mentions_json = Mention::list_for_post(conn, self.id)?
.into_iter()
@ -726,77 +736,6 @@ impl Post {
Ok(act)
}
pub fn handle_update(
conn: &Connection,
updated: &LicensedArticle,
searcher: &Searcher,
) -> Result<()> {
let id = updated.object.object_props.id_string()?;
let mut post = Post::find_by_ap_url(conn, &id)?;
if let Ok(title) = updated.object.object_props.name_string() {
post.slug = title.to_kebab_case();
post.title = title;
}
if let Ok(content) = updated.object.object_props.content_string() {
post.content = SafeString::new(&content);
}
if let Ok(subtitle) = updated.object.object_props.summary_string() {
post.subtitle = subtitle;
}
if let Ok(ap_url) = updated.object.object_props.url_string() {
post.ap_url = ap_url;
}
if let Ok(source) = updated.object.ap_object_props.source_object::<Source>() {
post.source = source.content;
}
if let Ok(license) = updated.custom_props.license_string() {
post.license = license;
}
let mut txt_hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(mention_tags)) =
updated.object.object_props.tag.clone()
{
let mut mentions = vec![];
let mut tags = vec![];
let mut hashtags = vec![];
for tag in mention_tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| mentions.push(m))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
if txt_hashtags.remove(&tag_name) {
hashtags.push(t);
} else {
tags.push(t);
}
Ok(())
})
.ok();
}
post.update_mentions(conn, mentions)?;
post.update_tags(conn, tags)?;
post.update_hashtags(conn, hashtags)?;
}
post.update(conn, searcher)?;
Ok(())
}
pub fn update_mentions(&self, conn: &Connection, mentions: Vec<link::Mention>) -> Result<()> {
let mentions = mentions
.into_iter()
@ -925,154 +864,374 @@ impl Post {
.and_then(|i| Media::get(conn, i).ok())
.and_then(|c| c.url(conn).ok())
}
pub fn build_delete(&self, conn: &Connection) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_authors(conn)?[0].clone().into_id())?;
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))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY)])?;
Ok(act)
}
}
impl<'a> FromActivity<LicensedArticle, (&'a Connection, &'a Searcher)> for Post {
impl FromId<PlumeRocket> for Post {
type Error = Error;
type Object = LicensedArticle;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(
(conn, searcher): &(&'a Connection, &'a Searcher),
article: LicensedArticle,
_actor: Id,
) -> Result<Post> {
fn from_activity(c: &PlumeRocket, article: LicensedArticle) -> Result<Self> {
let conn = &*c.conn;
let searcher = &c.searcher;
let license = article.custom_props.license_string().unwrap_or_default();
let article = article.object;
if let Ok(post) =
Post::find_by_ap_url(conn, &article.object_props.id_string().unwrap_or_default())
{
Ok(post)
} else {
let (blog, authors) = article
.object_props
.attributed_to_link_vec::<Id>()?
.into_iter()
.fold((None, vec![]), |(blog, mut authors), link| {
let url: String = link.into();
match User::from_url(conn, &url) {
Ok(u) => {
authors.push(u);
(blog, authors)
}
Err(_) => (blog.or_else(|| Blog::from_url(conn, &url).ok()), authors),
let (blog, authors) = article
.object_props
.attributed_to_link_vec::<Id>()?
.into_iter()
.fold((None, vec![]), |(blog, mut authors), link| {
let url: String = link.into();
match User::from_id(&c, &url, None) {
Ok(u) => {
authors.push(u);
(blog, authors)
}
});
Err(_) => (blog.or_else(|| Blog::from_id(&c, &url, None).ok()), authors),
}
});
let cover = article
.object_props
.icon_object::<Image>()
.ok()
.and_then(|img| Media::from_activity(conn, &img).ok().map(|m| m.id));
let cover = article
.object_props
.icon_object::<Image>()
.ok()
.and_then(|img| Media::from_activity(&c, &img).ok().map(|m| m.id));
let title = article.object_props.name_string()?;
let post = Post::insert(
let title = article.object_props.name_string()?;
let post = Post::insert(
conn,
NewPost {
blog_id: blog?.id,
slug: title.to_kebab_case(),
title,
content: SafeString::new(&article.object_props.content_string()?),
published: true,
license,
// FIXME: This is wrong: with this logic, we may use the display URL as the AP ID. We need two different fields
ap_url: article
.object_props
.url_string()
.or_else(|_| article.object_props.id_string())?,
creation_date: Some(article.object_props.published_utctime()?.naive_utc()),
subtitle: article.object_props.summary_string()?,
source: article.ap_object_props.source_object::<Source>()?.content,
cover_id: cover,
},
searcher,
)?;
for author in authors {
PostAuthor::insert(
conn,
NewPost {
blog_id: blog?.id,
slug: title.to_kebab_case(),
title,
content: SafeString::new(&article.object_props.content_string()?),
published: true,
license,
// FIXME: This is wrong: with this logic, we may use the display URL as the AP ID. We need two different fields
ap_url: article
.object_props
.url_string()
.or_else(|_| article.object_props.id_string())?,
creation_date: Some(article.object_props.published_utctime()?.naive_utc()),
subtitle: article.object_props.summary_string()?,
source: article.ap_object_props.source_object::<Source>()?.content,
cover_id: cover,
NewPostAuthor {
post_id: post.id,
author_id: author.id,
},
searcher,
)?;
}
for author in authors {
PostAuthor::insert(
conn,
NewPostAuthor {
post_id: post.id,
author_id: author.id,
},
)?;
}
// save mentions and tags
let mut hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(tags)) = article.object_props.tag.clone() {
for tag in tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| Mention::from_activity(conn, &m, post.id, true, true))
.ok();
// save mentions and tags
let mut hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(tags)) = article.object_props.tag.clone() {
for tag in tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| Mention::from_activity(conn, &m, post.id, true, true))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
Ok(Tag::from_activity(
conn,
&t,
post.id,
hashtags.remove(&tag_name),
))
})
.ok();
}
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
Ok(Tag::from_activity(
conn,
&t,
post.id,
hashtags.remove(&tag_name),
))
})
.ok();
}
Ok(post)
}
Ok(post)
}
}
impl<'a> Deletable<(&'a Connection, &'a Searcher), Delete> for Post {
impl AsObject<User, Create, &PlumeRocket> for Post {
type Error = Error;
type Output = Post;
fn delete(&self, (conn, searcher): &(&Connection, &Searcher)) -> Result<Delete> {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_authors(conn)?[0].clone().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))?;
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);
Ok(act)
fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result<Post> {
// TODO: check that _actor is actually one of the author?
Ok(self)
}
}
fn delete_id(
id: &str,
actor_id: &str,
(conn, searcher): &(&Connection, &Searcher),
) -> Result<Delete> {
let actor = User::find_by_ap_url(conn, actor_id)?;
let post = Post::find_by_ap_url(conn, id)?;
let can_delete = post
.get_authors(conn)?
impl AsObject<User, Delete, &PlumeRocket> for Post {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let can_delete = self
.get_authors(&c.conn)?
.into_iter()
.any(|a| actor.id == a.id);
if can_delete {
post.delete(&(conn, searcher))
self.delete(&c.conn, &c.searcher).map(|_| ())
} else {
Err(Error::Unauthorized)
}
}
}
pub struct PostUpdate {
pub ap_url: String,
pub title: Option<String>,
pub subtitle: Option<String>,
pub content: Option<String>,
pub cover: Option<i32>,
pub source: Option<String>,
pub license: Option<String>,
pub tags: Option<serde_json::Value>,
}
impl FromId<PlumeRocket> for PostUpdate {
type Error = Error;
type Object = LicensedArticle;
fn from_db(_: &PlumeRocket, _: &str) -> Result<Self> {
// Always fail because we always want to deserialize the AP object
Err(Error::NotFound)
}
fn from_activity(c: &PlumeRocket, updated: LicensedArticle) -> Result<Self> {
Ok(PostUpdate {
ap_url: updated.object.object_props.id_string()?,
title: updated.object.object_props.name_string().ok(),
subtitle: updated.object.object_props.summary_string().ok(),
content: updated.object.object_props.content_string().ok(),
cover: updated
.object
.object_props
.icon_object::<Image>()
.ok()
.and_then(|img| Media::from_activity(&c, &img).ok().map(|m| m.id)),
source: updated
.object
.ap_object_props
.source_object::<Source>()
.ok()
.map(|x| x.content),
license: updated.custom_props.license_string().ok(),
tags: updated.object.object_props.tag.clone(),
})
}
}
impl AsObject<User, Update, &PlumeRocket> for PostUpdate {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
let searcher = &c.searcher;
let mut post = Post::from_id(c, &self.ap_url, None).map_err(|(_, e)| e)?;
if !post.is_author(conn, actor.id)? {
// TODO: maybe the author was added in the meantime
return Err(Error::Unauthorized);
}
if let Some(title) = self.title {
post.slug = title.to_kebab_case();
post.title = title;
}
if let Some(content) = self.content {
post.content = SafeString::new(&content);
}
if let Some(subtitle) = self.subtitle {
post.subtitle = subtitle;
}
post.cover_id = self.cover;
if let Some(source) = self.source {
post.source = source;
}
if let Some(license) = self.license {
post.license = license;
}
let mut txt_hashtags = md_to_html(&post.source, "", false, None)
.2
.into_iter()
.map(|s| s.to_camel_case())
.collect::<HashSet<_>>();
if let Some(serde_json::Value::Array(mention_tags)) = self.tags {
let mut mentions = vec![];
let mut tags = vec![];
let mut hashtags = vec![];
for tag in mention_tags {
serde_json::from_value::<link::Mention>(tag.clone())
.map(|m| mentions.push(m))
.ok();
serde_json::from_value::<Hashtag>(tag.clone())
.map_err(Error::from)
.and_then(|t| {
let tag_name = t.name_string()?;
if txt_hashtags.remove(&tag_name) {
hashtags.push(t);
} else {
tags.push(t);
}
Ok(())
})
.ok();
}
post.update_mentions(conn, mentions)?;
post.update_tags(conn, tags)?;
post.update_hashtags(conn, hashtags)?;
}
post.update(conn, searcher)?;
Ok(())
}
}
impl IntoId for Post {
fn into_id(self) -> Id {
Id::new(self.ap_url.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inbox::{inbox, tests::fill_database, InboxResult};
use crate::safe_string::SafeString;
use crate::tests::rockets;
use diesel::Connection;
// creates a post, get it's Create activity, delete the post,
// "send" the Create to the inbox, and check it works
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&r);
let post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "yo".into(),
title: "Yo".into(),
content: SafeString::new("Hello"),
published: true,
license: "WTFPL".to_string(),
creation_date: None,
ap_url: String::new(), // automatically updated when inserting
subtitle: "Testing".into(),
source: "Hello".into(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
PostAuthor::insert(
conn,
NewPostAuthor {
post_id: post.id,
author_id: users[0].id,
},
)
.unwrap();
let create = post.create_activity(conn).unwrap();
post.delete(conn, &r.searcher).unwrap();
match inbox(&r, serde_json::to_value(create).unwrap()).unwrap() {
InboxResult::Post(p) => {
assert!(p.is_author(conn, users[0].id).unwrap());
assert_eq!(p.source, "Hello".to_owned());
assert_eq!(p.blog_id, blogs[0].id);
assert_eq!(p.content, SafeString::new("Hello"));
assert_eq!(p.subtitle, "Testing".to_owned());
assert_eq!(p.title, "Yo".to_owned());
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
#[test]
fn licensed_article_serde() {
let mut article = Article::default();
article.object_props.set_id_string("Yo".into()).unwrap();
let mut license = Licensed::default();
license.set_license_string("WTFPL".into()).unwrap();
let full_article = LicensedArticle::new(article, license);
let json = serde_json::to_value(full_article).unwrap();
let article_from_json: LicensedArticle = serde_json::from_value(json).unwrap();
assert_eq!(
"Yo",
&article_from_json.object.object_props.id_string().unwrap()
);
assert_eq!(
"WTFPL",
&article_from_json.custom_props.license_string().unwrap()
);
}
#[test]
fn licensed_article_deserialization() {
let json = json!({
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
});
let article: LicensedArticle = serde_json::from_value(json).unwrap();
assert_eq!(
"https://plu.me/~/Blog/my-article",
&article.object.object_props.id_string().unwrap()
);
}
}

@ -4,13 +4,13 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use notifications::*;
use plume_common::activity_pub::{
inbox::{Deletable, FromActivity, Notify},
Id, IntoId, PUBLIC_VISIBILTY,
inbox::{AsObject, FromId},
Id, IntoId, PUBLIC_VISIBILITY,
};
use posts::Post;
use schema::reshares;
use users::User;
use {Connection, Error, Result};
use {Connection, Error, PlumeRocket, Result};
#[derive(Clone, Queryable, Identifiable)]
pub struct Reshare {
@ -69,37 +69,14 @@ impl Reshare {
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
act.object_props.set_id_string(self.ap_url.clone())?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
Ok(act)
}
}
impl FromActivity<Announce, Connection> for Reshare {
type Error = Error;
fn from_activity(conn: &Connection, announce: Announce, _actor: Id) -> Result<Reshare> {
let user = User::from_url(conn, announce.announce_props.actor_link::<Id>()?.as_ref())?;
let post =
Post::find_by_ap_url(conn, announce.announce_props.object_link::<Id>()?.as_ref())?;
let reshare = Reshare::insert(
conn,
NewReshare {
post_id: post.id,
user_id: user.id,
ap_url: announce.object_props.id_string().unwrap_or_default(),
},
)?;
reshare.notify(conn)?;
Ok(reshare)
}
}
impl Notify<Connection> for Reshare {
type Error = Error;
fn notify(&self, conn: &Connection) -> Result<()> {
pub fn notify(&self, conn: &Connection) -> Result<()> {
let post = self.get_post(conn)?;
for author in post.get_authors(conn)? {
Notification::insert(
@ -113,19 +90,8 @@ impl Notify<Connection> for Reshare {
}
Ok(())
}
}
impl Deletable<Connection, Undo> for Reshare {
type Error = Error;
fn delete(&self, conn: &Connection) -> Result<Undo> {
diesel::delete(self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut act = Undo::default();
act.undo_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
@ -133,17 +99,88 @@ impl Deletable<Connection, Undo> for Reshare {
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link(Id::new(PUBLIC_VISIBILTY.to_string()))?;
act.object_props.set_cc_link_vec::<Id>(vec![])?;
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
Ok(act)
}
}
impl AsObject<User, Announce, &PlumeRocket> for Post {
type Error = Error;
type Output = Reshare;
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Reshare> {
let conn = &*c.conn;
let reshare = Reshare::insert(
conn,
NewReshare {
post_id: self.id,
user_id: actor.id,
ap_url: id.to_string(),
},
)?;
reshare.notify(conn)?;
Ok(reshare)
}
}
impl FromId<PlumeRocket> for Reshare {
type Error = Error;
type Object = Announce;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Reshare::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, act: Announce) -> Result<Self> {
let res = Reshare::insert(
&c.conn,
NewReshare {
post_id: Post::from_id(
c,
&{
let res: String = act.announce_props.object_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(
c,
&{
let res: String = act.announce_props.actor_link::<Id>()?.into();
res
},
None,
)
.map_err(|(_, e)| e)?
.id,
ap_url: act.object_props.id_string()?,
},
)?;
res.notify(&c.conn)?;
Ok(res)
}
}
impl AsObject<User, Undo, &PlumeRocket> for Reshare {
type Error = Error;
type Output = ();
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(&conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) -> Result<Undo> {
let reshare = Reshare::find_by_ap_url(conn, id)?;
let actor = User::find_by_ap_url(conn, actor_id)?;
if actor.id == reshare.user_id {
reshare.delete(conn)
Ok(())
} else {
Err(Error::Unauthorized)
}

@ -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))

@ -12,7 +12,7 @@ use openssl::{
};
use plume_common::activity_pub::{
ap_accept_header,
inbox::{Deletable, WithInbox},
inbox::{AsActor, FromId},
sign::{gen_keypair, Signer},
ActivityStream, ApSignature, Id, IntoId, PublicKey,
};
@ -43,7 +43,7 @@ use posts::Post;
use safe_string::SafeString;
use schema::users;
use search::Searcher;
use {ap_url, Connection, Error, Result, CONFIG};
use {ap_url, Connection, Error, PlumeRocket, Result};
pub type CustomPerson = CustomObject<ApSignature, Person>;
@ -168,7 +168,7 @@ impl User {
.unwrap_or(&0)
> &0;
if !has_other_authors {
Post::get(conn, post_id)?.delete(&(conn, searcher))?;
Post::get(conn, post_id)?.delete(conn, searcher)?;
}
}
@ -230,27 +230,27 @@ impl User {
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<User> {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<User> {
let from_db = users::table
.filter(users::fqn.eq(fqn))
.limit(1)
.load::<User>(conn)?
.load::<User>(&*c.conn)?
.into_iter()
.next();
if let Some(from_db) = from_db {
Ok(from_db)
} else {
User::fetch_from_webfinger(conn, fqn)
User::fetch_from_webfinger(c, fqn)
}
}
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<User> {
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<User> {
let link = resolve(acct.to_owned(), true)?
.links
.into_iter()
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
.ok_or(Error::Webfinger)?;
User::fetch_from_url(conn, link.href.as_ref()?)
User::from_id(c, link.href.as_ref()?, None).map_err(|(_, e)| e)
}
fn fetch(url: &str) -> Result<CustomPerson> {
@ -274,97 +274,8 @@ impl User {
Ok(json)
}
pub fn fetch_from_url(conn: &Connection, url: &str) -> Result<User> {
User::fetch(url)
.and_then(|json| User::from_activity(conn, &json, Url::parse(url)?.host_str()?))
}
fn from_activity(conn: &Connection, acct: &CustomPerson, inst: &str) -> Result<User> {
let instance = Instance::find_by_domain(conn, inst).or_else(|_| {
Instance::insert(
conn,
NewInstance {
name: inst.to_owned(),
public_domain: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
if acct
.object
.ap_actor_props
.preferred_username_string()?
.contains(&['<', '>', '&', '@', '\'', '"'][..])
{
return Err(Error::InvalidValue);
}
let user = User::insert(
conn,
NewUser {
username: acct
.object
.ap_actor_props
.preferred_username_string()
.unwrap(),
display_name: acct.object.object_props.name_string()?,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
is_admin: false,
summary: acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
email: None,
hashed_password: None,
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
shared_inbox_url: acct
.object
.ap_actor_props
.endpoints_endpoint()
.and_then(|e| e.shared_inbox_string())
.ok(),
followers_endpoint: acct.object.ap_actor_props.followers_string()?,
avatar_id: None,
},
)?;
let avatar = Media::save_remote(
conn,
acct.object
.object_props
.icon_image()?
.object_props
.url_string()?,
&user,
);
if let Ok(avatar) = avatar {
user.set_avatar(conn, avatar.id)?;
}
Ok(user)
pub fn fetch_from_url(c: &PlumeRocket, url: &str) -> Result<User> {
User::fetch(url).and_then(|json| User::from_activity(c, json))
}
pub fn refetch(&self, conn: &Connection) -> Result<()> {
@ -688,10 +599,11 @@ impl User {
.ap_actor_props
.set_followers_string(self.followers_endpoint.clone())?;
let mut endpoints = Endpoint::default();
endpoints
.set_shared_inbox_string(ap_url(&format!("{}/inbox/", CONFIG.base_url.as_str())))?;
actor.ap_actor_props.set_endpoints_endpoint(endpoints)?;
if let Some(shared_inbox_url) = self.shared_inbox_url.clone() {
let mut endpoints = Endpoint::default();
endpoints.set_shared_inbox_string(shared_inbox_url)?;
actor.ap_actor_props.set_endpoints_endpoint(endpoints)?;
}
let mut public_key = PublicKey::default();
public_key.set_id_string(format!("{}#main-key", self.ap_url))?;
@ -752,18 +664,6 @@ impl User {
})
}
pub fn from_url(conn: &Connection, url: &str) -> Result<User> {
User::find_by_ap_url(conn, url).or_else(|_| {
// The requested user was not in the DB
// We try to fetch it if it is remote
if Url::parse(&url)?.host_str()? != CONFIG.base_url.as_str() {
User::fetch_from_url(conn, url)
} else {
Err(Error::NotFound)
}
})
}
pub fn set_avatar(&self, conn: &Connection, id: i32) -> Result<()> {
diesel::update(self)
.set(users::avatar_id.eq(id))
@ -807,7 +707,100 @@ impl IntoId for User {
impl Eq for User {}
impl WithInbox for User {
impl FromId<PlumeRocket> for User {
type Error = Error;
type Object = CustomPerson;
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(c: &PlumeRocket, acct: CustomPerson) -> Result<Self> {
let url = Url::parse(&acct.object.object_props.id_string()?)?;
let inst = url.host_str()?;
let instance = Instance::find_by_domain(&c.conn, inst).or_else(|_| {
Instance::insert(
&c.conn,
NewInstance {
name: inst.to_owned(),
public_domain: inst.to_owned(),
local: false,
// We don't really care about all the following for remote instances
long_description: SafeString::new(""),
short_description: SafeString::new(""),
default_license: String::new(),
open_registrations: true,
short_description_html: String::new(),
long_description_html: String::new(),
},
)
})?;
let username = acct.object.ap_actor_props.preferred_username_string()?;
if username.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
return Err(Error::InvalidValue);
}
let user = User::insert(
&c.conn,
NewUser {
display_name: acct
.object
.object_props
.name_string()
.unwrap_or_else(|_| username.clone()),
username,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
is_admin: false,
summary: acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
email: None,
hashed_password: None,
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
shared_inbox_url: acct
.object
.ap_actor_props
.endpoints_endpoint()
.and_then(|e| e.shared_inbox_string())
.ok(),
followers_endpoint: acct.object.ap_actor_props.followers_string()?,
avatar_id: None,
},
)?;
if let Ok(icon) = acct.object.object_props.icon_image() {
if let Ok(url) = icon.object_props.url_string() {
let avatar = Media::save_remote(&c.conn, url, &user);
if let Ok(avatar) = avatar {
user.set_avatar(&c.conn, avatar.id)?;
}
}
}
Ok(user)
}
}
impl AsActor<&PlumeRocket> for User {
fn get_inbox_url(&self) -> String {
self.inbox_url.clone()
}
@ -893,7 +886,7 @@ pub(crate) mod tests {
use diesel::Connection;
use instance::{tests as instance_tests, Instance};
use search::tests::get_searcher;
use tests::db;
use tests::{db, rockets};
use Connection as Conn;
pub(crate) fn fill_database(conn: &Conn) -> Vec<User> {
@ -933,7 +926,8 @@ pub(crate) mod tests {
#[test]
fn find_by() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
fill_database(conn);
let test_user = NewUser::new_local(
@ -955,7 +949,7 @@ pub(crate) mod tests {
);
assert_eq!(
test_user.id,
User::find_by_fqn(conn, &test_user.fqn).unwrap().id
User::find_by_fqn(&r, &test_user.fqn).unwrap().id
);
assert_eq!(
test_user.id,
@ -1089,4 +1083,32 @@ pub(crate) mod tests {
Ok(())
});
}
#[test]
fn self_federation() {
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let users = fill_database(conn);
let ap_repr = users[0].to_activity(conn).unwrap();
users[0].delete(conn, &*r.searcher).unwrap();
let user = User::from_activity(&r, ap_repr).unwrap();
assert_eq!(user.username, users[0].username);
assert_eq!(user.display_name, users[0].display_name);
assert_eq!(user.outbox_url, users[0].outbox_url);
assert_eq!(user.inbox_url, users[0].inbox_url);
assert_eq!(user.instance_id, users[0].instance_id);
assert_eq!(user.ap_url, users[0].ap_url);
assert_eq!(user.public_key, users[0].public_key);
assert_eq!(user.shared_inbox_url, users[0].shared_inbox_url);
assert_eq!(user.followers_endpoint, users[0].followers_endpoint);
assert_eq!(user.avatar_url(conn), users[0].avatar_url(conn));
assert_eq!(user.fqn, users[0].fqn);
assert_eq!(user.summary_html, users[0].summary_html);
Ok(())
});
}
}

Binary file not shown.

Binary file not shown.

@ -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 <em>{0}</em> users"
#, fuzzy
msgid "Home to <em>{0}</em> people"
msgstr "يستضيف <em>{0}</em> مستخدمين"
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> people"
msgstr "يستضيف <em>{0}</em> مستخدمين"
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 <em>{0}</em> users"
#~ msgstr "يستضيف <em>{0}</em> مستخدمين"
#~ 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 "لا يمكن ترك التعليق فارغا"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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] ""

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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] ""

@ -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 <em>{0}</em> users"
#, fuzzy
msgid "Home to <em>{0}</em> people"
msgstr "Accueille <em>{0} personnes"
msgid "Who wrote <em>{0}</em> 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 dun même site."
@ -421,10 +426,6 @@ msgstr ""
"pouvez interagir avec directement depuis dautres plateformes telles que "
"Mastodon."
#, fuzzy
msgid "Home to <em>{0}</em> people"
msgstr "Accueille <em>{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 <em>{0}</em> users"
#~ msgstr "Accueille <em>{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 ""

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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 "コメントは空にできません"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr "Używana przez <em>{0}</em> użytkowników"
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> people"
msgstr "Używana przez <em>{0}</em> 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 <em>{0}</em> users"
#~ msgstr "Używana przez <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
#, fuzzy
msgid "Home to <em>{0}</em> people"
msgstr "Acolhe <em>{0}</em> pessoas"
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> people"
msgstr "Acolhe <em>{0}</em> 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 <em>{0}</em> users"
#~ msgstr "Acolhe <em>{0}</em> 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"

@ -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 <em>{0}</em> users"
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> 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 <em>{0}</em> 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 "Ваш комментарий не может быть пустым"

@ -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?<query..>")]
pub fn oauth(query: Form<OAuthRequest>, conn: DbConn) -> Result<Json<serde_json::Value>, ApiError> {
let app = App::find_by_client_id(&*conn, &query.client_id)?;
pub fn oauth(
query: Form<OAuthRequest>,
rockets: PlumeRocket,
) -> Result<Json<serde_json::Value>, 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<OAuthRequest>, conn: DbConn) -> Result<Json<serde_json:
// Making fake password verification to avoid different
// response times that would make it possible to know
// if a username is registered or not.
User::get(&*conn, 1)?.auth(&query.password);
User::get(conn, 1)?.auth(&query.password);
Ok(Json(json!({
"error": "Invalid credentials"
})))

@ -1,74 +1,45 @@
use canapi::{Error as ApiError, Provider};
use rocket::http::uri::Origin;
use rocket_contrib::json::Json;
use scheduled_thread_pool::ScheduledThreadPool;
use serde_json;
use serde_qs;
use api::authorization::*;
use plume_api::posts::PostEndpoint;
use plume_models::{
db_conn::DbConn, posts::Post, search::Searcher as UnmanagedSearcher, Connection,
};
use {Searcher, Worker};
use plume_models::{posts::Post, users::User, PlumeRocket};
#[get("/posts/<id>")]
pub fn get(
id: i32,
conn: DbConn,
worker: Worker,
auth: Option<Authorization<Read, Post>>,
search: Searcher,
mut rockets: PlumeRocket,
) -> Json<serde_json::Value> {
let post = <Post as Provider<(
&Connection,
&ScheduledThreadPool,
&UnmanagedSearcher,
Option<i32>,
)>>::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 = <Post as Provider<PlumeRocket>>::get(&rockets, id).ok();
Json(json!(post))
}
#[get("/posts")]
pub fn list(
conn: DbConn,
uri: &Origin,
worker: Worker,
auth: Option<Authorization<Read, Post>>,
search: Searcher,
mut rockets: PlumeRocket,
) -> Json<serde_json::Value> {
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 = <Post as Provider<(
&Connection,
&ScheduledThreadPool,
&UnmanagedSearcher,
Option<i32>,
)>>::list(
&(&*conn, &worker, &search, auth.map(|a| a.0.user_id)),
query,
);
let post = <Post as Provider<PlumeRocket>>::list(&rockets, query);
Json(json!(post))
}
#[post("/posts", data = "<payload>")]
pub fn create(
conn: DbConn,
payload: Json<PostEndpoint>,
worker: Worker,
auth: Authorization<Write, Post>,
search: Searcher,
payload: Json<PostEndpoint>,
mut rockets: PlumeRocket,
) -> Json<serde_json::Value> {
let new_post = <Post as Provider<(
&Connection,
&ScheduledThreadPool,
&UnmanagedSearcher,
Option<i32>,
)>>::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 = <Post as Provider<PlumeRocket>>::create(&rockets, (*payload).clone());
Json(new_post.map(|p| json!(p)).unwrap_or_else(|e| {
json!({
"error": "Invalid data, couldn't create new post",

@ -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");;
Ok(())
}
"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::<Tombstone>()?
.object_props
.id_string()?,
actor_id.as_ref(),
&(conn, searcher),
)
.ok();
Comment::delete_id(
&act.delete_props
.object_object::<Tombstone>()?
.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::<Like>()?
.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::<Announce>()?
.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::<FollowAct>()?
.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");
pub fn handle_incoming(
rockets: PlumeRocket,
data: SignedJson<serde_json::Value>,
headers: Headers,
) -> Result<String, status::BadRequest<&'static str>> {
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)
}
_ => 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;

@ -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<UnmanagedSearcher>>;
/// Initializes a database pool.
@ -241,7 +239,7 @@ Then try to restart Plume
.manage(Arc::new(Mutex::new(mail)))
.manage::<Arc<Mutex<Vec<routes::session::ResetRequest>>>>(Arc::new(Mutex::new(vec![])))
.manage(dbpool)
.manage(workpool)
.manage(Arc::new(workpool))
.manage(searcher)
.manage(include_i18n!())
.attach(

@ -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("/~/<name>?<page>", rank = 2)]
pub fn details(name: String, page: Option<Page>, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
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<Page>, rockets: PlumeRocket) -> Result
#[get("/~/<name>", rank = 1)]
pub fn activity_details(
name: String,
conn: DbConn,
rockets: PlumeRocket,
_ap: ApRequest,
) -> Option<ActivityStream<CustomGroup>> {
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 = "<form>")]
pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> Result<Redirect, Ructe> {
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<NewBlogForm>, rockets: PlumeRocket) -> Result<Re
Ok(Redirect::to(uri!(details: name = slug.clone(), page = _)))
} else {
Err(render!(blogs::new(
&(&*conn, &intl.catalog, Some(user)),
&(&*conn, intl, Some(user)),
&*form,
errors
)))
@ -148,8 +151,8 @@ pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> Result<Re
#[post("/~/<name>/delete")]
pub fn delete(name: String, rockets: PlumeRocket) -> Result<Redirect, Ructe> {
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("/~/<name>/edit")]
pub fn edit(
conn: DbConn,
name: String,
user: Option<User>,
intl: I18n,
) -> Result<Ructe, ErrorPage> {
let blog = Blog::find_by_fqn(&*conn, &name)?;
if user
pub fn edit(name: String, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
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("/~/<name>/edit", data = "<form>")]
pub fn update(
conn: DbConn,
name: String,
user: Option<User>,
intl: I18n,
form: LenientForm<EditForm>,
rockets: PlumeRocket,
) -> Result<Redirect, Ructe> {
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("/~/<name>/outbox")]
pub fn outbox(name: String, conn: DbConn) -> Option<ActivityStream<OrderedCollection>> {
let blog = Blog::find_by_fqn(&*conn, &name).ok()?;
Some(blog.outbox(&*conn).ok()?)
pub fn outbox(name: String, rockets: PlumeRocket) -> Option<ActivityStream<OrderedCollection>> {
let blog = Blog::find_by_fqn(&rockets, &name).ok()?;
Some(blog.outbox(&*rockets.conn).ok()?)
}
#[get("/~/<name>/atom.xml")]
pub fn atom_feed(name: String, conn: DbConn) -> Option<Content<String>> {
let blog = Blog::find_by_fqn(&*conn, &name).ok()?;
pub fn atom_feed(name: String, rockets: PlumeRocket) -> Option<Content<String>> {
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)

@ -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<NewCommentForm>,
user: User,
conn: DbConn,
worker: Worker,
intl: I18n,
rockets: PlumeRocket,
) -> Result<Redirect, Ructe> {
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<Redirect, ErrorPage> {
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<ActivityStream<Note>> {
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)
}

@ -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 = "<data>")]
pub fn shared_inbox(
conn: DbConn,
data: SignedJson<serde_json::Value>,
rockets: PlumeRocket,
data: inbox::SignedJson<serde_json::Value>,
headers: Headers,
searcher: Searcher,
) -> Result<String, status::BadRequest<&'static str>> {
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/<version>")]

@ -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("/~/<blog>/<slug>/like")]
pub fn create(
blog: String,
slug: String,
user: User,
conn: DbConn,
worker: Worker,
rockets: PlumeRocket,
) -> Result<Redirect, ErrorPage> {
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(

@ -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<User>,
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<PlumeRocket<'a>, ()> {
let conn = request.guard::<DbConn>()?;
let intl = request.guard::<I18n>()?;
let user = request.guard::<User>().succeeded();
let worker = request.guard::<Worker>()?;
let searcher = request.guard::<Searcher>()?;
rocket::Outcome::Success(PlumeRocket {
conn,
intl,
user,
worker,
searcher,
})
}
}
use plume_models::{posts::Post, Connection};
const ITEMS_PER_PAGE: i32 = 12;

@ -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("/~/<blog>/<slug>?<responding_to>", rank = 4)]
pub fn details(
blog: String,
slug: String,
conn: DbConn,
user: Option<User>,
responding_to: Option<i32>,
intl: I18n,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
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<ActivityStream<LicensedArticle>, Option<String>> {
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<Redirect> {
#[get("/~/<blog>/new", rank = 1)]
pub fn new(blog: String, cl: ContentLen, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
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<Ructe, ErrorPage> {
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<NewPostForm>,
rockets: PlumeRocket,
) -> Result<Redirect, Ructe> {
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<Redirect, Result<Ructe, ErrorPage>> {
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<Redirect, ErrorPage> {
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 = _),

@ -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("/~/<blog>/<slug>/reshare")]
pub fn create(
blog: String,
slug: String,
user: User,
conn: DbConn,
worker: Worker,
rockets: PlumeRocket,
) -> Result<Redirect, ErrorPage> {
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(

@ -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 = "<form>")]
pub fn create(
conn: DbConn,
form: LenientForm<LoginForm>,
flash: Option<FlashMessage>,
mut cookies: Cookies,
intl: I18n,
rockets: PlumeRocket,
) -> Result<Redirect, Ructe> {
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

@ -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<User>) -> Result<Redirect, Flash<Redirect>> {
@ -46,21 +40,19 @@ pub fn me(user: Option<User>) -> Result<Redirect, Flash<Redirect>> {
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<Ructe, ErrorPage> {
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::<Create>()
@ -68,12 +60,8 @@ pub fn details(
{
match create_act.create_props.object_object::<LicensedArticle>() {
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<Redirect> {
}
#[post("/@/<name>/follow")]
pub fn follow(
name: String,
conn: DbConn,
user: User,
worker: Worker,
) -> Result<Redirect, ErrorPage> {
let target = User::find_by_fqn(&*conn, &name)?;
pub fn follow(name: String, user: User, rockets: PlumeRocket) -> Result<Redirect, ErrorPage> {
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<Redirect> {
#[get("/@/<name>/followers?<page>", rank = 2)]
pub fn followers(
name: String,
conn: DbConn,
account: Option<User>,
page: Option<Page>,
intl: I18n,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
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("/@/<name>/followed?<page>", rank = 2)]
pub fn followed(
name: String,
conn: DbConn,
account: Option<User>,
page: Option<Page>,
intl: I18n,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
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("/@/<name>", rank = 1)]
pub fn activity_details(
name: String,
conn: DbConn,
rockets: PlumeRocket,
_ap: ApRequest,
) -> Option<ActivityStream<CustomPerson>> {
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("/@/<name>/delete")]
pub fn delete(
name: String,
conn: DbConn,
user: User,
mut cookies: Cookies,
searcher: Searcher,
rockets: PlumeRocket,
) -> Result<Redirect, ErrorPage> {
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<NewUserForm>, intl: I18n) -> Resul
}
#[get("/@/<name>/outbox")]
pub fn outbox(name: String, conn: DbConn) -> Option<ActivityStream<OrderedCollection>> {
let user = User::find_by_fqn(&*conn, &name).ok()?;
user.outbox(&*conn).ok()
pub fn outbox(name: String, rockets: PlumeRocket) -> Option<ActivityStream<OrderedCollection>> {
let user = User::find_by_fqn(&rockets, &name).ok()?;
user.outbox(&*rockets.conn).ok()
}
#[post("/@/<name>/inbox", data = "<data>")]
pub fn inbox(
name: String,
conn: DbConn,
data: SignedJson<serde_json::Value>,
data: inbox::SignedJson<serde_json::Value>,
headers: Headers,
searcher: Searcher,
) -> Result<String, Option<status::BadRequest<&'static str>>> {
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<String, status::BadRequest<&'static str>> {
User::find_by_fqn(&rockets, &name).map_err(|_| status::BadRequest(Some("User not found")))?;
inbox::handle_incoming(rockets, data, headers)
}
#[get("/@/<name>/followers", rank = 1)]
pub fn ap_followers(
name: String,
conn: DbConn,
rockets: PlumeRocket,
_ap: ApRequest,
) -> Option<ActivityStream<OrderedCollection>> {
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("/@/<name>/atom.xml")]
pub fn atom_feed(name: String, conn: DbConn) -> Option<Content<String>> {
let author = User::find_by_fqn(&*conn, &name).ok()?;
pub fn atom_feed(name: String, rockets: PlumeRocket) -> Option<Content<String>> {
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::<Vec<Entry>>(),
)
.build()

@ -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<String> {
@ -43,25 +43,25 @@ pub fn host_meta() -> String {
struct WebfingerResolver;
impl Resolver<DbConn> for WebfingerResolver {
impl Resolver<PlumeRocket> for WebfingerResolver {
fn instance_domain<'a>() -> &'a str {
CONFIG.base_url.as_str()
}
fn find(acct: String, conn: DbConn) -> Result<Webfinger, ResolverError> {
User::find_by_fqn(&*conn, &acct)
.and_then(|usr| usr.webfinger(&*conn))
fn find(acct: String, ctx: PlumeRocket) -> Result<Webfinger, ResolverError> {
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?<resource>")]
pub fn webfinger(resource: String, conn: DbConn) -> Content<String> {
match WebfingerResolver::endpoint(resource, conn)
pub fn webfinger(resource: String, rockets: PlumeRocket) -> Content<String> {
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),

Loading…
Cancel
Save