gourami

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

commit f0a646327fa21fb7dbb733acd81c5b51f2191894
parent d3b8430ce4eec79b0bb5fa8b17c3b8476fe8a9ad
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sat,  9 May 2020 08:28:23 -0500

Merge branch 'master' into activitypub

Diffstat:
Mdocs/ADMIN_GUIDE.md | 14++++++++++++++
Amigrations/2020-05-08-203656_showemail/down.sql | 2++
Amigrations/2020-05-08-203656_showemail/up.sql | 3+++
Msrc/db/schema.rs | 1+
Msrc/db/user.rs | 1+
Msrc/lib.rs | 54++++++++++++++++++++++++++++++++++++++++++------------
Msrc/routes.rs | 2+-
Mtemplates/base.html | 2+-
Mtemplates/edit_user.html | 21+++++++++++++++++++--
Mtemplates/noteslist.html | 5+++++
Mtemplates/server_info.html | 22++++++++++++++--------
Mtemplates/single_note.html | 7++++---
Mtemplates/user.html | 8++++++++
13 files changed, 115 insertions(+), 27 deletions(-)

diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md @@ -17,6 +17,11 @@ Gourami is built for small deployments -- I have not tested it or designed it fo I'm not big into formal rules or codes of context, but if you feel like that's important for your server, you may want to put it in your server message. +## Customizing Gourami + +You may want to customize parts of Gourami, such as the CSS format or server message. Right now, html templates are compiled into the binary. In retrospect, it might have been a better idea to use a templating engine that is rendered at runtime. If you want to customize the html, you'll have to edit the file and recompile. I may move towards a different templating library at some point. + + ## Securing your server I would recommend following basic Linux syadmin best practices: disable password login, consider a hardened Linux distro, set up a firewall, etc. I'm not a security expert here, I would recommend following guides produced by those who are. @@ -30,3 +35,12 @@ I would recommend following basic Linux syadmin best practices: disable password ## Passwordless local deployment Don't do this on the public internet, it is a bad idea and will only lead to ruin! Seriously, don't do it. + +## Federation + +ActivityPub varies across servers. Some functionality may not work with other AP servers. Examples of things that may break include: + +* HTML tags that aren't supported getting sanitized +* A different key algorithm being used for HTML signatures +* Custom service-specific activitypub features +* AP features supported by their server but not Gourami (Gourami is extremely limited in its interpretation of ActivityPub) diff --git a/migrations/2020-05-08-203656_showemail/down.sql b/migrations/2020-05-08-203656_showemail/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-08-203656_showemail/up.sql b/migrations/2020-05-08-203656_showemail/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here + +alter table users add show_email boolean default false; diff --git a/src/db/schema.rs b/src/db/schema.rs @@ -28,6 +28,7 @@ table! { created_time -> Timestamp, password -> Nullable<Varchar>, admin -> Bool, + show_email -> Bool, remote_url -> Nullable<Varchar>, } } diff --git a/src/db/user.rs b/src/db/user.rs @@ -49,6 +49,7 @@ pub struct User { pub created_time: String, pub password: Option<String>, pub admin: bool, + pub show_email: bool, pub remote_url: Option<String>, } diff --git a/src/lib.rs b/src/lib.rs @@ -107,14 +107,19 @@ struct RegisterTemplate<'a> { #[template(path = "server_info.html")] struct ServerInfoTemplate<'a> { global: Global<'a>, + users: Vec<User>, } +const PAGE_SIZE: i64 = 50; + struct Global<'a> { // variables used on all pages w header title: &'a str, page: &'a str, + page_num: i64, page_title: &'a str, me: User, + has_more: bool, logged_in: bool, unread_notifications: i64, // db query on every page } @@ -154,8 +159,10 @@ impl<'a> Default for Global<'a> { title: "gourami", // todo set with config me: User::default(), page: "", + page_num: 1, page_title: "", logged_in: false, + has_more: false, unread_notifications: 0, } } @@ -191,7 +198,7 @@ fn delete_note(note_id: i32) -> Result<(), Box<dyn std::error::Error>> { struct NewNoteRequest { note_input: String, // has to be String redirect_url: String, - neighborhood: Option<String>, // "on" + neighborhood: Option<String>, // "on" TODO -- add a custom serialization here } async fn handle_new_note_form(u: Option<User>, f: NewNoteRequest) -> Result<impl Reply, Rejection> { @@ -419,7 +426,7 @@ fn do_logout(cook: String) -> impl Reply { #[derive(Deserialize)] struct GetPostsParams { #[serde(default = "default_page")] - page_num: i64, + page: i64, user_id: Option<i32>, } fn default_page() -> i64 { @@ -429,7 +436,7 @@ fn default_page() -> i64 { impl Default for GetPostsParams { fn default() -> Self { GetPostsParams { - page_num: 1, + page: 1, user_id: None, } } @@ -472,6 +479,13 @@ fn get_single_note(note_id: i32) -> Option<Vec<UserNote>> { ) } +fn get_users() -> Result<Vec<User>, diesel::result::Error> { + use db::schema::users::dsl as u; + let conn = &POOL.get().unwrap(); + let users = u::users.load(conn); + users +} + /// We have to do a join here fn get_notes( params: GetPostsParams, @@ -479,12 +493,12 @@ fn get_notes( ) -> Result<Vec<UserNote>, diesel::result::Error> { use db::schema::notes::dsl as n; use db::schema::users::dsl as u; - const PAGE_SIZE: i64 = 250; + // TODO -- add whether this is complete so i can page properly let mut query = n::notes .inner_join(u::users) .order(n::id.desc()) .limit(PAGE_SIZE) - .offset((params.page_num - 1) * PAGE_SIZE) + .offset((params.page - 1) * PAGE_SIZE) .into_boxed(); if let Some(u_id) = params.user_id { query = query.filter(u::id.eq(u_id)); @@ -519,7 +533,8 @@ fn render_notifications(auth_user: Option<User>) -> impl Reply { .inner_join(nv::notification_viewers) .order(n::id.desc()) .filter(nv::user_id.eq(my_id)) - .limit(1000) // arbitrary TODO cleanup / paginate + .limit(100) // NOTE -- if you have 100 unread notifications this will cause issues + // older notifications wont be seen .load::<(Notification, NotificationViewer)>(conn) .unwrap() .into_iter() @@ -550,14 +565,20 @@ fn render_timeline( ) -> impl Reply { // no session -- anonymous // pulls a bunch of data i dont really need - let header = Global::create(auth_user, url_path.as_str()); + let mut header = Global::create(auth_user, url_path.as_str()); + header.page_num = params.page; // TODO -- ignore neighborhood replies let notes = get_notes(params, Some(false)); match notes { - Ok(n) => render_template(&TimelineTemplate { + Ok(n) => { + // NOTE -- breaks when exactly 50 notes + if n.len() == PAGE_SIZE as usize { + header.has_more = true; + } + render_template(&TimelineTemplate { global: header, notes: n, - }), + })}, _ => render_template(&ErrorTemplate { global: header, error_message: "Could not fetch notes", @@ -596,8 +617,10 @@ impl<'a> Default for ErrorTemplate<'a> { } fn server_info_page(auth_user: Option<User>) -> impl Reply { + let users = get_users().unwrap(); render_template(&ServerInfoTemplate { global: Global::create(auth_user, "/server"), + users: users }) } @@ -622,7 +645,8 @@ fn user_page( mut params: GetPostsParams, path: FullPath, ) -> impl Reply { - let header = Global::create(auth_user, path.as_str()); // maybe if i'm clever i can abstract this away + 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 @@ -632,6 +656,10 @@ fn user_page( if let Some(u) = user { params.user_id = Some(u.id); let notes = get_notes(params, None).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 @@ -684,7 +712,9 @@ pub fn post_inbox(message: Value) -> impl Reply { #[derive(Deserialize)] struct EditForm { redirect_url: String, - bio: Option<String>, + bio: String, + show_email: Option<String>, + email: String, } fn edit_user(user: Option<User>, user_name: String, f: EditForm) -> impl Reply { @@ -693,7 +723,7 @@ fn edit_user(user: Option<User>, user_name: String, f: EditForm) -> impl Reply { if u.username == user_name || u.admin { use db::schema::users::dsl::*; diesel::update(users.find(u.id)) - .set(bio.eq(&f.bio.unwrap_or(String::new()))) + .set((bio.eq(&f.bio), email.eq(&f.email), show_email.eq(&f.show_email.is_some()))) .execute(conn) .unwrap(); } diff --git a/src/routes.rs b/src/routes.rs @@ -26,7 +26,7 @@ pub async fn run_server() { let user_page = session_filter() .and(path!("user" / String)) - .and(form()) + .and(query()) .and(path::full()) .map(user_page); diff --git a/templates/base.html b/templates/base.html @@ -27,7 +27,7 @@ {% block content %} {% endblock %} </div> <div class="padded footer"> - Built with <a href="https://git.sr.ht/~alexwennerberg/gourami-social">gourami</a> + Built with <a href="https://github.com/alexwennerberg/gourami">gourami</a> </div> </div> </body> diff --git a/templates/edit_user.html b/templates/edit_user.html @@ -3,8 +3,25 @@ <form method="POST" action="/user/{{user.username}}/edit"> <b>user:</b> {{ user.username }} (#{{user.id}}) <br> -<label for="bio"><b>bio:<b></label> -<input type="text" id="bio" name="bio"> +<label for="bio"><b>bio:</b></label> +<input type="text" id="bio" name="bio" value="{{user.bio}}"> +<br> +<label for="email"><b>email:</b></label> +<input type="text" id="email" name="email" value= +{% match user.email %} + {%when Some with (e) %} + "{{e}}"> + {% when None %} + ""> +{%endmatch%} +<br> +<label for="show_email"><b>show_email?</b></label> +<input type="checkbox" id="show_email" name="show_email" +{% if user.show_email %} +checked> +{% else %} +unchecked> +{% endif %} <input type="hidden" name="redirect_url" value="/user/{{user.username}}"> <br> <button id="post" class="submit-button-style">submit</button> diff --git a/templates/noteslist.html b/templates/noteslist.html @@ -2,4 +2,9 @@ {% for note in notes %} {% include "single_note.html" %} {% endfor %} +{% if global.has_more %} +<div class='text-right'> +<a href="?page={{global.page_num + 1}}">moreβ† </a> +</div> +{% endif %} </div> diff --git a/templates/server_info.html b/templates/server_info.html @@ -1,13 +1,19 @@ {% extends "base.html" %} - {% block content %} <div class="text-block padded"> - Welcome to the alpha instance of Gourami! Expect many changes in the future. Right now, all notes are on a shared timeline visible to all users on this instance. I welcome feedback and feature requests. Please let me know if you encounter any bugs! - <br> - Check out <a href="https://github.com/alexwennerberg/gourami/blob/master/PHILOSOPHY.md">this document</a> where I discuss some of the design principles and goals of Gourami. - <br> - This instance is invite-only. Contact me to get an invite URL if you want to invite someone. Posts are not visible except to users on this instance, unless they are shared with the "neighborhood", the network of users and other servers networked with this server. Here is a list of those servers: (TBD) - <br> - Contact email: <a href="mailto:alex@alexwennerberg.com">alex@alexwennerberg.com</a> or <a href="/user/alex">message me</a> on Gourami. + Welcome to Gourami, an intentionally small, private, minimalist, invite-only social network. See the <a href="https://github.com/alexwennerberg/gourami/blob/master/docs/USER_GUIDE.md">user guide</a> for more info or look at the <a href="https://github.com/alexwennerberg/gourami">project page</a>! 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>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>{{user.bio}}</td> +</tr> +{% endfor %} </div> +</table> {% endblock %} diff --git a/templates/single_note.html b/templates/single_note.html @@ -7,16 +7,17 @@ <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 + <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 %} -<a href="#" onclick="reply({{note.note.id}}, '{{note.username}}')">β†ͺ</a> +<a title="reply" href="#" onclick="reply({{note.note.id}}, '{{note.username}}')">β†ͺ</a> {% endif %} {% if note.note.user_id == global.me.id %} <form method="post" action="/delete_note" class="inline"> <input type="hidden" name="note_id" value="{{note.note.id}}"> <input type="hidden" name="redirect_url" value="{{global.page}}"> - <button type="submit" name="submit_param" value="submit_value" + <button type="submit" title="delete note" name="submit_param" value="submit_value" class="link-button">βœ•</button> </form> {%endif%} diff --git a/templates/user.html b/templates/user.html @@ -6,6 +6,14 @@ <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>