gourami

[UNMAINTAINED] Activitypub server in Rust
Log | Files | Refs | README | LICENSE

commit f34e28d4e654a0d021bcbe6954b6a1e94fc58f3a
parent a17549bd0ae5dd1f2d4681b4feec71d6b9e93dcc
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Tue, 12 May 2020 15:18:02 -0500

Fix some AP bugs, add error handling

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mansible/playbook.yml | 6+++++-
Mmigrations/2020-04-13-014917_initialize/up.sql | 1+
Msrc/ap.rs | 165++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/db/schema.rs | 1+
Msrc/db/server_mutuals.rs | 2++
Msrc/lib.rs | 1+
Msrc/routes.rs | 21+++++++++++++++++----
9 files changed, 134 insertions(+), 65 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -651,6 +651,7 @@ dependencies = [ "log 0.4.8", "maplit", "openssl", + "r2d2", "rand 0.7.3", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml @@ -21,6 +21,7 @@ lazy_static = "1.4.0" log = "0.4.8" maplit = "1.0.2" openssl = "0.10.29" +r2d2 = "0.8.8" rand = "0.7.3" regex = "1.3.7" ring = "0.16.13" diff --git a/ansible/playbook.yml b/ansible/playbook.yml @@ -1,5 +1,9 @@ +# This is only tested on Debian. Attempt at other distros at your wown peril +# TODO -- generate https +# TODO -- generate public key for http signature +# TODO -- diesel migrations? or some other schema migraiton --- -- hosts: all +- hosts: gourami remote_user: root become: yes tasks: diff --git a/migrations/2020-04-13-014917_initialize/up.sql b/migrations/2020-04-13-014917_initialize/up.sql @@ -62,6 +62,7 @@ CREATE TABLE notes ( create table server_mutuals ( id integer primary key autoincrement, + actor_id VARCHAR(1000), inbox_url VARCHAR(1000), accepted boolean default false, followed_back boolean default false, diff --git a/src/ap.rs b/src/ap.rs @@ -1,3 +1,4 @@ +use crate::error::Error; use crate::db::conn::POOL; use crate::db::note::{NoteInput, RemoteNoteInput}; use crate::db::user::{NewRemoteUser, User}; @@ -19,16 +20,6 @@ use std::collections::BTreeMap; use std::env; use std::path::Path; -/// Users don't follow users in Gourami. Instead the server does hte following -/// There are a number of reasons for this: -/// Gives it a more 'community' feel -- everyone shares the same timeline -/// Much simpler from an engineering and user perspective -- I think its difficult for -/// non-engineering people to properly separate different audience -/// -/// This is a somewhat eccentric activitypub implementation, but it is as consistent with the spec -/// as I can make it! -use std::fs; - fn domain_url() -> String { if &env::var("SSL_ENABLED").unwrap() == "1" { return format!("https://{}", &env::var("GOURAMI_DOMAIN").unwrap()); @@ -37,23 +28,67 @@ fn domain_url() -> String { } struct ServerApData { - actor: String, global_id: String, key_id: String, - inbox: String + inbox: String, + public_key: String } lazy_static! { // TODO -- learn this a little better so it isnt so redundant static ref SERVER: ServerApData = ServerApData { - actor: format!("{}/actor", domain_url()), global_id: format!("{}/", domain_url()), key_id: format!("{}/actor#key", domain_url()), - inbox: format!("{}/inbox", domain_url())}; + inbox: format!("{}/inbox", domain_url()), + public_key: std::fs::read_to_string(env::var("SIGNATURE_PUBKEY_PEM").unwrap()).unwrap() + }; } // TODO figure out how to get static working + +#[derive(Deserialize, Serialize)] +pub struct CreateNote { // Maybe use AP crate +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Actor { + context: Vec<String>, + id: String, + #[serde(rename = "type")] + _type: String, + #[serde(rename = "preferredUsername")] + preferred_username: String, + inbox: String, + #[serde(rename = "publicKey")] + public_key: PublicKey +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ApNote { +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PublicKey { + id: String, + owner: String, + #[serde(rename = "publicKeyPem")] + public_key_pem: String, +} + +#[derive(Deserialize, Serialize)] +pub struct Note { +} + +/// Users don't follow users in Gourami. Instead the server does hte following +/// There are a number of reasons for this: +/// Gives it a more 'community' feel -- everyone shares the same timeline +/// Much simpler from an engineering and user perspective -- I think its difficult for +/// non-engineering people to properly separate different audience +/// +/// This is a somewhat eccentric activitypub implementation, but it is as consistent with the spec +/// as I can make it! +use std::fs; // ActivityPub outbox fn send_to_outbox(activity: bool) { // activitystreams object fetch/store from db. db objects need to serialize/deserialize this object if get -> fetch from db if post -> put to db, send to inbox of followers send to inbox of followers @@ -73,12 +108,10 @@ enum Action { /// get the server user json -pub fn server_actor_json() -> Value { +pub fn server_actor_json() -> Actor { // TODO figure out how to get lazy static working // TODO use ap library - let AP_PUBLIC_KEY: &str = - &fs::read_to_string(env::var("SIGNATURE_PUBKEY_PEM").unwrap()).unwrap(); - json!({ + serde_json::from_value(json!({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" @@ -90,9 +123,9 @@ pub fn server_actor_json() -> Value { "inbox": SERVER.inbox, "publicKey": { "id": SERVER.key_id, - "owner": SERVER.actor, - "publicKeyPem": SERVER.key_id - }}) + "owner": SERVER.global_id, + "publicKeyPem": SERVER.public_key + }})).unwrap() } fn categorize_input_message(v: Value) -> Action { @@ -106,9 +139,10 @@ pub fn process_create_note( // maybe there's a cleaner way to do this. cant iterate over types // TODO inbox forwarding https://www.w3.org/TR/activitypub/#inbox-forwarding // - let conn = &POOL.get().unwrap(); + let conn = &POOL.get()?; // Get actor - let object = v.get("object").ok_or("No object found")?; + // TODO -- look into this + let object = v.get("object").ok_or("No AP object found")?; let _type = object.get("type").ok_or("No object type found")?; // match type == note let content = object @@ -167,36 +201,35 @@ pub fn process_create_note( println!("{:?}", new_remote_note); insert_into(n::notes) .values(&new_remote_note) - .execute(conn) - .unwrap(); + .execute(conn)?; return Ok(()); } -pub async fn process_accept(v: Value) -> Result<(), reqwest::Error> { - let actor: &str = v.get("actor").unwrap().as_str().unwrap(); - let remote_actor: Value = reqwest::get(actor).await?.json().await?; - let actor_inbox = remote_actor.get("inbox").unwrap().as_str().unwrap(); - set_mutual_accepted(actor_inbox); +pub async fn process_accept(v: Value) -> Result<(), Error> { + let actor_id: &str = v.get("actor").ok_or("No actor found")?.as_str().ok_or("Not a string")?; + set_mutual_accepted(actor_id); Ok(()) } -fn set_mutual_accepted (actor_inbox: &str) { +fn set_mutual_accepted (the_actor_id: &str) -> Result<(), Error>{ use crate::db::schema::server_mutuals::dsl::*; - let conn = &POOL.get().unwrap(); + let conn = &POOL.get()?; diesel::update(server_mutuals) - .filter(inbox_url.eq(actor_inbox)) + .filter(actor_id.eq(the_actor_id)) .set(accepted.eq(true)) - .execute(conn).unwrap(); + .execute(conn)?; + Ok(()) } // TODO clean this up -fn set_mutual_followed_back (actor_inbox: &str) { +fn set_mutual_followed_back (actor_inbox: &str) -> Result<(), Error> { use crate::db::schema::server_mutuals::dsl::*; - let conn = &POOL.get().unwrap(); + let conn = &POOL.get()?; diesel::update(server_mutuals) .filter(inbox_url.eq(actor_inbox)) .set(followed_back.eq(true)) - .execute(conn).unwrap(); + .execute(conn)?; + Ok(()) } fn should_accept(actor_inbox: &str) -> bool { @@ -206,20 +239,20 @@ fn should_accept(actor_inbox: &str) -> bool { sent_req } -pub async fn process_follow(v: Value) -> Result<(), reqwest::Error> { +pub async fn process_follow(v: Value) -> Result<(), Error> { let actor: &str = v.get("actor").unwrap().as_str().unwrap(); - let remote_actor: Value = reqwest::get(actor).await?.json().await?; - let actor_inbox = remote_actor.get("inbox").unwrap().as_str().unwrap(); - let sent_req = should_accept(actor_inbox); + let remote_actor: Actor = get_remote_actor(actor).await?; // not strictly necessary can use db instead + let actor_inbox = &remote_actor.inbox; + let sent_req = should_accept(actor); debug!("Should server accept the request? {}", sent_req); if sent_req { - set_mutual_followed_back(actor_inbox); + set_mutual_followed_back(actor)?; // send accept follow let accept = json!({ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://my-example.com/my-first-accept", "type": "Accept", - "actor": SERVER.actor, + "actor": SERVER.global_id, "object": &v, }); send_ap_message(&accept, vec![actor_inbox.to_string()]).await.unwrap(); @@ -248,33 +281,47 @@ pub async fn send_ap_message( .post(&destination) .header("date", Utc::now().to_rfc2822()) .json(&ap_message) - .header("Content-Type", "application/activity+json") + .header("Content-Type", r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#) .send() .await?; } Ok(()) } +pub async fn get_remote_actor(actor_id: &str) -> Result<Actor, Error> { + let client = reqwest::Client::new(); + println!("{:?}", actor_id); + let res = client.get(actor_id) + .header("Accept", r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams"#) + .send() + .await?; + println!("{:?}", res); + let res: Actor = res.json().await?; + println!("{:?}", res); + Ok(res) +} -pub async fn follow_remote_server(remote_url: &str) -> Result<(), reqwest::Error> { - let remote_actor: Value = reqwest::get(remote_url).await?.json().await?; - let inbox_url = remote_actor.get("inbox").unwrap().as_str().unwrap(); - let msg = generate_server_follow(inbox_url); +pub async fn follow_remote_server(remote_url: &str) -> Result<(), Error> { + let remote_actor: Actor = get_remote_actor(remote_url).await?; + let inbox_url = &remote_actor.inbox; + let actor_id = &remote_actor.id; + let msg = generate_server_follow(actor_id, inbox_url)?; send_ap_message(&msg, vec![inbox_url.to_owned()]).await?; Ok(()) } -fn generate_server_follow(remote_url: &str) -> Value { - let conn = &POOL.get().unwrap(); +fn generate_server_follow(remote_actor: &str, my_inbox_url: &str) -> Result<Value, Error> { + let conn = &POOL.get()?; let res = json!({ "@context": "https://www.w3.org/ns/activitystreams", "id": "https://my-example.com/my-first-follow", "type": "Follow", - "actor": SERVER.actor, - "object": remote_url, + "actor": SERVER.global_id, + "object": remote_actor, }); use crate::db::schema::server_mutuals::dsl::*; - insert_into(server_mutuals).values(NewServerMutual{inbox_url: remote_url.to_owned()}).execute(conn).unwrap(); - res + // TODO use str instead of String + insert_into(server_mutuals).values(NewServerMutual{actor_id: remote_actor.to_owned(), inbox_url: my_inbox_url.to_owned()}).execute(conn)?; + Ok(res) } @@ -286,7 +333,7 @@ pub fn new_note_to_ap_message(note: &NoteInput, user: &User) -> Value { "@context": "https://www.w3.org/ns/activitystreams", "id": "someid", "type": "Create", - "actor": SERVER.actor, + "actor": SERVER.global_id, "published": "now", "to": [ "destination.server" @@ -325,9 +372,7 @@ impl HttpSignature for reqwest::RequestBuilder { let config = Config::default() .set_expiration(Duration::seconds(3600)) .dont_use_created_field(); - // let server_key_id = - let server_key_id: &str = - &format!("http://{}/actor#key", &env::var("GOURAMI_DOMAIN").unwrap()); + let server_key_id = SERVER.key_id.clone(); let mut bt = std::collections::BTreeMap::new(); for (k, v) in req.headers().iter() { bt.insert(k.as_str().to_owned(), v.to_str()?.to_owned()); @@ -340,7 +385,7 @@ impl HttpSignature for reqwest::RequestBuilder { let unsigned = config.begin_sign(req.method().as_str(), &path_and_query, bt)?; println!("{:?}", &unsigned); let sig_header = unsigned - .sign(server_key_id.to_owned(), |signing_string| { + .sign(server_key_id,|signing_string| { let private_key = read_file(Path::new(&env::var("SIGNATURE_PRIVKEY").unwrap())); let key_pair = ring::signature::RsaKeyPair::from_pkcs8(&private_key.unwrap()).unwrap(); @@ -496,7 +541,7 @@ mod tests { // for mastodon config -- newer versions of httsig dont use this .header("date", Utc::now().to_rfc2822()) .json(&body) - .header("Content-Type", "application/activity+json") + .header("Accept", r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams"#) .http_sign_outgoing() .unwrap(); } diff --git a/src/db/schema.rs b/src/db/schema.rs @@ -62,6 +62,7 @@ table! { table! { server_mutuals (id) { id -> Integer, + actor_id -> Varchar, inbox_url -> Varchar, accepted -> Bool, followed_back -> Bool, diff --git a/src/db/server_mutuals.rs b/src/db/server_mutuals.rs @@ -4,6 +4,7 @@ use super::schema::server_mutuals; #[derive(Queryable, PartialEq, Debug)] pub struct ServerMutual { id: i32, + actor_id: String, accepted: bool, followed_back: bool, inbox_url: String, @@ -13,5 +14,6 @@ pub struct ServerMutual { #[derive(Insertable)] #[table_name="server_mutuals"] pub struct NewServerMutual { + pub actor_id: String, pub inbox_url: String, } diff --git a/src/lib.rs b/src/lib.rs @@ -33,6 +33,7 @@ use session::Session; pub mod ap; mod db; +pub mod error; pub mod routes; mod session; diff --git a/src/routes.rs b/src/routes.rs @@ -1,7 +1,7 @@ use crate::session; use crate::*; use env_logger; -use warp::{body::form, body::json, filters::cookie, filters::query::query, path, reply}; +use warp::{header, body::form, body::json, filters::cookie, filters::query::query, path, reply}; // I had trouble decoupling routes from server -- couldnt figure out the return type pub async fn run_server() { @@ -12,6 +12,18 @@ pub async fn run_server() { // TODO - -create a filter that gives only certain users access to pages // we have to pass the full paths for redirect to work without javascript + // + let actor_json = warp::path::end() + // In practice, the headers may not follow the spec + // https://www.w3.org/TR/activitypub/#retrieving-objects + .and(header::exact_ignore_case("accept", r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#) + .or(header::exact_ignore_case("accept", r#"application/ld+json"#)) + .or(header::exact_ignore_case("accept", r#"profile="https://www.w3.org/ns/activitystreams""#) + ) + ) + .map(|_| reply::json(&ap::server_actor_json()) // how do async work + ); + let home = warp::path::end() .and(session_filter()) .and(query()) @@ -87,8 +99,9 @@ pub async fn run_server() { // setup authentication // POST // TODO -- setup proper replies - let server_actor = path!("actor").map(|| reply::json(&ap::server_actor_json())); + + // force content type to be application/ld+json; profile="https://www.w3.org/ns/activitystreams let post_server_inbox = path!("inbox") .and(json()) .and_then(post_inbox); @@ -101,7 +114,7 @@ pub async fn run_server() { // TODO secure against xss // used for api based authentication // let api_filter = session::create_session_filter(&POOL.get()); - let static_json = server_actor; // rename html renders + let static_json = actor_json; // rename html renders let html_renders = home .or(login_page) .or(register_page) @@ -122,7 +135,7 @@ pub async fn run_server() { // catch all for any other paths let routes = warp::get() - .and(html_renders.or(static_json)) + .and(static_json.or(html_renders)) .or(warp::post() .and(warp::body::content_length_limit(1024 * 32)) .and(forms))