gourami

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

commit 0573c3c9d91edd20b549e1b28d8edebcc96f3667
parent 80158ade03582ed485e7122ea6b6c54150140b86
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Fri,  5 Jun 2020 13:02:34 -0500

Begin public restructuring

Continuing the migration towards a single endpoint w/ query params

Allow neighborhood public, remove public servers

Diffstat:
MCargo.lock | 2+-
MREADME.md | 2+-
Msrc/db/user.rs | 3++-
Msrc/lib.rs | 114++++++++++++++-----------------------------------------------------------------
Msrc/routes.rs | 31+++++++++----------------------
Mtemplates/base.html | 3++-
Mtemplates/createnote.html | 2+-
Dtemplates/note.html | 8--------
Mtemplates/single_note.html | 4++--
Mtemplates/timeline.html | 5+++++
Mtemplates/user.html | 27+--------------------------
Atemplates/user_header.html | 23+++++++++++++++++++++++
12 files changed, 67 insertions(+), 157 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.3" dependencies = [ "activitystreams", "ammonia", 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/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)] 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,13 @@ 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 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 +433,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 +449,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 +470,6 @@ 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"; - } header.page_num = params.page; // TODO -- ignore neighborhood replies match notes { @@ -499,6 +481,8 @@ fn render_timeline( render_template(&TimelineTemplate { global: header, notes: n, + user: None, // TODO + params: params }) } _ => render_template(&ErrorTemplate { @@ -528,56 +512,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 +531,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( 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/templates/base.html b/templates/base.html @@ -23,7 +23,8 @@ <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 %} </div> 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">Share with neighborhood (public)?</label> <input type="hidden" name="redirect_url" value="{{global.page}}"> </form> </div> 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/single_note.html b/templates/single_note.html @@ -7,10 +7,10 @@ <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> + <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 +1,4 @@ {% 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" %} +% include "noteslist.html" %} </div> {% endblock %} diff --git a/templates/user_header.html b/templates/user_header.html @@ -0,0 +1,23 @@ +<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>