gourami

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

commit 4bac24e3f3fb5f73bf8610fc39af507a6b632e88
parent ba1c8128a4433105b59336440718bd6caf3240ac
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Mon, 20 Apr 2020 00:15:04 -0500

Cleanup html parsing and create basis for AP

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Asrc/ap.rs | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/db/mod.rs | 2+-
Asrc/db/note.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/db/status.rs | 129-------------------------------------------------------------------------------
Msrc/lib.rs | 37++++++++++++++++++++++++++++++++-----
Mtemplates/single_note.html | 5++++-
8 files changed, 219 insertions(+), 136 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -567,6 +567,7 @@ dependencies = [ "hyper", "lazy_static", "log 0.4.8", + "maplit", "rand 0.7.3", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml @@ -23,5 +23,6 @@ warp = "0.2" hyper = "0.13" regex = "1.3" ammonia = "3" +maplit = "1.0.2" [dev-dependencies] diff --git a/src/ap.rs b/src/ap.rs @@ -0,0 +1,42 @@ +use serde_json::value::Value; + +fn process_ap_activity(message: Value) { + // profiles +// follow +// accept / reject + +// statuses "Notes" +// match message.get("type").lower() { +// "create" => Some(1), + // "delete" => Some(1), + // Announce? + // _ => None +// }; +// main type: Note +// simple https://docs.joinmastodon.org/spec/activitypub/ +// support types: +// article +// page +// event +// +// "get" -> list all jsons. +// match these to notifications +} +pub fn post_user_inbox(user_name: String, message: Value) { +} + +pub fn post_user_outbox(user_name: String, message: Value) { +} + +pub fn get_user_outbox(user_name: String) { +} + +// requires authentication +pub fn get_user_inbox(user_name: String) { +} + +pub fn user_followers(user_name: String) { +} + +pub fn user_following(user_name: String) { +} diff --git a/src/db/mod.rs b/src/db/mod.rs @@ -1,3 +1,3 @@ -pub mod status; +pub mod note; pub mod schema; pub mod user; diff --git a/src/db/note.rs b/src/db/note.rs @@ -0,0 +1,138 @@ +use chrono; +use std::collections::HashSet; +use activitystreams::object::streams; +use diesel::sqlite::SqliteConnection; +use maplit::hashset; +use diesel::deserialize::{Queryable}; +use super::schema::notes; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use regex::Regex; +use std::iter::FromIterator; +use ammonia; + +// Statuses are note activitystream object + +#[derive(Queryable, Clone, Deserialize, Serialize)] +pub struct Note { + pub id: i32, + pub creator_id: i32, + pub creator_username: String, + pub parent_id: Option<i32>, + pub content: String, + pub created_time: String, +} + +#[derive(Insertable, Clone)] +#[table_name = "notes"] +pub struct NoteInput { + //pub id: i32, //unsigned? + pub creator_id: i32, + pub creator_username: String, + pub parent_id: Option<i32>, + pub content: String, // can we make this a slice? + // pub published: chrono::NaiveDateTime, +} + +/// used when we get content from another server +/// Derived from the big elephant +/// https://github.com/tootsuite/mastodon/blob/master/app/lib/sanitize_config.rb +pub fn sanitize_remote_content(html_string: &str) -> String { + let ok_tags = hashset!["p", "br", "span", "a"]; + let html_clean = ammonia::Builder::new() + .tags(ok_tags) + .clean(html_string) + .to_string(); + // this is OK for now -- but we want to add microformats like mastodon does + html_clean +} + +/// used for user-inpul +/// Parse links -- stolen from https://git.cypr.io/oz/autolink-rust/src/branch/master/src/lib.rs +pub fn parse_note_text(text: &str) -> String { + // dont hack me + let html_clean = ammonia::clean_text(text); + if text.len() == 0 { + return String::new(); + } + let re = Regex::new( + r"(?ix) + \b(([\w-]+:&\#48;&\#47;?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/))) + ", + ) + .unwrap(); + let replace_str = "<a href=\"$0\">$0</a>"; + let urls_parsed = re.replace_all(&html_clean, &replace_str as &str).to_string(); + let note_regex = Regex::new( + r"\B(📝|&gt;&gt;)(\d+)", + ).unwrap(); + let replace_str = "<a href=\"/note/$2\">$0</a>"; + let notes_parsed = note_regex.replace_all(&urls_parsed, &replace_str as &str).to_string(); + let person_regex = Regex::new( + r"\B(@)(\w+)").unwrap(); + let replace_str = "<a href=\"/user/$2\">$0</a>"; + let people_parsed = person_regex.replace_all(&notes_parsed, &replace_str as &str).to_string(); + // TODO get mentions too + return people_parsed; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_string() { + assert!(parse_note_text("") == "") + } + + fn test_escape_html() { + assert!(parse_note_text("<script>haxxor</script>hi>") == "hi") + } + + #[test] + fn test_string_without_urls() { + let src = "<p>Some HTML</p>"; + assert!(parse_note_text(src) == "Some HTML") + } + + #[test] + fn test_string_with_http_urls() { + let src = "Check this out: https://doc.rust-lang.org/\n + https://fr.wikipedia.org/wiki/Caf%C3%A9ine"; + let linked = "Check this out: <a href=\"https://doc.rust-lang.org/\">https://doc.rust-lang.org/</a>\n + <a href=\"https://fr.wikipedia.org/wiki/Caf%C3%A9ine\">https://fr.wikipedia.org/wiki/Caf%C3%A9ine</a>"; + assert!(parse_note_text(src) == linked) + } + + #[test] + fn test_string_with_mailto_urls() { + let src = "Send spam to mailto://oz@cypr.io"; + assert!( + parse_note_text(src) + == "Send spam to <a href=\"mailto://oz@cypr.io\">mailto://oz@cypr.io</a>" + ) + } + + #[test] + fn test_string_with_trailing_chars() { + let src = "I love https://cat-bounce.com!\n + Have you seen https://en.wikipedia.org/wiki/Cat_(disambiguation)?"; + let linked = "I love <a href=\"https://cat-bounce.com\">https://cat-bounce.com</a>!\n + Have you seen <a href=\"https://en.wikipedia.org/wiki/Cat_(disambiguation)\">https://en.wikipedia.org/wiki/Cat_(disambiguation)</a>?"; + assert!(parse_note_text(src) == linked) + } + + #[test] + fn test_user_replace() { + let src = "@joe whats up @sally"; + let linked = "<a href=\"/user/joe\">@joe</a> whats up <a href=\"/user/sally\">@sally</a>"; + assert!(parse_note_text(src) == linked) + } + + #[test] + fn test_note_replace() { + let src = "📝123 cool post >>456"; + let linked = "<a href=\"/note/123\">📝123</a> cool post <a href=\"/note/456\">&gt;&gt;456</a>"; + assert!(parse_note_text(src) == linked) + } +} diff --git a/src/db/status.rs b/src/db/status.rs @@ -1,129 +0,0 @@ -use chrono; -use std::collections::HashSet; -use activitystreams::object::streams; -use diesel::sqlite::SqliteConnection; -use diesel::deserialize::{Queryable}; -use super::schema::notes; -use diesel::prelude::*; -use serde::{Deserialize, Serialize}; -use regex::Regex; -use ammonia; - -// Statuses are note activitystream object - -#[derive(Queryable, Clone, Deserialize, Serialize)] -pub struct Note { - pub id: i32, - pub creator_id: i32, - pub creator_username: String, - pub parent_id: Option<i32>, - pub content: String, - pub created_time: String, -} - -#[derive(Insertable, Clone)] -#[table_name = "notes"] -pub struct NoteInput { - //pub id: i32, //unsigned? - pub creator_id: i32, - pub creator_username: String, - pub parent_id: Option<i32>, - pub content: String, // can we make this a slice? - // pub published: chrono::NaiveDateTime, -} - -impl Note { - pub fn parse_note_text(mut self) -> Self { - self.content = parse_note_text(&self.content); - self - } -} - -/// Parse links -- stolen from https://git.cypr.io/oz/autolink-rust/src/branch/master/src/lib.rs -fn parse_note_text(text: &str) -> String { - // dont hack me - let html_clean = ammonia::clean_text(text); - if text.len() == 0 { - return String::new(); - } - let re = Regex::new( - r"(?ix) - \b(([\w-]+:&#47;&#47;?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/))) - ", - ) - .unwrap(); - let replace_str = "<a href=\"$0\">$0</a>"; - let urls_parsed = re.replace_all(&html_clean, &replace_str as &str).to_string(); - let note_regex = Regex::new( - r"\B(📝|&gt;&gt;)(\d+)", - ).unwrap(); - let replace_str = "<a href=\"/note/$2\">$0</a>"; - let notes_parsed = note_regex.replace_all(&urls_parsed, &replace_str as &str).to_string(); - let person_regex = Regex::new( - r"\B(@)(\w+)").unwrap(); - let replace_str = "<a href=\"/user/$2\">$0</a>"; - let people_parsed = person_regex.replace_all(&notes_parsed, &replace_str as &str).to_string(); - // TODO get mentions too - return people_parsed; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_empty_string() { - assert!(parse_note_text("") == "") - } - - fn test_escape_html() { - assert!(parse_note_text("<script>haxxor</script>hi>") == "hi") - } - - #[test] - fn test_string_without_urls() { - let src = "<p>Some HTML</p>"; - assert!(parse_note_text(src) == "Some HTML") - } - - #[test] - fn test_string_with_http_urls() { - let src = "Check this out: https://doc.rust-lang.org/\n - https://fr.wikipedia.org/wiki/Caf%C3%A9ine"; - let linked = "Check this out: <a href=\"https://doc.rust-lang.org/\">https://doc.rust-lang.org/</a>\n - <a href=\"https://fr.wikipedia.org/wiki/Caf%C3%A9ine\">https://fr.wikipedia.org/wiki/Caf%C3%A9ine</a>"; - assert!(parse_note_text(src) == linked) - } - - #[test] - fn test_string_with_mailto_urls() { - let src = "Send spam to mailto://oz@cypr.io"; - assert!( - parse_note_text(src) - == "Send spam to <a href=\"mailto://oz@cypr.io\">mailto://oz@cypr.io</a>" - ) - } - - #[test] - fn test_string_with_trailing_chars() { - let src = "I love https://cat-bounce.com!\n - Have you seen https://en.wikipedia.org/wiki/Cat_(disambiguation)?"; - let linked = "I love <a href=\"https://cat-bounce.com\">https://cat-bounce.com</a>!\n - Have you seen <a href=\"https://en.wikipedia.org/wiki/Cat_(disambiguation)\">https://en.wikipedia.org/wiki/Cat_(disambiguation)</a>?"; - assert!(parse_note_text(src) == linked) - } - - #[test] - fn test_user_replace() { - let src = "@joe whats up @sally"; - let linked = "<a href=\"/user/joe\">@joe</a> whats up <a href=\"/user/sally\">@sally</a>"; - assert!(parse_note_text(src) == linked) - } - - #[test] - fn test_note_replace() { - let src = "📝123 cool post >>456"; - let linked = "<a href=\"/note/123\">📝123</a> cool post <a href=\"/note/456\">&gt;&gt;456</a>"; - assert!(parse_note_text(src) == linked) - } -} diff --git a/src/lib.rs b/src/lib.rs @@ -3,6 +3,7 @@ extern crate diesel; #[macro_use] extern crate log; #[macro_use] extern crate lazy_static; +#[macro_use] extern crate maplit; use warp::{Reply, Filter, Rejection}; @@ -14,7 +15,8 @@ use warp::reject::{custom, not_found}; use hyper; use askama::Template; use env_logger; -use db::status::{NoteInput, Note}; +use db::note::{NoteInput, Note}; +use db::note; use db::user::{User, NewUser}; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; @@ -27,6 +29,7 @@ type SqlitePool = Pool<ConnectionManager<SqliteConnection>>; mod db; mod session; +mod ap; // We use a global shared sqlite connection because it's simple and performance is not // very important @@ -117,7 +120,7 @@ fn new_note(session: Option<Session>, req: NewNoteRequest) -> impl Reply { creator_id: s.user.id, creator_username: s.user.username, parent_id: None, - content: req.note_input.clone(), // how to avoid clone here? + content: note::parse_note_text(&req.note_input), // how to avoid clone here? }; insert_into(notes).values(new_note).execute(&POOL.get().unwrap()).unwrap(); return warp::redirect::redirect(warp::http::Uri::from_static("/")) @@ -242,11 +245,10 @@ fn render_timeline(session: Option<Session>) -> impl Reply { .limit(250) .load::<Note>(&POOL.get().unwrap()) .expect("Error loading posts"); - let parsed = results.into_iter().map(|n| n.parse_note_text()).collect(); render_template(&TimelineTemplate{ page: "timeline", global: global, - notes: parsed, + notes: results, }) } @@ -336,7 +338,7 @@ pub async fn run_server() { // cors filters etc let session_filter = move || session::create_session_filter().clone(); - use warp::{path, body::form}; + use warp::{path, body::json, body::form}; let home = warp::path::end() .and(session_filter()) @@ -388,6 +390,31 @@ pub async fn run_server() { let static_files = warp::path("static") .and(warp::fs::dir("./static")); + // activityPub stuff + // + // POST + let post_user_inbox = path!("user" / String / "inbox.json" ) + .and(json()) + .map(ap::post_user_inbox); + + let post_user_outbox = path!("user" / String / "outbox.json" ) + .and(json()) + .map(ap::post_user_outbox); + + let get_user_outbox = path!("user" / String / "outbox.json" ) + .map(ap::get_user_outbox); + + // let get_user_inbox = path!("user" / String / "outbox.json" ) + // .and(json()) + // .map(ap::post_user_outbox); + + let user_followers = path!("user" / String / "followers.json" ) + .map(ap::user_followers); + + let user_following = session_filter() + .and(path!("user" / String / "following.json" )) + .map(user_inbox); + // https://github.com/seanmonstar/warp/issues/42 -- how to set up diesel // TODO set content length limit // TODO redirect via redirect in request diff --git a/templates/single_note.html b/templates/single_note.html @@ -8,8 +8,11 @@ function reply(note_id) <div class="note"> <a href="/note/{{note.id}}">📝{{note.id}}</a> {{note.created_time}} <a class="bold" href="/user/{{note.creator_username}}">@{{note.creator_username}}</a> ▶ {{note.content|safe}} + {% if global.logged_in %} + <a href="#" onclick="reply({{note.id}})">↪</a> + {% endif %} {% if note.creator_id == global.user.id %} - <a href="#" onclick="reply({{note.id}})">↪</a> + <form method="post" action="/{{note.id}}/delete" class="inline"> <input type="hidden" name="extra_submit_param" value="extra_submit_value"> <button type="submit" name="submit_param" value="submit_value" class="link-button"> x