gourami

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

commit e553a7e0cf3066536f2b39476616851a21e3757d
parent f0b52f36bebe9d43e1f8c91d552ab421cd840522
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sat,  2 May 2020 21:53:34 -0500

Restructure, AP stuff, minor features

Diffstat:
Mdocs/USER_GUIDE.md | 4++++
Msrc/ap.rs | 40++++++++++++++++++++++++++++++++++++++--
Msrc/db/note.rs | 8+++++++-
Msrc/db/user.rs | 11++++++++++-
Msrc/lib.rs | 260++++++++++++++++++++++++++++++++++++++-----------------------------------------
Msrc/routes.rs | 8++++----
6 files changed, 188 insertions(+), 143 deletions(-)

diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md @@ -18,4 +18,8 @@ Gourami has a feature called the "neighbhorhood", which allows one Gourami serve The Neighbhorhood timeline consists of all other [ActivityPub](http://activitypub.rocks/) services. If you're not familiar with ActivityPub, it's a shared language that allows different social media applications to communicate with each other. This means that two services that both implement ActivityPub (such as Gourami and [Mastodon](https://joinmastodon.org/) should be able to communicate with each other. In practice, there may be differences between each individual ActivityPub services, +A note on permissions -- + +While local-only posts are private, a neighborhood post WILL be sent to other servers. Only add servers to your neighborhood that you trust! + todo -- explain more diff --git a/src/ap.rs b/src/ap.rs @@ -6,26 +6,58 @@ /// /// This is a somewhat eccentric activitypub implementation, but it is as consistent with the spec /// as I can make it! - +use std::fs; use serde_json::json; use serde_json::{Value}; use crate::db::note::{NoteInput, RemoteNoteInput}; use crate::db::user::{User, NewRemoteUser}; use crate::db::conn::POOL; -use warp::{Reply, Filter, Rejection}; use diesel::insert_into; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; +use std::env; lazy_static! { // const SERVER_ACTOR = "gourami.social" } + +// 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 +} + enum Action { CreateNote, DoNothing, // DeleteNote } +/// get the server user json +fn server_actor() -> Value { + let domain = env::var("GOURAMI_DOMAIN").unwrap(); + let actor = format!("{}/actor", domain); + let inbox = format!("{}/inbox", domain); + let public_key = fs::read_to_string(env::var("SIGNATURE_PUBKEY").unwrap()).unwrap(); + json!({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + + "id": actor, + "type": "Organization", // application? + "preferredUsername": domain, // think about it + "inbox": inbox, + "publicKey": { + "id": format!("{}#main-key", actor), + "owner": actor, + "publicKeyPem": public_key + }}) +} fn categorize_input_message(v: Value) -> Action { Action::DoNothing @@ -97,6 +129,10 @@ pub async fn send_ap_message(ap_message: &Value, destinations: Vec<String>) -> R Ok(()) } +fn follow_remote_server(remote_url: String) { + // create follow request +} + fn generate_server_follow(remote_url: String) -> Value { json!({ "@context": "https://www.w3.org/ns/activitystreams", diff --git a/src/db/note.rs b/src/db/note.rs @@ -9,8 +9,9 @@ use crate::db::user::User; // weird import /// This isn't queryable directly, /// It only works when joined with the users table /// -#[derive(Queryable, Associations, Clone, Deserialize, Serialize)] +#[derive(Queryable, Debug, QueryableByName, Associations, Clone, Deserialize, Serialize)] #[belongs_to(User)] +#[table_name = "notes"] pub struct Note { // rename RenderedNote pub id: i32, pub user_id: i32, @@ -83,6 +84,11 @@ pub fn get_reply(note_text: &str) -> Option<i32> { } } +pub fn get_mentions(note_text: &str) -> Vec<String> { + let re = Regex::new(r"\B(@)(\w+)").unwrap(); + re.captures_iter(note_text).map(|c| String::from(&c[2])).collect() +} + /// used for user-input /// Parse links -- stolen from https://git.cypr.io/oz/autolink-rust/src/branch/master/src/lib.rs /// TODO -- sanitize before write and then render links on read diff --git a/src/db/user.rs b/src/db/user.rs @@ -33,7 +33,16 @@ impl RegistrationKey { } } -#[derive(Debug, Clone, Default, Queryable, Deserialize)] + +// a hack +#[derive(QueryableByName)] +#[table_name = "users"] +pub struct Username{ + pub username: String +} + +#[derive(Debug, Clone, Default, Queryable, QueryableByName, Deserialize)] +#[table_name = "users"] pub struct User { pub id: i32, pub username: String, diff --git a/src/lib.rs b/src/lib.rs @@ -8,7 +8,7 @@ use serde_json::{Value}; use std::convert::Infallible; use zxcvbn::zxcvbn; -use warp::{reject, reject::Reject, Reply, Filter, Rejection}; +use warp::{reject::Reject, Reply, Filter, Rejection}; use warp::{redirect::redirect}; use warp::filters::path::FullPath; use warp::http; @@ -19,7 +19,7 @@ use askama::Template; use db::note::{NoteInput, Note}; use db::conn::POOL; use db::note; -use db::user::{RegistrationKey, User, NewUser}; +use db::user::{RegistrationKey, User, NewUser, Username}; use db::notification::{NewNotification, NewNotificationViewer, Notification, NotificationViewer}; use diesel::prelude::*; use diesel::insert_into; @@ -33,6 +33,79 @@ mod ap; pub mod routes; +#[derive(Template)] +#[template(path = "user.html")] +struct UserTemplate<'a>{ + global: Global<'a>, + notes: Vec<UserNote>, + user: User, +} + +#[derive(Template)] +#[template(path = "note.html")] +struct NoteTemplate<'a> { + global: Global<'a>, + note_thread: Vec<UserNote>, + // thread +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate<'a> { + global: Global<'a>, + error_message: &'a str +} + +#[derive(Template)] +#[template(path = "edit_user.html")] +struct UserEditTemplate<'a> { + global: Global<'a>, + user: User, +} + +#[derive(Template)] +#[template(path = "neighborhood.html")] +struct NeighborhoodTemplate<'a>{ + global: Global<'a>, + notes: Vec<UserNote>, +} // TODO reconsider structure + +// TODO split into separate templates. not sure how +#[derive(Template)] +#[template(path = "timeline.html")] +struct TimelineTemplate<'a>{ + global: Global<'a>, + notes: Vec<UserNote>, +} + +#[derive(Template)] +#[template(path = "notifications.html")] +struct NotificationTemplate<'a>{ + notifs: Vec<RenderedNotif>, // required for redirects. + global: Global<'a>, +} + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate<'a>{ + login_failed: bool, // required for redirects. + global: Global<'a>, +} + +#[derive(Template)] +#[template(path = "register.html")] +struct RegisterTemplate<'a>{ + keyed: bool, + key: &'a str, + global: Global<'a>, +} + +#[derive(Template)] +#[template(path = "server_info.html")] +struct ServerInfoTemplate<'a> { + global: Global<'a>, +} + struct Global<'a> { // variables used on all pages w header title: &'a str, page: &'a str, @@ -131,11 +204,15 @@ async fn handle_new_note_form(u: Option<User>, f: NewNoteRequest) -> Result<impl pub fn new_note(auth_user: &User, note_input: &str, neighborhood: bool) -> Result<NoteInput, Box<dyn std::error::Error>> { use db::schema::notes::dsl as notes; + use db::schema::users::dsl as u; + use db::schema::notifications::dsl as notifs; + use db::schema::notification_viewers::dsl as nv; // create activitypub activity object // TODO -- micropub? // if its in reply to something let conn = &POOL.get()?; let reply = note::get_reply(note_input); + let mentions = note::get_mentions(note_input); let parsed_note_text = note::parse_note_text(note_input); let new_note = NoteInput{ user_id: auth_user.id, @@ -143,38 +220,42 @@ pub fn new_note(auth_user: &User, note_input: &str, neighborhood: bool) -> Resul content: parsed_note_text, neighborhood: neighborhood }; + // TODO fix potential multithreading issue insert_into(notes::notes).values(&new_note).execute(conn)?; + let note_id: i32 = notes::notes + .order(notes::id.desc()) + .select(notes::id) + .first(conn).unwrap(); // notify person u reply to - if let Some(r_id) = reply { - use db::schema::notifications::dsl as notifs; - use db::schema::notification_viewers::dsl as nv; - // create reply notification - let message = format!("@{} created a note in reply to 📝{}", auth_user.username, r_id); + if mentions.len() > 0 { + let message = format!("@{} mentioned you in a note 📝{}", auth_user.username, note_id); let new_notification = NewNotification { // reusing the same parser for now. rename maybe notification_html: note::parse_note_text(&message), server_message: false }; insert_into(notifs::notifications).values(new_notification).execute(conn)?; - // I thinks this may work but worry about multithreading - let notif_id = notifs::notifications - .order(notifs::id.desc()) - .select(notifs::id) - .first(conn).unwrap(); - let user_id = notes::notes - .select(notes::user_id) - .find(r_id) - .first(conn) - .unwrap(); // TODO - // TODO -- notify all members of the thread - // Mark notes as read - let new_nv = NewNotificationViewer { - notification_id: notif_id, - user_id: user_id, - viewed: false - }; - - insert_into(nv::notification_viewers).values(new_nv).execute(conn)?; + for mention in mentions { + // create reply notification + // I thinks this may work but worry about multithreading + let user_id = u::users + .select(u::id) + .filter(u::username.eq(mention)) + .first(conn) + .ok(); // TODO + if let Some(u_id) = user_id { + let notif_id = notifs::notifications + .order(notifs::id.desc()) + .select(notifs::id) + .first(conn).unwrap(); + let new_nv = NewNotificationViewer { + notification_id: notif_id, + user_id: u_id, + viewed: false + }; + insert_into(nv::notification_viewers).values(new_nv).execute(conn).ok(); // work with conn failures + } + } } // generate activitypub object from post request @@ -184,24 +265,6 @@ pub fn new_note(auth_user: &User, note_input: &str, neighborhood: bool) -> Resul Ok(new_note) } -// 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 -} - - -#[derive(Template)] -#[template(path = "register.html")] -struct RegisterTemplate<'a>{ - keyed: bool, - key: &'a str, - global: Global<'a>, -} - #[derive(Deserialize)] struct QueryParams { key: Option<String>, @@ -272,13 +335,6 @@ struct LoginForm { } -#[derive(Template)] -#[template(path = "login.html")] -struct LoginTemplate<'a>{ - login_failed: bool, // required for redirects. - global: Global<'a>, -} - fn login_page() -> impl Reply { // dont let you access this page if logged in render_template(&LoginTemplate{login_failed: false, global: Global{page: "login", ..Default::default()}}) @@ -286,12 +342,13 @@ fn login_page() -> impl Reply { fn do_login(form: LoginForm) -> impl Reply { if let Some(cookie) = Session::authenticate(&POOL.get().unwrap(), &form.username, &form.password) { + // 1 year cookie expiration http::Response::builder() .status(http::StatusCode::FOUND) .header(http::header::LOCATION, "/") .header( http::header::SET_COOKIE, - format!("EXAUTH={}; SameSite=Strict; HttpOpnly", cookie), + format!("EXAUTH={}; MAX-AGE=31536000; SameSite=Strict; HttpOpnly", cookie), ) .body(Body::empty()).unwrap() } else { @@ -305,14 +362,6 @@ fn do_logout(cookie: String) -> impl Reply { redirect(warp::http::Uri::from_static("/")) } -// TODO split into separate templates. not sure how -#[derive(Template)] -#[template(path = "timeline.html")] -struct TimelineTemplate<'a>{ - global: Global<'a>, - notes: Vec<UserNote>, -} - #[derive(Deserialize)] struct GetPostsParams { #[serde(default = "default_page")] @@ -332,20 +381,27 @@ impl Default for GetPostsParams { } } - pub struct UserNote { note: Note, username: String, } -// TODO merge this with the other get notes function fn get_single_note(note_id: i32) -> Option<Vec<UserNote>> { - use db::schema::notes::dsl as n; - use db::schema::users::dsl as u; - let results = n::notes.inner_join(u::users) - .filter(n::id.eq(note_id).or(n::in_reply_to.eq(note_id))) - .load::<(Note, User)>(&POOL.get().unwrap()).unwrap(); - Some(results.into_iter().map(|a| UserNote{note: a.0, username: a.1.username}).collect()) + // doing some fancy recursive stuff + let conn = &POOL.get().unwrap(); + // TODO -- it isnt serializing ids right + // Username is a hack because there are two ID columns and it doesnt work right + let results: Vec<(Note, Username)> = diesel::sql_query(format!(r"with recursive tc( p ) + as ( values({}) + union select id from notes, tc + where notes.in_reply_to = tc.p + ) + select notes.*, users.username from notes + join users on notes.user_id = users.id + where notes.id in tc", note_id)).load(conn).unwrap(); + Some(results.into_iter().map(|mut a| { + // the ids are swapped for some reason + UserNote{note: a.0, username: a.1.username}}).collect()) } /// We have to do a join here @@ -370,13 +426,6 @@ fn get_notes(params: GetPostsParams, neighborhood: Option<bool>) -> Result<Vec<U Ok(results.into_iter().map(|a| UserNote{note: a.0, username: a.1.username}).collect()) } -#[derive(Template)] -#[template(path = "notifications.html")] -struct NotificationTemplate<'a>{ - notifs: Vec<RenderedNotif>, // required for redirects. - global: Global<'a>, -} - struct RenderedNotif { notif: Notification, viewed: bool @@ -420,13 +469,6 @@ fn render_timeline(auth_user: Option<User>, params:GetPostsParams, url_path: Ful } -#[derive(Template)] -#[template(path = "neighborhood.html")] -struct NeighborhoodTemplate<'a>{ - global: Global<'a>, - notes: Vec<UserNote>, -} // TODO reconsider structure - fn render_neighborhood(auth_user: Option<User>, params:GetPostsParams, url_path: FullPath) -> impl Reply{ let header = Global::create(auth_user, url_path.as_str()); let notes = get_notes(params, Some(true)); @@ -440,19 +482,6 @@ fn render_neighborhood(auth_user: Option<User>, params:GetPostsParams, url_path: } -#[derive(Template)] -#[template(path = "server_info.html")] -struct ServerInfoTemplate<'a> { - global: Global<'a>, -} - -#[derive(Template)] -#[template(path = "error.html")] -struct ErrorTemplate<'a> { - global: Global<'a>, - error_message: &'a str -} - impl<'a> Default for ErrorTemplate<'a> { fn default() -> Self { Self { @@ -462,24 +491,6 @@ impl<'a> Default for ErrorTemplate<'a> { } } -#[derive(Template)] -#[template(path = "user.html")] -struct UserTemplate<'a>{ - global: Global<'a>, - page: &'a str, - notes: Vec<UserNote>, - user: User, -} - -#[derive(Template)] -#[template(path = "note.html")] -struct NoteTemplate<'a> { - global: Global<'a>, - page: &'a str, - note_thread: Vec<UserNote>, - // thread -} - fn server_info_page(auth_user: Option<User>) -> impl Reply { render_template(&ServerInfoTemplate{global: Global::create(auth_user, "/server")}) } @@ -487,7 +498,7 @@ fn server_info_page(auth_user: Option<User>) -> impl Reply { fn note_page(auth_user: Option<User>, note_id: i32, path: FullPath) -> impl Reply { let note_thread = get_single_note(note_id); if let Some(n) = note_thread { - render_template(&NoteTemplate{global: Global::create(auth_user, path.as_str()), note_thread: n, page: &note_id.to_string()}) + render_template(&NoteTemplate{global: Global::create(auth_user, path.as_str()), note_thread: n}) } else { render_template(&ErrorTemplate{global: Global::create(auth_user, path.as_str()), error_message: "Note not found"}) @@ -507,7 +518,6 @@ fn user_page(auth_user: Option<User>, user_name: String, mut params: GetPostsPar let notes = get_notes(params, None).unwrap(); render_template(&UserTemplate{ global: header, - page: &u.username, user: u.clone(), // TODO stop cloning notes: notes }) @@ -518,13 +528,6 @@ fn user_page(auth_user: Option<User>, user_name: String, mut params: GetPostsPar } -#[derive(Template)] -#[template(path = "edit_user.html")] -struct UserEditTemplate<'a> { - global: Global<'a>, - user: User, -} - fn render_user_edit_page(user: Option<User>, user_name: String) -> impl Reply { let u = user.clone().unwrap(); let global = Global::create(user, "/edit"); @@ -536,7 +539,6 @@ fn render_user_edit_page(user: Option<User>, user_name: String) -> impl Reply { } } - pub fn get_outbox() {} pub fn post_outbox(message: Value) {} @@ -579,15 +581,3 @@ fn edit_user(user: Option<User>, user_name: String, f: EditForm) -> impl Reply { async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> { Ok(render_template(&ErrorTemplate{global: Global::create(None, "error"), error_message: "You do not have access to this page, it does not exist, or something went wrong."})) } - - -// Url query -#[derive(Deserialize)] -struct Page { - page_num: i32 -} - -// TODO -- move this into separate module -#[derive(Debug)] -struct LoggedOut; -impl Reject for LoggedOut {} diff --git a/src/routes.rs b/src/routes.rs @@ -100,15 +100,15 @@ pub async fn run_server() { // setup authentication // POST // TODO -- setup proper replies - let post_server_inbox = path!("inbox.json" ) + let post_server_inbox = path!("inbox" ) .and(json()) .map(post_inbox); - let post_server_outbox = path!("outbox.json" ) + let post_server_inbox = path!("inbox" ) .and(json()) - .map(post_outbox); + .map(post_inbox); - let get_server_outbox = path!("outbox.json" ) + let get_server_outbox = path!("outbox" ) .map(get_outbox); // https://github.com/seanmonstar/warp/issues/42 -- how to set up diesel