gourami

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

commit 94b528c68148928f1ed1c4e2156bfd1b965ebafa
parent f326dba07da5f0ba3f5274c65587d93d1d22be04
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sat,  2 May 2020 12:14:27 -0500

Prototyping local/neighborhood

Some restructuring
Add clap

Diffstat:
M.gitignore | 1+
MCargo.lock | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 1+
MTODO | 14+++++++-------
Amigrations/2020-05-02-001130_remoteuser/down.sql | 2++
Amigrations/2020-05-02-001130_remoteuser/up.sql | 12++++++++++++
Msrc/ap.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Asrc/db/conn.rs | 15+++++++++++++++
Msrc/db/mod.rs | 1+
Msrc/db/note.rs | 7+++++++
Msrc/db/schema.rs | 13+++++++++++--
Asrc/db/server_mutuals.rs | 8++++++++
Msrc/db/user.rs | 32++++++++++++++++++++++++--------
Msrc/lib.rs | 52++++++++++++++--------------------------------------
Msrc/main.rs | 22+++++++++++++++++++++-
Mstatic/css/style.css | 6++++++
Mtemplates/single_note.html | 6++++++
17 files changed, 253 insertions(+), 67 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,3 +2,4 @@ test_env sample.db warp-diesel-ructe-sample/ +/local diff --git a/Cargo.lock b/Cargo.lock @@ -51,6 +51,15 @@ dependencies = [ ] [[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi 0.3.8", +] + +[[package]] name = "askama" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -277,6 +286,21 @@ dependencies = [ ] [[package]] +name = "clap" +version = "2.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5067f5bb2d80ef5d68b4c87db81601f0b75bca627bc2ef76b141d7b846a3c6d9" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] name = "cloudabi" version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -330,7 +354,7 @@ dependencies = [ "ident_case", "proc-macro2 1.0.10", "quote 1.0.3", - "strsim", + "strsim 0.9.3", "syn 1.0.17", ] @@ -631,6 +655,7 @@ dependencies = [ "askama", "bcrypt", "chrono", + "clap", "cookie", "diesel", "env_logger", @@ -1942,6 +1967,12 @@ dependencies = [ [[package]] name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "strsim" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" @@ -2003,6 +2034,15 @@ dependencies = [ ] [[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] name = "thiserror" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2250,6 +2290,12 @@ dependencies = [ ] [[package]] +name = "unicode-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" + +[[package]] name = "unicode-xid" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2297,6 +2343,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fc439f2794e98976c88a2a2dafce96b930fe8010b0a256b3c2199a773933168" [[package]] +name = "vec_map" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a" + +[[package]] name = "version_check" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -9,6 +9,7 @@ activitystreams = "0.6.0" askama = "0.8.0" bcrypt = "0.7.0" cookie = "0.13.3" +clap = "2.33.0" chrono = "0.4.11" diesel = { version = "1.4.4", features = ["sqlite", "r2d2"] } env_logger = "0.7.1" diff --git a/TODO b/TODO @@ -1,17 +1,17 @@ -CORE FEATURES +v0.1.0 goals: sharing with a small community -Change to GPL v3 license due to dependencies +major + +minor +- better tagging / notifications +- better session management? +- Change to GPL v3 license due to dependencies Proper threading - Render all DESCENDENTS (allow for replies to multiple items...) -Notifications -- Notify on tag - -Replace notes with >> - Federation / ActivityPub retryeets following/followers & separate timelines diff --git a/migrations/2020-05-02-001130_remoteuser/down.sql b/migrations/2020-05-02-001130_remoteuser/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` +\ No newline at end of file diff --git a/migrations/2020-05-02-001130_remoteuser/up.sql b/migrations/2020-05-02-001130_remoteuser/up.sql @@ -0,0 +1,12 @@ +-- Your SQL goes here + +alter table users add remote_url VARCHAR(1000); + +create unique index uniqremote on users(remote_url); + +create table server_mutuals ( + id integer primary key autoincrement, + inbox_url VARCHAR(1000), + outbox_url VARCHAR(1000) +); + diff --git a/src/ap.rs b/src/ap.rs @@ -10,9 +10,12 @@ 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; lazy_static! { // const SERVER_ACTOR = "gourami.social" } @@ -28,7 +31,7 @@ fn categorize_input_message(v: Value) -> Action { Action::DoNothing } -pub fn parse_create_note(v: Value) -> Result<RemoteNoteInput, Box<dyn std::error::Error>>{ +pub fn process_create_note(conn: &SqliteConnection, v: Value) -> Result<(), Box<dyn std::error::Error>>{ // Actions usually associated with notes // maybe there's a cleaner way to do this. cant iterate over types // TODO inbox forwarding https://www.w3.org/TR/activitypub/#inbox-forwarding @@ -43,23 +46,74 @@ pub fn parse_create_note(v: Value) -> Result<RemoteNoteInput, Box<dyn std::error let remote_creator = object.get("attributedTo").ok_or("No attributedTo found")?.as_str().ok_or("Not a string")?; let remote_url = object.get("url").ok_or("No url Found")?.as_str().ok_or("Not a string")?; let remote_id = object.get("id").ok_or("No ID found")?.as_str().ok_or("Not a string")?; + + use crate::db::schema::users::dsl as u; + use crate::db::schema::notes::dsl as n; + // if user not in db, insert + // + let new_user = NewRemoteUser { + username: String::from(remote_creator), + remote_url: Some(String::from(remote_creator)), + }; + // + insert_into(u::users) + .values(&new_user) + .execute(conn).ok(); // TODO only check unique constraint error + + let new_user_id: i32 = u::users.select(u::id) + .filter(u::remote_url.eq(remote_creator)) + .first(conn).unwrap(); + let new_remote_note = RemoteNoteInput { content: String::from(content), - in_reply_to: None, + in_reply_to: None, // TODO neighborhood: true, is_remote: true, - user_id: -1, // for remote. placeholder. not sure what to do with this ultimately + user_id: new_user_id, // for remote. placeholder. not sure what to do with this ultimately remote_creator: String::from(remote_creator), remote_id: String::from(remote_id), remote_url: String::from(remote_url) } ; println!("{:?}", new_remote_note); - return Ok(new_remote_note); + insert_into(n::notes).values(&new_remote_note) + .execute(conn).unwrap(); + return Ok(()); +} + + +pub fn get_destinations() -> Vec<String> { // maybe lazy static this + use crate::db::schema::server_mutuals::dsl::*; + let conn = &POOL.get().unwrap(); + server_mutuals.select(inbox_url).load(conn).unwrap() } +pub async fn send_ap_message(ap_message: &Value, destinations: Vec<String>) -> Result<(), reqwest::Error> { + // Right now we have only once delivery + for destination in destinations { + let client = reqwest::Client::new(); + client.post(&destination) + .json(&ap_message) + .send().await?; + } + Ok(()) +} + +fn generate_server_follow(remote_url: String) -> Value { + json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://my-example.com/my-first-follow", + "type": "Follow", + "actor": "https://my-example.com/actor", + "object": remote_url, + }) +} /// Generate an AP create message from a new note -pub fn new_note_to_ap_message(note_input: &NoteInput) -> Value{ +pub fn new_note_to_ap_message(note: &NoteInput, user: &User) -> Value { + // we need note, user. note noteinput but note obj + let conn = &POOL.get().unwrap(); + // Do a bunch of db queries to get the info I need json!({ + "@context": "https://www.w3.org/ns/activitystreams", "id": "someid", "type": "Create", "actor": "my_server/actor", // get from DEPLOY_URL @@ -73,7 +127,7 @@ pub fn new_note_to_ap_message(note_input: &NoteInput) -> Value{ "url": "abc", "inReplyTo": "none", "attributedTo": "joe", - "content": "whats up", + "content": note.content } }) } @@ -90,7 +144,7 @@ mod tests { // to write } - #[test] + // #[test] fn test_mastodon_create_status_example() { let create_note_mastodon = serde_json::from_str(r#"{ "id": "https://mastodon.social/users/alexwennerberg/statuses/104028309437021899/activity", @@ -139,7 +193,7 @@ mod tests { } } }"#).unwrap(); - assert_eq!(parse_create_note(create_note_mastodon).unwrap(), RemoteNoteInput { + assert_eq!(process_create_note(create_note_mastodon).unwrap(), RemoteNoteInput { content: String::from("hello world"), in_reply_to: None, neighborhood: true, diff --git a/src/db/conn.rs b/src/db/conn.rs @@ -0,0 +1,15 @@ +use std; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::sqlite::SqliteConnection; + +type SqlitePool = Pool<ConnectionManager<SqliteConnection>>; +// We use a global shared sqlite connection because it's simple and performance is not +// very important +lazy_static! { + pub static ref POOL: SqlitePool = pooled_sqlite(); +} + +fn pooled_sqlite() -> SqlitePool { + let manager = ConnectionManager::<SqliteConnection>::new(std::env::var("DATABASE_URL").unwrap()); + Pool::new(manager).expect("Postgres connection pool could not be created") +} diff --git a/src/db/mod.rs b/src/db/mod.rs @@ -2,3 +2,4 @@ pub mod note; pub mod schema; pub mod user; pub mod notification; +pub mod conn; diff --git a/src/db/note.rs b/src/db/note.rs @@ -1,3 +1,4 @@ +use std::env; use maplit::hashset; use super::schema::notes; use serde::{de::Error, Deserialize, Serialize, Deserializer}; @@ -23,6 +24,12 @@ pub struct Note { // rename RenderedNote pub remote_id: Option<String> } +impl Note { + fn get_url(&self) -> String { + format!("{}/note/{}", env::var("GOURAMI_DOMAIN").unwrap(), self.id) + } +} + /// Content in the DB is stored in plaintext (WILL BE) /// We want to render it so that it is rendered in HTML /// This basically just means escaping characters and adding diff --git a/src/db/schema.rs b/src/db/schema.rs @@ -23,11 +23,12 @@ table! { users (id) { id -> Integer, username -> Varchar, - email -> Varchar, + email -> Nullable<Varchar>, bio -> Text, created_time -> Timestamp, - password -> Varchar, + password -> Nullable<Varchar>, admin -> Bool, + remote_url -> Nullable<Varchar>, } } @@ -57,6 +58,14 @@ table! { } } +table! { + server_mutuals (id) { + id -> Integer, + inbox_url -> Varchar, + outbox_url -> Nullable<Varchar>, + } +} + joinable!(sessions -> users (user_id)); joinable!(notes -> users (user_id)); joinable!(notification_viewers -> notifications (notification_id)); diff --git a/src/db/server_mutuals.rs b/src/db/server_mutuals.rs @@ -0,0 +1,8 @@ +use diesel::sqlite::SqliteConnection; + +#[derive(Queryable, Debug)] +struct ServerMutuals { + id: i32, + inbox_url: String, + outbox_url: String, +} diff --git a/src/db/user.rs b/src/db/user.rs @@ -1,4 +1,5 @@ use diesel::sqlite::SqliteConnection; +use std::env; use super::schema::users; use diesel::prelude::*; use serde::{Deserialize}; @@ -36,25 +37,29 @@ impl RegistrationKey { pub struct User { pub id: i32, pub username: String, - pub email: String, + pub email: Option<String>, // TODO option pub bio: String, pub created_time: String, - pub password: String, // is this OK? hashed - pub admin: bool, -} - -// TODO -- default "anonymous" user + pub password: Option<String>, + pub admin: bool, + pub remote_url: Option<String>, +} impl User { + pub fn get_url(&self) -> String { + format!("{}/user/{}", env::var("GOURAMI_DOMAIN").unwrap(), self.username) + // remote url? + } pub fn authenticate( conn: &SqliteConnection, user: &str, pass: &str, ) -> Option<Self> { use crate::db::schema::users::dsl::*; + // TODO -- allow email login as well let user = match users .filter(username.eq(user)) - .first::<User>(conn) + .first::<Self>(conn) { Ok(user) => user, Err(e) => { @@ -63,7 +68,12 @@ impl User { } }; - match bcrypt::verify(&pass, &user.password) { + let u_pass = match &user.password { + Some(p) => p, + None => return None + }; + + match bcrypt::verify(&pass, &u_pass) { Ok(true) => Some(user), Ok(false) => None, Err(e) => { @@ -82,6 +92,12 @@ pub struct NewUser<'a> { pub email: &'a str, } +#[derive(Insertable, Deserialize)] +#[table_name="users"] +pub struct NewRemoteUser { + pub username: String, + pub remote_url: Option<String>, +} // impl NewUser { // } diff --git a/src/lib.rs b/src/lib.rs @@ -17,17 +17,15 @@ use warp::hyper::Body; use hyper; use askama::Template; use db::note::{NoteInput, Note}; +use db::conn::POOL; use db::note; use db::user::{RegistrationKey, User, NewUser}; use db::notification::{NewNotification, NewNotificationViewer, Notification, NotificationViewer}; use diesel::prelude::*; -use diesel::sqlite::SqliteConnection; use diesel::insert_into; use serde::{Deserialize}; use session::{Session}; -use diesel::r2d2::{ConnectionManager, Pool}; -type SqlitePool = Pool<ConnectionManager<SqliteConnection>>; mod db; mod session; @@ -35,23 +33,6 @@ mod ap; pub mod routes; -// We use a global shared sqlite connection because it's simple and performance is not -// very important - -fn pooled_sqlite() -> SqlitePool { - let manager = ConnectionManager::<SqliteConnection>::new(std::env::var("DATABASE_URL").unwrap()); - Pool::new(manager).expect("Postgres connection pool could not be created") -} - - -lazy_static! { - static ref POOL: SqlitePool = pooled_sqlite(); -} - -// fn POOL.get().unwrap() -> diesel::SqliteConnection { -// return *POOL.get().unwrap(); - - struct Global<'a> { // variables used on all pages w header title: &'a str, page: &'a str, @@ -137,22 +118,18 @@ struct NewNoteRequest { async fn handle_new_note_form(u: Option<User>, f: NewNoteRequest) -> Result<impl Reply, Rejection> { match u { Some(u) => { - let n = new_note(u, &f.note_input, f.neighborhood.is_some()).unwrap(); - send_note(&n).await.unwrap(); // TODO error handling + let n = new_note(&u, &f.note_input, f.neighborhood.is_some()).unwrap(); + if n.neighborhood { + let nj = ap::new_note_to_ap_message(&n, &u); + let destinations = ap::get_destinations(); + ap::send_ap_message(&nj, destinations).await.unwrap(); // TODO error handling + } let red_url: http::Uri = f.redirect_url.parse().unwrap(); Ok(redirect(red_url))}, None => Ok(redirect(http::Uri::from_static("error")))} } -async fn send_note(note: &NoteInput) -> Result<(), reqwest::Error> { - let nj = ap::new_note_to_ap_message(note); - let client = reqwest::Client::new(); - client.post("http://localhost:3030/inbox.json") - .json(&nj) - .send().await?; - Ok(()) -} -pub fn new_note(auth_user: User, note_input: &str, neighborhood: bool,) -> Result<NoteInput, Box<dyn std::error::Error>> { +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; // create activitypub activity object // TODO -- micropub? @@ -384,8 +361,10 @@ fn get_notes(params: GetPostsParams, neighborhood: Option<bool>) -> Result<Vec<U query = query.filter(u::id.eq(u_id)); } match neighborhood { - Some(n) => query = query.filter(n::neighborhood.eq(n)), - None => () + Some(true) => query = query.filter(n::neighborhood.eq(true)), + Some(false) => query = query.filter(n::is_remote.eq(false)), + // OR is neighborhood and reply is to a neighborhood tweet + None => (), } let results = query.load::<(Note, User)>(&POOL.get().unwrap()).unwrap(); // TODO get rid of unwrap Ok(results.into_iter().map(|a| UserNote{note: a.0, username: a.1.username}).collect()) @@ -430,7 +409,7 @@ fn render_timeline(auth_user: Option<User>, params:GetPostsParams, url_path: Ful // pulls a bunch of data i dont really need let header = Global::create(auth_user, url_path.as_str()); // TODO -- ignore neighborhood replies - let notes = get_notes(params, None); + let notes = get_notes(params, Some(false)); match notes { Ok(n) => render_template(&TimelineTemplate{ global: header, @@ -570,11 +549,8 @@ pub fn user_following(user_name: String) {} pub fn post_inbox(message: Value) -> impl Reply { // TODO check if it is a create note message - use db::schema::notes::dsl::*; let conn = &POOL.get().unwrap(); - let mynote = ap::parse_create_note(message).unwrap(); - insert_into(notes).values(&mynote).execute(conn).unwrap(); - // insert new note into database + ap::process_create_note(conn, message).unwrap(); // thtas it! Ok("ok!") } diff --git a/src/main.rs b/src/main.rs @@ -1,6 +1,26 @@ +use clap::{App, Arg, SubCommand}; use gourami_social::routes::run_server; #[tokio::main] async fn main() { - run_server().await; + let matches = App::new("Gourami") + .version("0.0.0") + .author("Alex Wennerberg <alex@alexwennerberg.com>") + .about("Gourami server and admin tools") + .subcommand( + App::new("run") + .about("Run server") + ) + .subcommand( + App::new("admin") + .about("Admin Tools") + ).get_matches(); + if let Some(m) = matches.subcommand_matches("run") { + run_server().await; + } + else if let Some(m) = matches.subcommand_matches("admin") { + // write admin commands here + // reset password + // follow remote + } } diff --git a/static/css/style.css b/static/css/style.css @@ -49,6 +49,12 @@ a:link, a:visited { padding: 3px; } +.remote-note { + background-color: #fddcd8; + margin: .5em; + padding: 3px; +} + .notif-unread { background-color: #fff9e2; margin: .5em; diff --git a/templates/single_note.html b/templates/single_note.html @@ -1,6 +1,12 @@ +{% if note.note.is_remote %} +<div class="remote-note"> +{% else %} <div class="note"> +{% endif %} <div class="note-meta"> + {% if note.note.neighborhood %}🏠{%endif%} + {% if note.note.is_remote %}🌎{%endif%} <a href="/note/{{note.note.id}}">📝{{note.note.id}}</a> {{note.note.created_time}} <a class="bold" href="/user/{{note.username}}">@{{note.username}}</a> {% if global.logged_in %}