Big repository reorganization
The code is divided in three crates: - plume-common, for the ActivityPub module, and some common utils - plume-models, for the models and database-related code - plume, the app itself This new organization will allow to test it more easily, but also to create other tools that only reuse a little part of the code (for instance a Wordpress import tool, that would just use the plume-models crate)
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
use activitypub::{Object, activity::Create};
|
||||
|
||||
use activity_pub::Id;
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
pub trait FromActivity<T: Object, C>: Sized {
|
||||
fn from_activity(conn: &C, obj: T, actor: Id) -> Self;
|
||||
|
||||
fn try_from_activity(conn: &C, act: Create) -> bool {
|
||||
if let Ok(obj) = act.create_props.object_object() {
|
||||
Self::from_activity(conn, obj, act.create_props.actor_link::<Id>().unwrap());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Notify<C> {
|
||||
fn notify(&self, conn: &C);
|
||||
}
|
||||
|
||||
pub trait Deletable<C> {
|
||||
/// true if success
|
||||
fn delete_activity(conn: &C, id: Id) -> bool;
|
||||
}
|
||||
|
||||
pub trait WithInbox {
|
||||
fn get_inbox_url(&self) -> String;
|
||||
|
||||
fn get_shared_inbox_url(&self) -> Option<String>;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use activitypub::{Activity, Actor, Object, Link};
|
||||
use array_tool::vec::Uniq;
|
||||
use reqwest::Client;
|
||||
use rocket::{
|
||||
http::Status,
|
||||
response::{Response, Responder},
|
||||
request::Request
|
||||
};
|
||||
use serde_json;
|
||||
|
||||
use self::sign::Signable;
|
||||
|
||||
pub mod inbox;
|
||||
pub mod request;
|
||||
pub mod sign;
|
||||
|
||||
pub const CONTEXT_URL: &'static str = "https://www.w3.org/ns/activitystreams";
|
||||
pub const PUBLIC_VISIBILTY: &'static str = "https://www.w3.org/ns/activitystreams#Public";
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn ap_url(url: String) -> String {
|
||||
format!("http://{}", url)
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub fn ap_url(url: String) -> String {
|
||||
format!("https://{}", url)
|
||||
}
|
||||
|
||||
pub fn context() -> serde_json::Value {
|
||||
json!([
|
||||
CONTEXT_URL,
|
||||
"https://w3id.org/security/v1",
|
||||
{
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
"sensitive": "as:sensitive",
|
||||
"movedTo": "as:movedTo",
|
||||
"Hashtag": "as:Hashtag",
|
||||
"ostatus":"http://ostatus.org#",
|
||||
"atomUri":"ostatus:atomUri",
|
||||
"inReplyToAtomUri":"ostatus:inReplyToAtomUri",
|
||||
"conversation":"ostatus:conversation",
|
||||
"toot":"http://joinmastodon.org/ns#",
|
||||
"Emoji":"toot:Emoji",
|
||||
"focalPoint": {
|
||||
"@container":"@list",
|
||||
"@id":"toot:focalPoint"
|
||||
},
|
||||
"featured":"toot:featured"
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
pub struct ActivityStream<T> (T);
|
||||
|
||||
impl<T> ActivityStream<T> {
|
||||
pub fn new(t: T) -> ActivityStream<T> {
|
||||
ActivityStream(t)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, O: Object> Responder<'r> for ActivityStream<O> {
|
||||
fn respond_to(self, request: &Request) -> Result<Response<'r>, Status> {
|
||||
let mut json = serde_json::to_value(&self.0).map_err(|_| Status::InternalServerError)?;
|
||||
json["@context"] = context();
|
||||
serde_json::to_string(&json).respond_to(request).map(|r| Response::build_from(r)
|
||||
.raw_header("Content-Type", "application/activity+json")
|
||||
.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn broadcast<A: Activity, S: sign::Signer, T: inbox::WithInbox + Actor>(sender: &S, act: A, to: Vec<T>) {
|
||||
let boxes = to.into_iter()
|
||||
.map(|u| u.get_shared_inbox_url().unwrap_or(u.get_inbox_url()))
|
||||
.collect::<Vec<String>>()
|
||||
.unique();
|
||||
|
||||
let mut act = serde_json::to_value(act).unwrap();
|
||||
act["@context"] = context();
|
||||
let signed = act.sign(sender);
|
||||
|
||||
for inbox in boxes {
|
||||
// TODO: run it in Sidekiq or something like that
|
||||
let res = Client::new()
|
||||
.post(&inbox[..])
|
||||
.headers(request::headers())
|
||||
.header(request::signature(sender, request::headers()))
|
||||
.header(request::digest(signed.to_string()))
|
||||
.body(signed.to_string())
|
||||
.send();
|
||||
match res {
|
||||
Ok(mut r) => println!("Successfully sent activity to inbox ({})\n\n{:?}", inbox, r.text().unwrap()),
|
||||
Err(e) => println!("Error while sending to inbox ({:?})", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Id(String);
|
||||
|
||||
impl Id {
|
||||
pub fn new<T: Into<String>>(id: T) -> Id {
|
||||
Id(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<String> for Id {
|
||||
fn into(self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoId {
|
||||
fn into_id(self) -> Id;
|
||||
}
|
||||
|
||||
impl Link for Id {}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApSignature {
|
||||
#[activitystreams(concrete(PublicKey), functional)]
|
||||
pub public_key: Option<serde_json::Value>
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicKey {
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub id: Option<serde_json::Value>,
|
||||
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub owner: Option<serde_json::Value>,
|
||||
|
||||
#[activitystreams(concrete(String), functional)]
|
||||
pub public_key_pem: Option<serde_json::Value>
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use base64;
|
||||
use openssl::hash::{Hasher, MessageDigest};
|
||||
use reqwest::{
|
||||
mime::Mime,
|
||||
header::{ContentType, Date, Headers, UserAgent}
|
||||
};
|
||||
use std::{
|
||||
str::FromStr,
|
||||
time::SystemTime
|
||||
};
|
||||
|
||||
use activity_pub::sign::Signer;
|
||||
|
||||
const USER_AGENT: &'static str = "Plume/0.1.0";
|
||||
|
||||
header! {
|
||||
(Signature, "Signature") => [String]
|
||||
}
|
||||
|
||||
header! {
|
||||
(Digest, "Digest") => [String]
|
||||
}
|
||||
|
||||
pub fn headers() -> Headers {
|
||||
let mut headers = Headers::new();
|
||||
headers.set(UserAgent::new(USER_AGENT));
|
||||
headers.set(Date(SystemTime::now().into()));
|
||||
headers.set(ContentType(Mime::from_str("application/activity+json").unwrap()));
|
||||
headers
|
||||
}
|
||||
|
||||
pub fn signature<S: Signer>(signer: &S, headers: Headers) -> Signature {
|
||||
let signed_string = headers.iter().map(|h| format!("{}: {}", h.name().to_lowercase(), h.value_string())).collect::<Vec<String>>().join("\n");
|
||||
let signed_headers = headers.iter().map(|h| h.name().to_string()).collect::<Vec<String>>().join(" ").to_lowercase();
|
||||
|
||||
let data = signer.sign(signed_string);
|
||||
let sign = base64::encode(&data[..]);
|
||||
|
||||
Signature(format!(
|
||||
"keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"{signed_headers}\",signature=\"{signature}\"",
|
||||
key_id = signer.get_key_id(),
|
||||
signed_headers = signed_headers,
|
||||
signature = sign
|
||||
))
|
||||
}
|
||||
|
||||
pub fn digest(body: String) -> Digest {
|
||||
let mut hasher = Hasher::new(MessageDigest::sha256()).unwrap();
|
||||
hasher.update(&body.into_bytes()[..]).unwrap();
|
||||
let res = base64::encode(&hasher.finish().unwrap());
|
||||
Digest(format!("SHA-256={}", res))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use base64;
|
||||
use chrono::Utc;
|
||||
use hex;
|
||||
use openssl::{
|
||||
pkey::PKey,
|
||||
rsa::Rsa,
|
||||
sha::sha256
|
||||
};
|
||||
use serde_json;
|
||||
|
||||
/// Returns (public key, private key)
|
||||
pub fn gen_keypair() -> (Vec<u8>, Vec<u8>) {
|
||||
let keypair = Rsa::generate(2048).unwrap();
|
||||
let keypair = PKey::from_rsa(keypair).unwrap();
|
||||
(keypair.public_key_to_pem().unwrap(), keypair.private_key_to_pem_pkcs8().unwrap())
|
||||
}
|
||||
|
||||
pub trait Signer {
|
||||
fn get_key_id(&self) -> String;
|
||||
|
||||
/// Sign some data with the signer keypair
|
||||
fn sign(&self, to_sign: String) -> Vec<u8>;
|
||||
}
|
||||
|
||||
pub trait Signable {
|
||||
fn sign<T>(&mut self, creator: &T) -> &mut Self where T: Signer;
|
||||
|
||||
fn hash(data: String) -> String {
|
||||
let bytes = data.into_bytes();
|
||||
hex::encode(sha256(&bytes[..]))
|
||||
}
|
||||
}
|
||||
|
||||
impl Signable for serde_json::Value {
|
||||
fn sign<T: Signer>(&mut self, creator: &T) -> &mut serde_json::Value {
|
||||
let creation_date = Utc::now().to_rfc3339();
|
||||
let mut options = json!({
|
||||
"type": "RsaSignature2017",
|
||||
"creator": creator.get_key_id(),
|
||||
"created": creation_date
|
||||
});
|
||||
|
||||
let options_hash = Self::hash(json!({
|
||||
"@context": "https://w3id.org/identity/v1",
|
||||
"created": creation_date
|
||||
}).to_string());
|
||||
let document_hash = Self::hash(self.to_string());
|
||||
let to_be_signed = options_hash + &document_hash;
|
||||
|
||||
let signature = base64::encode(&creator.sign(to_be_signed));
|
||||
|
||||
options["signaureValue"] = serde_json::Value::String(signature);
|
||||
self["signature"] = options;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#![feature(custom_attribute, iterator_flatten)]
|
||||
|
||||
extern crate activitypub;
|
||||
#[macro_use]
|
||||
extern crate activitystreams_derive;
|
||||
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 gettextrs;
|
||||
extern crate hex;
|
||||
extern crate heck;
|
||||
#[macro_use]
|
||||
extern crate hyper;
|
||||
extern crate openssl;
|
||||
extern crate pulldown_cmark;
|
||||
extern crate reqwest;
|
||||
extern crate rocket;
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
#[macro_use]
|
||||
extern crate serde_json;
|
||||
|
||||
pub mod activity_pub;
|
||||
pub mod utils;
|
||||
@@ -0,0 +1,70 @@
|
||||
use gettextrs::gettext;
|
||||
use heck::CamelCase;
|
||||
use pulldown_cmark::{Event, Parser, Options, Tag, html};
|
||||
use rocket::{
|
||||
http::uri::Uri,
|
||||
response::{Redirect, Flash}
|
||||
};
|
||||
|
||||
/// Remove non alphanumeric characters and CamelCase a string
|
||||
pub fn make_actor_id(name: String) -> String {
|
||||
name.as_str()
|
||||
.to_camel_case()
|
||||
.to_string()
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn requires_login(message: &str, url: Uri) -> Flash<Redirect> {
|
||||
Flash::new(Redirect::to(Uri::new(format!("/login?m={}", gettext(message.to_string())))), "callback", url.as_str())
|
||||
}
|
||||
|
||||
/// Returns (HTML, mentions)
|
||||
pub fn md_to_html(md: &str) -> (String, Vec<String>) {
|
||||
let parser = Parser::new_ext(md, Options::all());
|
||||
|
||||
let (parser, mentions): (Vec<Vec<Event>>, Vec<Vec<String>>) = parser.map(|evt| match evt {
|
||||
Event::Text(txt) => {
|
||||
let (evts, _, _, _, new_mentions) = txt.chars().fold((vec![], false, String::new(), 0, vec![]), |(mut events, in_mention, text_acc, n, mut mentions), c| {
|
||||
if in_mention {
|
||||
if (c.is_alphanumeric() || c == '@' || c == '.' || c == '-' || c == '_') && (n < (txt.chars().count() - 1)) {
|
||||
(events, in_mention, text_acc + c.to_string().as_ref(), n + 1, mentions)
|
||||
} else {
|
||||
let mention = text_acc + c.to_string().as_ref();
|
||||
let short_mention = mention.clone();
|
||||
let short_mention = short_mention.splitn(1, '@').nth(0).unwrap_or("");
|
||||
let link = Tag::Link(format!("/@/{}/", mention).into(), short_mention.to_string().into());
|
||||
|
||||
mentions.push(mention);
|
||||
events.push(Event::Start(link.clone()));
|
||||
events.push(Event::Text(format!("@{}", short_mention).into()));
|
||||
events.push(Event::End(link));
|
||||
|
||||
(events, false, c.to_string(), n + 1, mentions)
|
||||
}
|
||||
} else {
|
||||
if c == '@' {
|
||||
events.push(Event::Text(text_acc.into()));
|
||||
(events, true, String::new(), n + 1, mentions)
|
||||
} else {
|
||||
if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention.
|
||||
events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into()))
|
||||
}
|
||||
(events, in_mention, text_acc + c.to_string().as_ref(), n + 1, mentions)
|
||||
}
|
||||
}
|
||||
});
|
||||
(evts, new_mentions)
|
||||
},
|
||||
_ => (vec![evt], vec![])
|
||||
}).unzip();
|
||||
let parser = parser.into_iter().flatten();
|
||||
let mentions = mentions.into_iter().flatten().map(|m| String::from(m.trim()));
|
||||
|
||||
// TODO: fetch mentionned profiles in background, if needed
|
||||
|
||||
let mut buf = String::new();
|
||||
html::push_html(&mut buf, parser);
|
||||
(buf, mentions.collect())
|
||||
}
|
||||
Reference in New Issue
Block a user