gourami

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

commit e6e9377db5e946b5873a2efcc7e2d19d559326ed
parent 80158ade03582ed485e7122ea6b6c54150140b86
Author: Alex Wennerberg <alex@alexwennerberg.com>
Date:   Fri,  5 Jun 2020 16:27:17 -0500

Merge pull request #24 from alexwennerberg/restructure

Restructure
Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
MREADME.md | 2+-
Amigrations/2020-06-05-211246_add_wesbite/down.sql | 2++
Amigrations/2020-06-05-211246_add_wesbite/up.sql | 5+++++
Msample_env | 2--
Msrc/db/schema.rs | 1+
Msrc/db/user.rs | 4+++-
Msrc/lib.rs | 123+++++++++++++++++++------------------------------------------------------------
Msrc/main.rs | 2+-
Msrc/routes.rs | 31+++++++++----------------------
Asrc/schema.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/base.html | 6++++--
Mtemplates/createnote.html | 2+-
Mtemplates/edit_user.html | 5++++-
Dtemplates/note.html | 8--------
Mtemplates/server_info.html | 21++-------------------
Mtemplates/single_note.html | 7+++----
Mtemplates/timeline.html | 5+++++
Dtemplates/user.html | 29-----------------------------
Atemplates/user_header.html | 25+++++++++++++++++++++++++
21 files changed, 171 insertions(+), 187 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -649,7 +649,7 @@ dependencies = [ [[package]] name = "gourami_social" -version = "0.1.2" +version = "0.1.4" dependencies = [ "activitystreams", "ammonia", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gourami_social" -version = "0.1.3" +version = "0.1.4" authors = ["alex wennerberg <alex@alexwennerberg.com>"] edition = "2018" diff --git a/README.md b/README.md @@ -17,7 +17,7 @@ An intentionally small, community-focused ultra-lightweight ActivityPub social n Gourami differs from existing social networks in a number of ways: * **Intentionally small** -- designed to support 50-100 active users. I'm sure it could support more, but things could quickly become a mess. Gourami was hugely and directly inspired by the fantastic essay on [runyourown.social](https://runyourown.social). Gourami is designed to support relatively small communities, maybe tied to a specific interest, community, or physical location. * **Decentralized** -- Gourami uses [ActivityPub](https://activitypub.rocks/) to connect separate servers, but with an implementation that differs from existing ActivityPub servers such as Mastodon. Instances federate at the server level, rather than the user level, which means all users on the server share the same "neighborhood". -* **Invite-only and closed** -- a community curated by the server admin, rather than open to all. You can choose to make posts on your instance public, but they are private by default. +* **Invite-only and closed** -- a community curated by the server admin, rather than open to all. Server-local posts are private. * **Community, rather than user focused** -- All users share the same timeline(s), and ActivityPub federation occurs on the server, rather than user level. This is somewhat different than how most ActivityPub servers work, and focuses on privacy, community, and locality over easily-shareable public content. This may change if users are interested in a more conventional AP implementation, but I thought it'd be interesting to experiment with a different model for federation. * **Free and open source** -- I find very concerning the way that the very space for building community and networking with our friends is controlled by corporations with potentially different values and goals than their users. Large, for-profit social networks have economic incentives that distorts the content and the kind of communities that can develop on them. Gourami is 100% free and open source, licensed under [AGPL v3](LICENSE). * **A social network with physical context** -- Gourami should be easy to deploy in a physical space (such as a home, apartment building, coffee shop or [wireless mesh network](https://www.nycmesh.net/)) or among people in a specific physical community, such as a university or town. In *How to Do Nothing*, Jenny Odell discusses the lack of a context, specifically physical and temporal context, in social media, and calls for social networks that are tied to physical space. While Gourami does not force you to tie a deployment to a place, it is designed in such a way that such a deployment would be relatively easy. diff --git a/migrations/2020-06-05-211246_add_wesbite/down.sql b/migrations/2020-06-05-211246_add_wesbite/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` +\ No newline at end of file diff --git a/migrations/2020-06-05-211246_add_wesbite/up.sql b/migrations/2020-06-05-211246_add_wesbite/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here + +ALTER TABLE users +add column website VARCHAR(255) default ""; + diff --git a/sample_env b/sample_env @@ -2,8 +2,6 @@ export DATABASE_URL=sample.db export RUST_LOG=debug,hyper=info,html5ever=info export GOURAMI_DOMAIN="localhost:3030" export SSL_ENABLED="0" -# Public instances will make all posts visible to anyone -export PUBLIC="0" # Used for http signature verification. Not necessary if you aren't federating your server. # If you are using federation, you'll have to generate these keys. diff --git a/src/db/schema.rs b/src/db/schema.rs @@ -22,6 +22,7 @@ table! { id -> Integer, username -> Varchar, email -> Nullable<Varchar>, + website -> Varchar, bio -> Text, created_time -> Timestamp, password -> Nullable<Varchar>, diff --git a/src/db/user.rs b/src/db/user.rs @@ -35,8 +35,9 @@ impl RegistrationKey { // a hack #[derive(QueryableByName)] #[table_name = "users"] -pub struct Username { +pub struct UserNameId { pub username: String, + pub id: i32, } #[derive(Debug, Clone, Default, Queryable, QueryableByName, Deserialize)] @@ -45,6 +46,7 @@ pub struct User { pub id: i32, pub username: String, pub email: Option<String>, // TODO option + pub website: String, // TODO option pub bio: String, pub created_time: String, pub password: Option<String>, diff --git a/src/lib.rs b/src/lib.rs @@ -25,7 +25,7 @@ pub use db::conn::POOL; use db::note; use db::note::{Note, NoteInput}; use db::server_mutuals::ServerMutual; -use db::user::{NewUser, RegistrationKey, User, Username}; +use db::user::{NewUser, RegistrationKey, User, UserNameId}; use diesel::insert_into; use diesel::prelude::*; use hyper; @@ -41,22 +41,6 @@ pub mod routes; mod session; #[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>, @@ -76,6 +60,8 @@ struct UserEditTemplate<'a> { struct TimelineTemplate<'a> { global: Global<'a>, notes: Vec<UserNote>, + params: &'a GetPostsParams, + user: Option<User> } #[derive(Template)] @@ -374,39 +360,30 @@ struct GetPostsParams { all: Option<bool>, search_string: Option<String>, user_id: Option<i32>, + username: Option<String>, + note_id: Option<i32>, } + fn default_page() -> i64 { 1 } -impl Default for GetPostsParams { - // is this used? - fn default() -> Self { - GetPostsParams { - page: 1, - user_id: None, - neighborhood: Some(false), - all: Some(false), - search_string: None, - } - } -} - pub struct UserNote { note: Note, username: String, + user_id: i32, } fn get_single_note(note_id: i32) -> Option<Vec<UserNote>> { // Get note and all children, recursively let conn = &POOL.get().unwrap(); - let results: Vec<(Note, Username)> = diesel::sql_query(format!( + let results: Vec<(Note, UserNameId)> = 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 + select notes.*, users.username, users.id from notes join users on notes.user_id = users.id where notes.id in tc", note_id @@ -421,6 +398,7 @@ fn get_single_note(note_id: i32) -> Option<Vec<UserNote>> { UserNote { note: a.0, username: a.1.username, + user_id: a.1.id, } }) .collect(), @@ -435,10 +413,15 @@ fn get_local_users() -> Result<Vec<User>, diesel::result::Error> { } /// We have to do a join here -fn get_notes(params: &GetPostsParams) -> Result<Vec<UserNote>, diesel::result::Error> { +fn get_notes(logged_in: bool, params: &GetPostsParams) -> Result<Vec<UserNote>, diesel::result::Error> { use db::schema::notes::dsl as n; use db::schema::users::dsl as u; // TODO -- add whether this is complete so i can page properly + if logged_in { + if let Some(n_id) = params.note_id { + return Ok(get_single_note(n_id).unwrap()) // TODO filter replies logged out + } + } let mut query = n::notes .inner_join(u::users) .order(n::id.desc()) @@ -452,6 +435,9 @@ fn get_notes(params: &GetPostsParams) -> Result<Vec<UserNote>, diesel::result::E query = query.filter(n::content.like(format!("%{}%", search))); } // this is wonky + if !logged_in { + query = query.filter(n::neighborhood.eq(true)) + } if !params.all.is_some() { match params.neighborhood { Some(true) => query = query.filter(n::neighborhood.eq(true)), @@ -465,6 +451,7 @@ fn get_notes(params: &GetPostsParams) -> Result<Vec<UserNote>, diesel::result::E .map(|a| UserNote { note: a.0, username: a.1.username, + user_id: a.1.id, }) .collect()) } @@ -485,9 +472,11 @@ fn render_timeline( let mut header = Global::create(auth_user, url_with_params); header.page_title = ""; // wonky - if params.neighborhood == Some(true) { - header.page_title = "neighborhood"; - } + use db::schema::users::dsl as u; + let user = match params.user_id { + Some(u_id) => u::users.filter(u::id.eq(u_id)).first(&POOL.get().unwrap()).ok(), + None => None, + }; header.page_num = params.page; // TODO -- ignore neighborhood replies match notes { @@ -499,6 +488,8 @@ fn render_timeline( render_template(&TimelineTemplate { global: header, notes: n, + user: user, + params: params }) } _ => render_template(&ErrorTemplate { @@ -528,56 +519,6 @@ 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, - }) - } else { - render_template(&ErrorTemplate { - global: Global::create(auth_user, path.as_str()), - error_message: "Note not found", - }) - } -} - -fn user_page( - auth_user: Option<User>, - user_name: String, - mut params: GetPostsParams, - path: FullPath, -) -> impl Reply { - let mut header = Global::create(auth_user, path.as_str()); // maybe if i'm clever i can abstract this away - header.page_num = params.page; - use db::schema::users::dsl::*; - let conn = &POOL.get().unwrap(); - let user: Option<User> = users - .filter(username.eq(user_name)) - .first::<User>(conn) - .ok(); - if let Some(u) = user { - params.user_id = Some(u.id); - let notes = get_notes(&params).unwrap(); - // NOTE -- breaks when exactly 50 notes - if notes.len() == PAGE_SIZE as usize { - header.has_more = true; - } - render_template(&UserTemplate { - global: header, - user: u.clone(), // TODO stop cloning - notes: notes, - }) - } else { - render_template(&ErrorTemplate { - global: header, - error_message: "User not found", - ..Default::default() - }) - } -} - fn render_user_edit_page(user: Option<User>, user_name: String) -> impl Reply { let u = user.clone().unwrap(); let global = Global::create(user, "/edit"); @@ -597,14 +538,6 @@ fn render_user_edit_page(user: Option<User>, user_name: String) -> impl Reply { pub fn get_outbox() {} -// pub fn post_outbox(message: Value) {} - -// TODO figure out how to follow mastodon -// -// pub fn user_followers(user_name: String) {} - -// pub fn user_following(user_name: String) {} - use warp::Buf; pub async fn post_inbox( @@ -638,6 +571,7 @@ pub async fn post_inbox( struct EditForm { redirect_url: String, bio: String, + website: String, show_email: Option<String>, email: String, } @@ -651,6 +585,7 @@ fn edit_user(user: Option<User>, user_name: String, f: EditForm) -> impl Reply { .set(( bio.eq(&f.bio), email.eq(&f.email), + website.eq(&f.website), show_email.eq(&f.show_email.is_some()), )) .execute(conn) diff --git a/src/main.rs b/src/main.rs @@ -16,7 +16,7 @@ async fn main() { let conn = &POOL.get().unwrap(); embedded_migrations::run(conn).unwrap(); let matches = App::new("Gourami") - .version("0.1.3") + .version("0.1.4") .author("Alex Wennerberg <alex@alexwennerberg.com>") .about("Gourami server and admin tools") .subcommand(App::new("run").about("Run server")) diff --git a/src/routes.rs b/src/routes.rs @@ -12,9 +12,8 @@ use warp::{ pub async fn run_server() { // NOT TESTED YET - let public = std::env::var("PUBLIC").ok() == Some("1".to_owned()); - let session_filter = move || session::create_session_filter(public).clone(); - let private_session_filter = move || session::create_session_filter(false).clone(); + let optional_session_filter = move || session::create_session_filter(true).clone(); + let session_filter = move || session::create_session_filter(false).clone(); // Background worker for sending activitypub messages // TODO -- Improve concurrency. each request is blocking. @@ -63,32 +62,22 @@ pub async fn run_server() { |_| reply::json(&ap::server_actor_json()), // how do async work ); + // replace with timeline query let home = warp::path::end() - .and(session_filter()) - .and(query()) - .and(path::full()) - .map(|a, p, u| render_timeline(a, &p, u, get_notes(&p))); - - let user_page = session_filter() - .and(path!("user" / String)) + .and(optional_session_filter()) .and(query()) .and(path::full()) - .map(user_page); + .map(|user: Option<User>, params, url| render_timeline(user.clone(), &params, url, get_notes(user.is_some(), &params))); - let user_edit_page = private_session_filter() + let user_edit_page = session_filter() .and(path!("user" / String / "edit")) .map(render_user_edit_page); - let edit_user = private_session_filter() + let edit_user = session_filter() .and(path!("user" / String / "edit")) .and(form()) .map(edit_user); - let note_page = session_filter() - .and(path!("note" / i32)) - .and(path::full()) - .map(note_page); - let server_info_page = session_filter() .and(path("server_info")) .map(server_info_page); @@ -106,13 +95,13 @@ pub async fn run_server() { let do_logout = path("logout").and(cookie::cookie("EXAUTH")).map(do_logout); let create_note = path("create_note") - .and(private_session_filter()) + .and(session_filter()) .and(form()) .and(with_sender) .and_then(handle_new_note_form); let delete_note = path("delete_note") - .and(private_session_filter()) + .and(session_filter()) .and(form()) .map(|u: Option<User>, f: DeleteNoteRequest| match u { Some(u) => { @@ -161,8 +150,6 @@ pub async fn run_server() { let html_renders = home .or(login_page) .or(register_page) - .or(user_page) - .or(note_page) .or(server_info_page) .or(user_edit_page); let forms = do_register diff --git a/src/schema.rs b/src/schema.rs @@ -0,0 +1,74 @@ +table! { + activities (id) { + id -> Integer, + json_text -> Nullable<Text>, + } +} + +table! { + notes (id) { + id -> Integer, + user_id -> Nullable<Integer>, + content -> Nullable<Text>, + created_time -> Nullable<Timestamp>, + in_reply_to -> Nullable<Binary>, + neighborhood -> Nullable<Bool>, + is_remote -> Nullable<Bool>, + remote_url -> Nullable<Text>, + remote_creator -> Nullable<Text>, + remote_id -> Nullable<Text>, + } +} + +table! { + registration_keys (value) { + value -> Nullable<Text>, + } +} + +table! { + server_mutuals (id) { + id -> Nullable<Integer>, + actor_id -> Nullable<Text>, + inbox_url -> Nullable<Text>, + accepted -> Nullable<Bool>, + followed_back -> Nullable<Bool>, + outbox_url -> Nullable<Text>, + } +} + +table! { + sessions (id) { + id -> Integer, + cookie -> Text, + user_id -> Integer, + created_time -> Nullable<Timestamp>, + } +} + +table! { + users (id) { + id -> Integer, + username -> Nullable<Text>, + email -> Nullable<Text>, + bio -> Nullable<Text>, + website -> Varchar, + created_time -> Nullable<Timestamp>, + password -> Nullable<Text>, + admin -> Nullable<Bool>, + show_email -> Nullable<Bool>, + remote_user -> Nullable<Bool>, + } +} + +joinable!(notes -> users (user_id)); +joinable!(sessions -> users (user_id)); + +allow_tables_to_appear_in_same_query!( + activities, + notes, + registration_keys, + server_mutuals, + sessions, + users, +); diff --git a/templates/base.html b/templates/base.html @@ -23,9 +23,11 @@ <div class="title">🐟{{global.title}}/<wbr>{{global.page_title}}</div> </div> <div class="navlinks"> - <a href="/">local</a> <a href="/?neighborhood=true">neighborhood</a> {% if global.logged_in %} + {% if global.logged_in %} + <a href="/">local</a>{% endif %} <a href="/?neighborhood=true">neighborhood</a> {% if global.logged_in %} <a href="/?search_string={{global.me.username}}&all=true">mentions</a> {% endif %} - <a href="/server_info">server</a> {% if global.logged_in %} <a href="/user/{{global.me.username}}">@{{global.me.username}}</a>{% else %} <a href="login">login</a>{% endif %} + <a href="/server_info">server</a> {% if global.logged_in %} <a + href="/?user_id={{global.me.id}}">@{{global.me.username}}</a>{% else %} <a href="/register">register</a> <a href="login">login</a>{% endif %} </div> </div> <div class="padded main-content"> diff --git a/templates/createnote.html b/templates/createnote.html @@ -9,7 +9,7 @@ checked> {% else %} unchecked> {% endif %} -<label for="neighborhood">Share with neighborhood?</label> +<label for="neighborhood">Make public?</label> <input type="hidden" name="redirect_url" value="{{global.page}}"> </form> </div> diff --git a/templates/edit_user.html b/templates/edit_user.html @@ -15,6 +15,9 @@ ""> {%endmatch%} <br> +<label for="bio"><b>website:</b></label> +<input class="basic-input" type="text" id="website" name="website" value="{{user.website}}"> +<br> <label for="show_email"><b>show_email?</b></label> <input type="checkbox" id="show_email" name="show_email" {% if user.show_email %} @@ -22,7 +25,7 @@ checked> {% else %} unchecked> {% endif %} -<input type="hidden" name="redirect_url" value="/user/{{user.username}}"> +<input type="hidden" name="redirect_url" value="/?user_id={{user.id}}"> <br> <button id="post" class="submit-button-style">submit</button> </form> diff --git a/templates/note.html b/templates/note.html @@ -1,8 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -{% include "createnote.html" %} -{% for note in note_thread %} - {% include "single_note.html" %} -{% endfor %} -{% endblock %} diff --git a/templates/server_info.html b/templates/server_info.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} <div class="text-block padded"> - Welcome to Gourami, an intentionally small, minimalist, invite-only <a href="https://activitypub.rocks/">ActivityPub</a>-based social network. See the <a href="https://github.com/alexwennerberg/gourami">project page</a> for more info! This server can connect with other Gourami servers or other services that implement ActivityPub through the neighborhood. Be aware that any post that you share with the neibhorhood is potentially public, and won't be deleted from servers it's shared with if you delete it, so be careful! Right now, we are linked with the following actors: + Welcome to Gourami, an intentionally small, minimalist, invite-only <a href="https://activitypub.rocks/">ActivityPub</a>-based social network. See the <a href="https://github.com/alexwennerberg/gourami">project page</a> for more info! This server can connect with other Gourami servers or other services that implement ActivityPub through the neighborhood. Right now, we are linked with the following actors: <br> <table> {% for server in server_mutuals %} @@ -17,25 +17,8 @@ Here is a list of users on this server -- maybe introduce yourself to someone you don't know! By default your email is not shared, but you can share it to other users on this instance on your edit user page. <br> -<table> -<tr> - <th>username</th> - <th>email</th> - <th>bio</th> -</tr> {% for user in users %} -<tr> - <td><a href="/user/{{user.username}}">{{user.username}}</a> {%if user.admin %}<em>admin</em>{%endif%}</td> - <td> - {% match user.email %} - {% when Some with (e) %} - {% if user.show_email %} - <a href="mailto:{{e}}">{{e}}</a></td> - {% endif %} - {% when None %} - {%endmatch%} - <td>{{user.bio}}</td> -</tr> +{% include "user_header.html" %} {% endfor %} </div> </table> diff --git a/templates/single_note.html b/templates/single_note.html @@ -5,12 +5,11 @@ <article class="note"> {% endif %} <div class="note-meta"> - {% if note.note.neighborhood %}<span title="A neighborhood post shared with other servers">🏠</span>{%endif%} - {% if note.note.is_remote %}<span title="A remote post coming from another server">🌎<span>{%endif%} - <a href="/note/{{note.note.id}}">📝{{note.note.id}}</a> + {% if note.note.neighborhood %}<span title="A public post">🌎</span>{%endif%} + <a href="/?note_id={{note.note.id}}">📝{{note.note.id}}</a> <a class="bold" - href="/user/{{note.username}}">@{{note.username}}</a> {{note.note.relative_timestamp()}} + href="/?user_id={{note.user_id}}">@{{note.username}}</a> {{note.note.relative_timestamp()}} {% if global.logged_in %} <a title="reply" href="#" onclick="reply({{note.note.id}}, '{{note.username}}')">↪</a> {% endif %} diff --git a/templates/timeline.html b/templates/timeline.html @@ -1,6 +1,11 @@ {% extends "base.html" %} {% block content %} +{% match user %} +{% when Some with (user) %} +{% include "user_header.html" %} +{% when None %} +{% endmatch %} {% if global.logged_in %} {% include "createnote.html" %} {% endif %} diff --git a/templates/user.html b/templates/user.html @@ -1,29 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -<div> - <div class="padded"> - <b>user:</b> {{ user.username }} (#{{user.id}}) - <br> - <b>bio:</b> {{user.bio}} - {% match user.email %} - {%when Some with (e) %} - {%if user.show_email%} - <br> - <b>email:</b> <a href="mailto:{{e}}">{{e}}</a> - {%endif %} - {% when None %} - {%endmatch%} - {% if global.me.id == user.id%} - <br> - <a href="/user/{{user.username}}/edit">edit</a> - <div> - <form method="post" action="/logout" class="inline"> - <button type="submit" name="submit_param" value="submit_value" - class="link-button">logout</button></form> - </div> - {% endif %} - </div> -{% include "noteslist.html" %} -</div> -{% endblock %} diff --git a/templates/user_header.html b/templates/user_header.html @@ -0,0 +1,25 @@ +<div> + <div class="padded"> + <b>user:</b> {{ user.username }} (#{{user.id}}) + <br> + <b>bio:</b> {{user.bio}} + <br> + {% match user.email %} + {%when Some with (e) %} + {%if user.show_email && global.logged_in %} + <b>email:</b> <a href="mailto:{{e}}">{{e}}</a><br> + {%endif %} + {% when None %} + {%endmatch%} + <b>website:</b> <a href="{{user.website}}">{{user.website}}</a> + {% if global.me.id == user.id%} + <br> + <a href="/user/{{user.username}}/edit">edit</a> + <div> + <form method="post" action="/logout" class="inline"> + <button type="submit" name="submit_param" value="submit_value" + class="link-button">logout</button></form> + </div> + {% endif %} + <br><br> +</div>