diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 6263118..a2f6b77 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -29,6 +29,7 @@ pub const ME_JS: &str = include_str!("./public/js/me.js"); // html pub const ROOT: &str = include_str!("./public/html/root.html"); pub const MACROS: &str = include_str!("./public/html/macros.html"); +pub const COMPONENTS: &str = include_str!("./public/html/components.html"); pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html"); pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.html"); @@ -41,6 +42,9 @@ pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.html"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.html"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.html"); +pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.html"); +pub const COMMUNITIES_FEED: &str = include_str!("./public/html/communities/feed.html"); +pub const COMMUNITIES_POST: &str = include_str!("./public/html/communities/post.html"); // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -136,6 +140,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"root.html"(crate::assets::ROOT) --config=config); write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config); + write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config); write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc" --config=config); write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) --config=config); @@ -148,6 +153,9 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config); + write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config); + write_template!(html_path->"communities/feed.html"(crate::assets::COMMUNITIES_FEED) --config=config); + write_template!(html_path->"communities/post.html"(crate::assets::COMMUNITIES_POST) --config=config); html_path } @@ -161,9 +169,15 @@ pub(crate) async fn init_dirs(config: &Config) { create_dir_if_not_exists!( &PathBufD::current().extend(&[config.dirs.media.as_str(), "avatars"]) ); + create_dir_if_not_exists!( + &PathBufD::current().extend(&[config.dirs.media.as_str(), "community_avatars"]) + ); create_dir_if_not_exists!( &PathBufD::current().extend(&[config.dirs.media.as_str(), "banners"]) ); + create_dir_if_not_exists!( + &PathBufD::current().extend(&[config.dirs.media.as_str(), "community_banners"]) + ); write_if_track!(images_path->"default-avatar.svg"(DEFAULT_AVATAR) --config=config); write_if_track!(images_path->"default-banner.svg"(DEFAULT_BANNER) --config=config); @@ -191,6 +205,15 @@ pub(crate) async fn initial_context( let mut ctx = Context::new(); ctx.insert("config", &config); ctx.insert("user", &user); + + if let Some(ua) = user { + ctx.insert("is_helper", &ua.permissions.check_helper()); + ctx.insert("is_manager", &ua.permissions.check_manager()); + } else { + ctx.insert("is_helper", &false); + ctx.insert("is_manager", &false); + } + ctx.insert("lang", &lang.data); ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone()); ctx diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index d34aee8..7c45d97 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -4,6 +4,7 @@ version = "1.0.0" [data] "general:link.home" = "Home" "general:link.communities" = "Communities" +"general:action.delete" = "Delete" "dialog:action.okay" = "Ok" "dialog:action.continue" = "Continue" @@ -20,8 +21,18 @@ version = "1.0.0" "auth:link.settings" = "Settings" "auth:label.followers" = "Followers" "auth:label.following" = "Following" -"auth:label.joined_journals" = "Joined Journals" +"auth:label.joined_communities" = "Joined communities" +"auth:label.recent_posts" = "Recent posts" "communities:action.create" = "Create" "communities:label.create_new" = "Create new community" "communities:label.name" = "Name" +"communities:action.join" = "Join" +"communities:action.leave" = "Leave" +"communities:action.configure" = "Configure" +"communities:label.create_post" = "Create post" +"communities:label.content" = "Content" +"communities:label.posts" = "Posts" +"communities:label.create_reply" = "Create reply" +"communities:label.replies" = "Replies" +"communities:action.continue_thread" = "Continue thread" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 4420cff..647a7dd 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -78,11 +78,7 @@ body { overflow-x: hidden; } -main, -article, -nav, -header, -footer { +main { width: 80ch; margin: 0 auto; padding: 0.75rem 1rem; @@ -422,11 +418,16 @@ select { transition: background 0.15s; resize: vertical; width: 100%; + font-family: inherit; /* personality */ background: transparent; color: inherit; } +textarea { + min-height: 5rem; +} + input:focus, textarea:focus, select:focus { @@ -1062,6 +1063,10 @@ details.accordion .inner { } @media screen and (max-width: 900px) { + .flex-collapse { + flex-direction: column !important; + } + .sm\:static { position: static !important; } diff --git a/crates/app/src/public/html/communities/base.html b/crates/app/src/public/html/communities/base.html new file mode 100644 index 0000000..765fd94 --- /dev/null +++ b/crates/app/src/public/html/communities/base.html @@ -0,0 +1,87 @@ +{% import "macros.html" as macros %} {% import "components.html" as components +%} {% extends "root.html" %} {% block head %} +{{ community.context.display_name }} - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+
+
+
+
+
+ {{ + components::community_avatar(id=community.id,size="72px") + }} +
+ +

+ {% if community.context.display_name %} + {{ community.context.display_name }} + {% else %} + {{ community.username }} + {% endif %} +

+ + {{ community.title }} +
+
+ + {% if user %} +
+ {% if not is_owner %} {% if not is_member %} + + {% else %} + + {% endif %} {% else %} + + {{ icon "settings" }} + {{ text "communities:action.configure" }} + + {% endif %} +
+ {% endif %} +
+ +
+
+ {{ community.context.description }} +
+ +
+
+ ID + +
+ +
+ Created + {{ community.created }} +
+
+
+
+ +
{% block content %}{% endblock %}
+
+
+
+{% endblock %} diff --git a/crates/app/src/public/html/communities/feed.html b/crates/app/src/public/html/communities/feed.html new file mode 100644 index 0000000..9175ef5 --- /dev/null +++ b/crates/app/src/public/html/communities/feed.html @@ -0,0 +1,79 @@ +{% import "macros.html" as macros %} {% import "components.html" as components +%} {% extends "communities/base.html" %} {% block content %} +
+ {% if user %} +
+
+ {{ text "communities:label.create_post" }} +
+ +
+
+ + +
+ + +
+
+ {% endif %} + +
+
+ {{ icon "newspaper" }} + {{ text "communities:label.posts" }} +
+ +
+ + {% for post in feed %} + {{ components::post(post=post[0], owner=post[1], secondary=true, show_community=false) }} + {% endfor %} +
+
+
+ + +{% endblock %} diff --git a/crates/app/src/public/html/communities/list.html b/crates/app/src/public/html/communities/list.html index 9b04419..5c7c8e3 100644 --- a/crates/app/src/public/html/communities/list.html +++ b/crates/app/src/public/html/communities/list.html @@ -1,13 +1,18 @@ -{% import "macros.html" as macros %} {% extends "root.html" %} {% block head %} +{% import "macros.html" as macros %} {% import "components.html" as components +%} {% extends "root.html" %} {% block head %} My communities - {{ config.name }} {% endblock %} {% block body %} {{ macros::nav(selected="communities") }}
+ {% if user %}
-
+
{{ text "communities:label.create_new" }}
-
+
+ {% endif %} {% for item in list %} {{ + components::community_listing_card(community=item) }} {% endfor %}
+ + {% endblock %} diff --git a/crates/app/src/public/html/communities/post.html b/crates/app/src/public/html/communities/post.html new file mode 100644 index 0000000..6d782a4 --- /dev/null +++ b/crates/app/src/public/html/communities/post.html @@ -0,0 +1,86 @@ +{% import "macros.html" as macros %} {% import "components.html" as components +%} {% extends "root.html" %} {% block head %} +Post - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav() }} +
+ {% if post.replying_to %} + + {{ icon "arrow-up" }} + {{ text "communities:action.continue_thread" }} + + {% endif %} {{ components::post(post=post, owner=owner, community=community, + show_community=true) }} {% if user %} +
+
+ {{ text "communities:label.create_reply" }} +
+ +
+
+ + +
+ + +
+
+ {% endif %} + +
+
+ {{ icon "newspaper" }} + {{ text "communities:label.replies" }} +
+ +
+ {% for post in replies %} {{ components::post(post=post[0], + owner=post[1], secondary=true, show_community=false) }} {% endfor %} +
+
+
+ + +{% endblock %} diff --git a/crates/app/src/public/html/components.html b/crates/app/src/public/html/components.html new file mode 100644 index 0000000..fbdc98c --- /dev/null +++ b/crates/app/src/public/html/components.html @@ -0,0 +1,125 @@ +{% macro avatar(username, size="24px", selector_type="username") -%} +@{{ username }} +{%- endmacro %} {% macro community_avatar(id, community=false, size="24px") -%} +{% if community %} +{{ community.title }} +{% else %} +{{ id }} +{% endif %} {%- endmacro %} {% macro community_listing_card(community) -%} + + {{ components::community_avatar(id=community.id, community=community, + size="48px") }} +
+

{{ community.context.display_name }}

+ {{ community.member_count }} members +
+
+{%- endmacro %} {% macro username(user) -%} +
+ {% if user.settings.display_name %} {{ user.settings.display_name }} {% else + %} {{ user.username }} {% endif %} +
+{%- endmacro %} {% macro post(post, owner, secondary=false, community=false, +show_community=true) -%} +
+
+ + {{ components::avatar(username=post.owner, size="52px", + selector_type="id") }} + + + +
+ +
+
+ {% if user %} + + + {% endif %} +
+ +
+ + {{ icon "message-circle" }} + {{ post.comment_count }} + + + + {{ icon "external-link" }} + + + {% if user %} {% if (user.id == post.owner) or is_helper %} + + {% endif %} {% endif %} +
+
+
+{%- endmacro %} diff --git a/crates/app/src/public/html/macros.html b/crates/app/src/public/html/macros.html index 66228fe..25b51ca 100644 --- a/crates/app/src/public/html/macros.html +++ b/crates/app/src/public/html/macros.html @@ -1,4 +1,5 @@ -{% macro nav(selected="", show_lhs=true) -%} +{% import "components.html" as components %} {% macro nav(selected="", +show_lhs=true) -%} -{%- endmacro %} {% macro avatar(username, size="24px") -%} -@{{ username }} {%- endmacro %} diff --git a/crates/app/src/public/html/profile/base.html b/crates/app/src/public/html/profile/base.html index 1ceb5d0..502dd31 100644 --- a/crates/app/src/public/html/profile/base.html +++ b/crates/app/src/public/html/profile/base.html @@ -10,7 +10,8 @@ >
- {{ macros::avatar(username=profile.username,size="72px") + {{ + components::avatar(username=profile.username,size="72px") }}
@@ -77,14 +78,21 @@
{{ icon "users-round" }} - {{ text "auth:label.joined_journals" }} + {{ text "auth:label.joined_communities" }}
-
+
-
{% block content %}{% endblock %}
+
{% block content %}{% endblock %}
diff --git a/crates/app/src/public/html/profile/posts.html b/crates/app/src/public/html/profile/posts.html index 43b93eb..d3de40c 100644 --- a/crates/app/src/public/html/profile/posts.html +++ b/crates/app/src/public/html/profile/posts.html @@ -1,2 +1,16 @@ {% import "macros.html" as macros %} {% extends "profile/base.html" %} {% block -content %}{% endblock %} +content %} +
+
+ {{ icon "clock" }} + {{ text "auth:label.recent_posts" }} +
+ +
+ + {% for post in posts %} + {{ components::post(post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} + {% endfor %} +
+
+{% endblock %} diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 1e644ef..e729fbb 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -27,4 +27,25 @@ } }); }); + + self.define("remove_post", async (_, id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/posts/${id}`, { + method: "DELETE", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); })(); diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 40389a4..a0dcb48 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -1,6 +1,12 @@ -use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse}; +use axum::{ + Extension, Json, + body::Body, + extract::{Path, Query}, + response::IntoResponse, +}; use axum_extra::extract::CookieJar; use pathbufd::{PathBufD, pathd}; +use serde::Deserialize; use std::{ fs::{File, exists}, io::Read, @@ -23,15 +29,36 @@ pub fn read_image(path: PathBufD) -> Vec { bytes } +#[derive(Deserialize, PartialEq, Eq)] +pub enum AvatarSelectorType { + #[serde(alias = "username")] + Username, + #[serde(alias = "id")] + Id, +} + +#[derive(Deserialize)] +pub struct AvatarSelectorQuery { + pub selector_type: AvatarSelectorType, +} + /// Get a profile's avatar image /// `/api/v1/auth/profile/{id}/avatar` pub async fn avatar_request( - Path(username): Path, + Path(selector): Path, Extension(data): Extension, + Query(req): Query, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match data.get_user_by_username(&username).await { + let user = match { + if req.selector_type == AvatarSelectorType::Id { + data.get_user_by_id(selector.parse::().unwrap()) + .await + } else { + data.get_user_by_username(&selector).await + } + } { Ok(ua) => ua, Err(_) => { return ( @@ -88,7 +115,7 @@ pub async fn banner_request( }; let path = - PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]); + PathBufD::current().extend(&["banners", &data.0.dirs.media, &format!("{}.avif", &user.id)]); if !exists(&path).unwrap() { return ( @@ -107,7 +134,7 @@ pub async fn banner_request( ) } -static MAXIUMUM_FILE_SIZE: usize = 8388608; +pub static MAXIUMUM_FILE_SIZE: usize = 8388608; /// Upload avatar pub async fn upload_avatar_request( diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index f471fb6..1c87600 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,13 +3,33 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::UpdateUserIsVerified, }; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum::{ + Extension, Json, + extract::Path, + response::{IntoResponse, Redirect}, +}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ auth::{Token, UserSettings}, permissions::FinePermission, }; +pub async fn redirect_from_id( + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + match (&(data.read().await).0) + .get_user_by_id(match id.parse::() { + Ok(id) => id, + Err(_) => return Redirect::to("/"), + }) + .await + { + Ok(u) => Redirect::to(&format!("/user/{}", u.username)), + Err(_) => Redirect::to("/"), + } +} + /// Update the settings of the given user. pub async fn update_profile_settings_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 7be0c5f..2d69770 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -1,15 +1,35 @@ -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum::{ + Extension, Json, + extract::Path, + response::{IntoResponse, Redirect}, +}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ApiReturn, Error, communities::Community}; use crate::{ State, get_user_from_token, routes::api::v1::{ - CreateCommunity, UpdateCommunityContext, UpdateJournalReadAccess, UpdateJournalTitle, - UpdateJournalWriteAccess, + CreateCommunity, UpdateCommunityContext, UpdateCommunityReadAccess, UpdateCommunityTitle, + UpdateCommunityWriteAccess, }, }; +pub async fn redirect_from_id( + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + match (&(data.read().await).0) + .get_community_by_id(match id.parse::() { + Ok(id) => id, + Err(_) => return Redirect::to("/"), + }) + .await + { + Ok(c) => Redirect::to(&format!("/community/{}", c.title)), + Err(_) => Redirect::to("/"), + } +} + pub async fn create_request( jar: CookieJar, Extension(data): Extension, @@ -25,10 +45,10 @@ pub async fn create_request( .create_community(Community::new(req.title, user.id)) .await { - Ok(_) => Json(ApiReturn { + Ok(id) => Json(ApiReturn { ok: true, message: "Community created".to_string(), - payload: (), + payload: Some(id.to_string()), }), Err(e) => return Json(e.into()), } @@ -59,7 +79,7 @@ pub async fn update_title_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { @@ -103,7 +123,7 @@ pub async fn update_read_access_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { @@ -128,7 +148,7 @@ pub async fn update_write_access_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs new file mode 100644 index 0000000..4ebbbac --- /dev/null +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -0,0 +1,214 @@ +use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use pathbufd::{PathBufD, pathd}; +use std::fs::exists; +use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission}; + +use crate::{ + State, + avif::{Image, save_avif_buffer}, + get_user_from_token, + routes::api::v1::auth::images::{MAXIUMUM_FILE_SIZE, read_image}, +}; + +/// Get a community's avatar image +/// `/api/v1/communities/{id}/avatar` +pub async fn avatar_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let community = match data.get_community_by_id(id).await { + Ok(ua) => ua, + Err(_) => { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + ); + } + }; + + let path = PathBufD::current().extend(&[ + "community_avatars", + &data.0.dirs.media, + &format!("{}.avif", &community.id), + ]); + + if !exists(&path).unwrap() { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + ); + } + + ( + [("Content-Type", "image/avif")], + Body::from(read_image(path)), + ) +} + +/// Get a profile's banner image +/// `/api/v1/auth/profile/{id}/banner` +pub async fn banner_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let community = match data.get_community_by_id(id).await { + Ok(ua) => ua, + Err(_) => { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + }; + + let path = PathBufD::current().extend(&[ + "community_banners", + &data.0.dirs.media, + &format!("{}.avif", &community.id), + ]); + + if !exists(&path).unwrap() { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + + ( + [("Content-Type", "image/avif")], + Body::from(read_image(path)), + ) +} + +/// Upload avatar +pub async fn upload_avatar_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + img: Image, +) -> impl IntoResponse { + // get user from token + let data = &(data.read().await).0; + let auth_user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let community = match data.get_community_by_id(id).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + if auth_user.id != community.owner { + if !auth_user + .permissions + .check(FinePermission::MANAGE_COMMUNITIES) + { + return Json(Error::NotAllowed.into()); + } + } + + let path = pathd!( + "{}/community_avatars/{}.avif", + data.0.dirs.media, + &auth_user.id + ); + + // check file size + if img.0.len() > MAXIUMUM_FILE_SIZE { + return Json(Error::DataTooLong("image".to_string()).into()); + } + + // upload image + let mut bytes = Vec::new(); + + for byte in img.0 { + bytes.push(byte); + } + + match save_avif_buffer(&path, bytes) { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Avatar uploaded. It might take a bit to update".to_string(), + payload: (), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} + +/// Upload banner +pub async fn upload_banner_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + img: Image, +) -> impl IntoResponse { + // get user from token + let data = &(data.read().await).0; + let auth_user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let community = match data.get_community_by_id(id).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + if auth_user.id != community.owner { + if !auth_user + .permissions + .check(FinePermission::MANAGE_COMMUNITIES) + { + return Json(Error::NotAllowed.into()); + } + } + + let path = pathd!( + "{}/community_banners/{}.avif", + data.0.dirs.media, + &auth_user.id + ); + + // check file size + if img.0.len() > MAXIUMUM_FILE_SIZE { + return Json(Error::DataTooLong("image".to_string()).into()); + } + + // upload image + let mut bytes = Vec::new(); + + for byte in img.0 { + bytes.push(byte); + } + + match save_avif_buffer(&path, bytes) { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Banner uploaded. It might take a bit to update".to_string(), + payload: (), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} diff --git a/crates/app/src/routes/api/v1/communities/mod.rs b/crates/app/src/routes/api/v1/communities/mod.rs index 2c53b84..763fd1d 100644 --- a/crates/app/src/routes/api/v1/communities/mod.rs +++ b/crates/app/src/routes/api/v1/communities/mod.rs @@ -1,2 +1,3 @@ pub mod communities; +pub mod images; pub mod posts; diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 5121ead..cf9fc29 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -4,13 +4,13 @@ use tetratto_core::model::{ApiReturn, Error, communities::Post}; use crate::{ State, get_user_from_token, - routes::api::v1::{CreateJournalEntry, UpdateJournalEntryContent, UpdateJournalEntryContext}, + routes::api::v1::{CreatePost, UpdatePostContent, UpdatePostContext}, }; pub async fn create_request( jar: CookieJar, Extension(data): Extension, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { @@ -21,16 +21,26 @@ pub async fn create_request( match data .create_post(Post::new( req.content, - req.journal, - req.replying_to, + match req.community.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + if let Some(rt) = req.replying_to { + match rt.parse::() { + Ok(x) => Some(x), + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + } + } else { + None + }, user.id, )) .await { - Ok(_) => Json(ApiReturn { + Ok(id) => Json(ApiReturn { ok: true, message: "Post created".to_string(), - payload: (), + payload: Some(id.to_string()), }), Err(e) => return Json(e.into()), } @@ -61,7 +71,7 @@ pub async fn update_content_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { @@ -83,7 +93,7 @@ pub async fn update_context_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data) { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 51be704..3365b79 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -18,7 +18,11 @@ pub fn routes() -> Router { .route("/reactions", post(reactions::create_request)) .route("/reactions/{id}", get(reactions::get_request)) .route("/reactions/{id}", delete(reactions::delete_request)) - // journal journals + // communities + .route( + "/communities/find/{id}", + get(communities::communities::redirect_from_id), + ) .route( "/communities", post(communities::communities::create_request), @@ -36,13 +40,29 @@ pub fn routes() -> Router { post(communities::communities::update_context_request), ) .route( - "/journals/{id}/access/read", + "/communities/{id}/access/read", post(communities::communities::update_read_access_request), ) .route( - "/journals/{id}/access/write", + "/communities/{id}/access/write", post(communities::communities::update_write_access_request), ) + .route( + "/communities/{id}/upload/avatar", + post(communities::images::upload_avatar_request), + ) + .route( + "/communities/{id}/upload/banner", + post(communities::images::upload_banner_request), + ) + .route( + "/communities/{id}/avatar", + get(communities::images::avatar_request), + ) + .route( + "/communities/{id}/banner", + get(communities::images::banner_request), + ) // posts .route("/posts", post(communities::posts::create_request)) .route("/posts/{id}", delete(communities::posts::delete_request)) @@ -96,6 +116,10 @@ pub fn routes() -> Router { "/auth/profile/{id}/verified", post(auth::profile::update_profile_is_verified_request), ) + .route( + "/auth/profile/find/{id}", + get(auth::profile::redirect_from_id), + ) } #[derive(Deserialize)] @@ -110,7 +134,7 @@ pub struct CreateCommunity { } #[derive(Deserialize)] -pub struct UpdateJournalTitle { +pub struct UpdateCommunityTitle { pub title: String, } @@ -120,30 +144,30 @@ pub struct UpdateCommunityContext { } #[derive(Deserialize)] -pub struct UpdateJournalReadAccess { +pub struct UpdateCommunityReadAccess { pub access: CommunityReadAccess, } #[derive(Deserialize)] -pub struct UpdateJournalWriteAccess { +pub struct UpdateCommunityWriteAccess { pub access: CommunityWriteAccess, } #[derive(Deserialize)] -pub struct CreateJournalEntry { +pub struct CreatePost { pub content: String, - pub journal: usize, + pub community: String, #[serde(default)] - pub replying_to: Option, + pub replying_to: Option, } #[derive(Deserialize)] -pub struct UpdateJournalEntryContent { +pub struct UpdatePostContent { pub content: String, } #[derive(Deserialize)] -pub struct UpdateJournalEntryContext { +pub struct UpdatePostContext { pub context: PostContext, } diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 0bc008b..fac4131 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -1,11 +1,38 @@ -use super::render_error; +use super::{PaginatedQuery, render_error}; use crate::{State, assets::initial_context, get_lang, get_user_from_token}; use axum::{ Extension, + extract::{Path, Query}, response::{Html, IntoResponse}, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::Error; +use tera::Context; +use tetratto_core::model::{ + Error, + auth::User, + communities::{Community, CommunityReadAccess}, +}; + +macro_rules! check_permissions { + ($community:ident, $jar:ident, $data:ident, $user:ident) => { + match $community.read_access { + CommunityReadAccess::Private => { + if let Some(ref ua) = $user { + if ua.id != $community.owner { + return Err(Html( + render_error(Error::NotAllowed, &$jar, &$data, &$user).await, + )); + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &$jar, &$data, &$user).await, + )); + } + } + _ => (), + }; + }; +} /// `/communities` pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { @@ -19,14 +46,22 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } }; - let posts = match data.0.get_memberships_by_owner(user.id).await { + let list = match data.0.get_memberships_by_owner(user.id).await { Ok(p) => p, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; + let mut communities: Vec = Vec::new(); + for membership in &list { + match data.0.get_community_by_id(membership.community).await { + Ok(c) => communities.push(c), + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; - context.insert("posts", &posts); + context.insert("list", &communities); // return Ok(Html( @@ -35,3 +70,146 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> .unwrap(), )) } + +pub fn community_context( + context: &mut Context, + community: &Community, + is_owner: bool, + is_joined: bool, +) { + context.insert("community", &community); + context.insert("is_owner", &is_owner); + context.insert("is_joined", &is_joined); +} + +/// `/community/{title}` +pub async fn feed_request( + jar: CookieJar, + Path(title): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let community = match data.0.get_community_by_title(&title).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // check permissions + check_permissions!(community, jar, data, user); + + // ... + let feed = match data + .0 + .get_posts_by_community(community.id, 12, props.page) + .await + { + Ok(p) => match data.0.fill_posts(p).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + let is_owner = if let Some(ref ua) = user { + ua.id == community.owner + } else { + false + }; + + let is_joined = if let Some(ref ua) = user { + data.0 + .get_membership_by_owner_community(ua.id, community.id) + .await + .is_ok() + } else { + false + }; + + context.insert("feed", &feed); + community_context(&mut context, &community, is_owner, is_joined); + + // return + Ok(Html( + data.1 + .render("communities/feed.html", &mut context) + .unwrap(), + )) +} + +/// `/post/{id}` +pub async fn post_request( + jar: CookieJar, + Path(id): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let post = match data.0.get_post_by_id(id).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let community = match data.0.get_community_by_id(post.community).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // check permissions + check_permissions!(community, jar, data, user); + + // ... + let feed = match data.0.get_post_comments(post.id, 12, props.page).await { + Ok(p) => match data.0.fill_posts(p).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &user).await; + + let is_owner = if let Some(ref ua) = user { + ua.id == community.owner + } else { + false + }; + + let is_joined = if let Some(ref ua) = user { + data.0 + .get_membership_by_owner_community(ua.id, community.id) + .await + .is_ok() + } else { + false + }; + + context.insert("post", &post); + context.insert("replies", &feed); + context.insert( + "owner", + &data + .0 + .get_user_by_id(post.owner) + .await + .unwrap_or(User::deleted()), + ); + community_context(&mut context, &community, is_owner, is_joined); + + // return + Ok(Html( + data.1 + .render("communities/post.html", &mut context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 53996b8..c63e62b 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -24,6 +24,8 @@ pub fn routes() -> Router { .route("/user/{username}", get(profile::posts_request)) // communities .route("/communities", get(communities::list_request)) + .route("/community/{title}", get(communities::feed_request)) + .route("/post/{id}", get(communities::post_request)) } pub async fn render_error( diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index e54e6a7..9713f79 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -7,10 +7,17 @@ use axum::{ }; use axum_extra::extract::CookieJar; use tera::Context; -use tetratto_core::model::{Error, auth::User}; +use tetratto_core::model::{Error, auth::User, communities::Community}; -pub fn profile_context(context: &mut Context, profile: &User, is_self: bool, is_following: bool) { +pub fn profile_context( + context: &mut Context, + profile: &User, + communities: &Vec, + is_self: bool, + is_following: bool, +) { context.insert("profile", &profile); + context.insert("communities", &communities); context.insert("is_self", &is_self); context.insert("is_following", &is_following); } @@ -30,15 +37,6 @@ pub async fn posts_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; - let posts = match data - .0 - .get_posts_by_user(other_user.id, 12, props.page) - .await - { - Ok(p) => p, - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - }; - // check if we're blocked if let Some(ref ua) = user { if data @@ -53,6 +51,27 @@ pub async fn posts_request( } } + // fetch data + let posts = match data + .0 + .get_posts_by_user(other_user.id, 12, props.page) + .await + { + Ok(p) => match data.0.fill_posts_with_community(p).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let communities = match data.0.get_memberships_by_owner(other_user.id).await { + Ok(m) => match data.0.fill_communities(m).await { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + // init context let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &user).await; @@ -73,7 +92,13 @@ pub async fn posts_request( }; context.insert("posts", &posts); - profile_context(&mut context, &other_user, is_self, is_following); + profile_context( + &mut context, + &other_user, + &communities, + is_self, + is_following, + ); // return Ok(Html( diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index bd17247..7da7a47 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -21,8 +21,8 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> User { User { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, username: get!(x->2(String)), password: get!(x->3(String)), salt: get!(x->4(String)), @@ -31,9 +31,9 @@ impl DataManager { permissions: FinePermission::from_bits(get!(x->7(u32))).unwrap(), is_verified: if get!(x->8(i8)) == 1 { true } else { false }, // counts - notification_count: get!(x->9(i64)) as usize, - follower_count: get!(x->10(i64)) as usize, - following_count: get!(x->11(i64)) as usize, + notification_count: get!(x->9(isize)) as usize, + follower_count: get!(x->10(isize)) as usize, + following_count: get!(x->11(isize)) as usize, } } @@ -116,7 +116,7 @@ impl DataManager { Ok(()) } - /// Create a new user in the database. + /// Delete an existing user in the database. /// /// # Arguments /// * `id` - the ID of the user diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 0dcd6d8..703a53a 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -408,4 +408,131 @@ macro_rules! auto_method { Ok(()) } }; + + ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + pub async fn $name(&self, id: usize, user: User) -> Result<()> { + let y = self.$select_fn(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::$permission) { + return Err(Error::NotAllowed); + } + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.$cache_key_tmpl(&y).await; + + Ok(()) + } + }; + + ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> { + let y = self.$select_fn(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::$permission) { + return Err(Error::NotAllowed); + } + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&x, &id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.$cache_key_tmpl(&y).await; + + Ok(()) + } + }; + + ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { + pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> { + let y = self.$select_fn(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::$permission) { + return Err(Error::NotAllowed); + } + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + $query, + &[&serde_json::to_string(&x).unwrap(), &id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.$cache_key_tmpl(&y).await; + + Ok(()) + } + }; + + ($name:ident()@$select_fn:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident --incr) => { + pub async fn $name(&self, id: usize) -> Result<()> { + let y = self.$select_fn(id).await?; + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.$cache_key_tmpl(&y).await; + + Ok(()) + } + }; + + ($name:ident()@$select_fn:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident --decr) => { + pub async fn $name(&self, id: usize) -> Result<()> { + let y = self.$select_fn(id).await?; + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.$cache_key_tmpl(&y).await; + + Ok(()) + } + }; } diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index efac9a0..7997acb 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -24,26 +24,29 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> Community { Community { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, title: get!(x->2(String)), context: serde_json::from_str(&get!(x->3(String))).unwrap(), - owner: get!(x->4(i64)) as usize, + owner: get!(x->4(isize)) as usize, read_access: serde_json::from_str(&get!(x->5(String))).unwrap(), write_access: serde_json::from_str(&get!(x->6(String))).unwrap(), // likes - likes: get!(x->6(i64)) as isize, - dislikes: get!(x->7(i64)) as isize, + likes: get!(x->7(isize)) as isize, + dislikes: get!(x->8(isize)) as isize, + // counts + member_count: get!(x->9(isize)) as usize, } } auto_method!(get_community_by_id()@get_community_from_row -> "SELECT * FROM communities WHERE id = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}"); + auto_method!(get_community_by_title(&str)@get_community_from_row -> "SELECT * FROM communities WHERE title = $1" --name="community" --returns=Community --cache-key-tmpl="atto.community:{}"); /// Create a new community in the database. /// /// # Arguments /// * `data` - a mock [`Community`] to insert - pub async fn create_community(&self, data: Community) -> Result<()> { + pub async fn create_community(&self, data: Community) -> Result { // check values if data.title.len() < 2 { return Err(Error::DataTooShort("title".to_string())); @@ -51,6 +54,17 @@ impl DataManager { return Err(Error::DataTooLong("title".to_string())); } + if !data.title.is_ascii() | data.title.contains(" ") { + return Err(Error::MiscError( + "Title contains characters which aren't allowed".to_string(), + )); + } + + // make sure community doesn't already exist with title + if self.get_community_by_title(&data.title).await.is_ok() { + return Err(Error::MiscError("Title already in use".to_string())); + } + // ... let conn = match self.connect().await { Ok(c) => c, @@ -59,7 +73,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", &[ &data.id.to_string().as_str(), &data.created.to_string().as_str(), @@ -68,6 +82,9 @@ impl DataManager { &data.owner.to_string().as_str(), &serde_json::to_string(&data.read_access).unwrap().as_str(), &serde_json::to_string(&data.write_access).unwrap().as_str(), + &0.to_string().as_str(), + &0.to_string().as_str(), + &0.to_string().as_str() ] ); @@ -85,17 +102,29 @@ impl DataManager { .unwrap(); // return - Ok(()) + Ok(data.title) } - auto_method!(delete_community()@get_community_by_id:MANAGE_COMMUNITY_PAGES -> "DELETE communities pages WHERE id = $1" --cache-key-tmpl="atto.community:{}"); - auto_method!(update_community_title(String)@get_community_by_id:MANAGE_COMMUNITY_PAGES -> "UPDATE communities SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.community:{}"); - auto_method!(update_community_context(CommunityContext)@get_community_by_id:MANAGE_COMMUNITY_PAGES -> "UPDATE communities SET prompt = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.community:{}"); - auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id:MANAGE_COMMUNITY_PAGES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.community:{}"); - auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id:MANAGE_COMMUNITY_PAGES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.community:{}"); + pub async fn cache_clear_community(&self, community: &Community) { + self.2 + .remove(format!("atto.community:{}", community.id)) + .await; + self.2 + .remove(format!("atto.community:{}", community.title)) + .await; + } - auto_method!(incr_community_likes() -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl="atto.community:{}" --incr); - auto_method!(incr_community_dislikes() -> "UPDATE communities SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.community:{}" --incr); - auto_method!(decr_community_likes() -> "UPDATE communities SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl="atto.community:{}" --decr); - auto_method!(decr_community_dislikes() -> "UPDATE communities SET likes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.community:{}" --decr); + auto_method!(delete_community()@get_community_by_id:MANAGE_COMMUNITIES -> "DELETE communities pages WHERE id = $1" --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_title(String)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_context(CommunityContext)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET prompt = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + + auto_method!(incr_community_likes()@get_community_by_id -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); + auto_method!(incr_community_dislikes()@get_community_by_id -> "UPDATE communities SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); + auto_method!(decr_community_likes()@get_community_by_id -> "UPDATE communities SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr); + auto_method!(decr_community_dislikes()@get_community_by_id -> "UPDATE communities SET likes = dislikes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr); + + auto_method!(incr_community_member_count()@get_community_by_id -> "UPDATE communities SET member_count = member_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); + auto_method!(decr_community_member_count()@get_community_by_id -> "UPDATE communities SET member_count = member_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr); } diff --git a/crates/core/src/database/drivers/sql/create_communities.sql b/crates/core/src/database/drivers/sql/create_communities.sql index 6a5acbd..b98635b 100644 --- a/crates/core/src/database/drivers/sql/create_communities.sql +++ b/crates/core/src/database/drivers/sql/create_communities.sql @@ -8,5 +8,7 @@ CREATE TABLE IF NOT EXISTS communities ( write_access TEXT NOT NULL, -- likes likes INTEGER NOT NULL, - dislikes INTEGER NOT NULL + dislikes INTEGER NOT NULL, + -- counts + member_count INTEGER NOT NULL ) diff --git a/crates/core/src/database/ipbans.rs b/crates/core/src/database/ipbans.rs index c6df8ed..8925edd 100644 --- a/crates/core/src/database/ipbans.rs +++ b/crates/core/src/database/ipbans.rs @@ -17,9 +17,9 @@ impl DataManager { ) -> IpBan { IpBan { ip: get!(x->0(String)), - created: get!(x->1(i64)) as usize, + created: get!(x->1(isize)) as usize, reason: get!(x->2(String)), - moderator: get!(x->3(i64)) as usize, + moderator: get!(x->3(isize)) as usize, } } diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 93bee23..45b95ce 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -1,5 +1,6 @@ use super::*; use crate::cache::Cache; +use crate::model::communities::Community; use crate::model::{ Error, Result, auth::User, communities::CommunityMembership, communities_permissions::CommunityPermission, permissions::FinePermission, @@ -19,16 +20,25 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> CommunityMembership { CommunityMembership { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - owner: get!(x->2(i64)) as usize, - community: get!(x->3(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, + owner: get!(x->2(isize)) as usize, + community: get!(x->3(isize)) as usize, role: CommunityPermission::from_bits(get!(x->4(u32))).unwrap(), } } auto_method!(get_membership_by_id()@get_membership_from_row -> "SELECT * FROM memberships WHERE id = $1" --name="journal membership" --returns=CommunityMembership --cache-key-tmpl="atto.membership:{}"); + /// Replace a list of community memberships with the proper community. + pub async fn fill_communities(&self, list: Vec) -> Result> { + let mut communities: Vec = Vec::new(); + for membership in &list { + communities.push(self.get_community_by_id(membership.community).await?); + } + Ok(communities) + } + /// Get a community membership by `owner` and `community`. pub async fn get_membership_by_owner_community( &self, @@ -87,7 +97,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO memberships VALUES ($1, $2, $3, $4, $5", + "INSERT INTO memberships VALUES ($1, $2, $3, $4, $5)", &[ &data.id.to_string().as_str(), &data.created.to_string().as_str(), @@ -101,6 +111,10 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + self.incr_community_member_count(data.community) + .await + .unwrap(); + Ok(()) } @@ -139,6 +153,8 @@ impl DataManager { self.2.remove(format!("atto.membership:{}", id)).await; + self.decr_community_member_count(y.community).await.unwrap(); + Ok(()) } diff --git a/crates/core/src/database/notifications.rs b/crates/core/src/database/notifications.rs index 3be42b2..117af17 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -16,11 +16,11 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> Notification { Notification { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, title: get!(x->2(String)), content: get!(x->3(String)), - owner: get!(x->4(i64)) as usize, + owner: get!(x->4(isize)) as usize, } } diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index c701411..02956e8 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1,10 +1,9 @@ use super::*; use crate::cache::Cache; -use crate::model::communities::PostContext; use crate::model::{ Error, Result, auth::User, - communities::{CommunityWriteAccess, Post}, + communities::{Community, CommunityWriteAccess, Post, PostContext}, permissions::FinePermission, }; use crate::{auto_method, execute, get, query_row, query_rows}; @@ -22,11 +21,11 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> Post { Post { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, content: get!(x->2(String)), - owner: get!(x->3(i64)) as usize, - community: get!(x->4(i64)) as usize, + owner: get!(x->3(isize)) as usize, + community: get!(x->4(isize)) as usize, context: serde_json::from_str(&get!(x->5(String))).unwrap(), replying_to: if let Some(id) = get!(x->6(Option)) { Some(id as usize) @@ -34,10 +33,10 @@ impl DataManager { None }, // likes - likes: get!(x->7(i64)) as isize, - dislikes: get!(x->8(i64)) as isize, + likes: get!(x->7(isize)) as isize, + dislikes: get!(x->8(isize)) as isize, // other counts - comment_count: get!(x->9(i64)) as usize, + comment_count: get!(x->9(isize)) as usize, } } @@ -49,17 +48,22 @@ impl DataManager { /// * `id` - the ID of the post the requested posts are commenting on /// * `batch` - the limit of posts in each page /// * `page` - the page number - pub async fn get_post_comments(&self, id: usize, batch: usize, page: usize) -> Result { + pub async fn get_post_comments( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { let conn = match self.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let res = query_row!( + let res = query_rows!( &conn, "SELECT * FROM posts WHERE replying_to = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", &[&(id as i64), &(batch as i64), &((page * batch) as i64)], - |x| { Ok(Self::get_post_from_row(x)) } + |x| { Self::get_post_from_row(x) } ); if res.is_err() { @@ -69,6 +73,38 @@ impl DataManager { Ok(res.unwrap()) } + /// Complete a vector of just posts with their owner as well. + pub async fn fill_posts(&self, posts: Vec) -> Result> { + let mut out: Vec<(Post, User)> = Vec::new(); + + for post in posts { + let owner = post.owner.clone(); + out.push((post, self.get_user_by_id(owner).await?)); + } + + Ok(out) + } + + /// Complete a vector of just posts with their owner and community as well. + pub async fn fill_posts_with_community( + &self, + posts: Vec, + ) -> Result> { + let mut out: Vec<(Post, User, Community)> = Vec::new(); + + for post in posts { + let owner = post.owner.clone(); + let community = post.community.clone(); + out.push(( + post, + self.get_user_by_id(owner).await?, + self.get_community_by_id(community).await?, + )); + } + + Ok(out) + } + /// Get all posts from the given user (from most recent). /// /// # Arguments @@ -100,11 +136,42 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts from the given community (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the community the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_by_community( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE community = $1 AND replying_to IS NULL ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new journal entry in the database. /// /// # Arguments /// * `data` - a mock [`JournalEntry`] object to insert - pub async fn create_post(&self, data: Post) -> Result<()> { + pub async fn create_post(&self, data: Post) -> Result { // check values if data.content.len() < 2 { return Err(Error::DataTooShort("content".to_string())); @@ -112,21 +179,21 @@ impl DataManager { return Err(Error::DataTooLong("username".to_string())); } - // check permission in page - let page = match self.get_community_by_id(data.community).await { + // check permission in community + let community = match self.get_community_by_id(data.community).await { Ok(p) => p, Err(e) => return Err(e), }; - match page.write_access { + match community.write_access { CommunityWriteAccess::Owner => { - if data.owner != page.owner { + if data.owner != community.owner { return Err(Error::NotAllowed); } } CommunityWriteAccess::Joined => { if let Err(_) = self - .get_membership_by_owner_community(data.owner, page.id) + .get_membership_by_owner_community(data.owner, community.id) .await { return Err(Error::NotAllowed); @@ -135,6 +202,16 @@ impl DataManager { _ => (), }; + // check if we're blocked + if let Some(replying_to) = data.replying_to { + if let Ok(_) = self + .get_userblock_by_initiator_receiver(replying_to, data.owner) + .await + { + return Err(Error::NotAllowed); + } + } + // ... let conn = match self.connect().await { Ok(c) => c, @@ -143,7 +220,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", &[ &Some(data.id.to_string()), &Some(data.created.to_string()), @@ -171,13 +248,43 @@ impl DataManager { self.incr_post_comments(id).await.unwrap(); } + // return + Ok(data.id) + } + + pub async fn delete_post(&self, id: usize, user: User) -> Result<()> { + let y = self.get_post_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } + } + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM posts WHERE id = $1", &[&id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.post:{}", id)).await; + + // decr parent comment count + if let Some(replying_to) = y.replying_to { + self.decr_post_comments(replying_to).await.unwrap(); + } + // return Ok(()) } - auto_method!(delete_post()@get_post_by_id:MANAGE_COMMUNITY_ENTRIES -> "DELETE FROM posts WHERE id = $1" --cache-key-tmpl="atto.post:{}"); - auto_method!(update_post_content(String)@get_post_by_id:MANAGE_COMMUNITY_ENTRIES -> "UPDATE posts SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.post:{}"); - auto_method!(update_post_context(PostContext)@get_post_by_id:MANAGE_COMMUNITY_ENTRIES -> "UPDATE posts SET context = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.post:{}"); + auto_method!(update_post_content(String)@get_post_by_id:MANAGE_POSTS -> "UPDATE posts SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.post:{}"); + auto_method!(update_post_context(PostContext)@get_post_by_id:MANAGE_POSTS -> "UPDATE posts SET context = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.post:{}"); auto_method!(incr_post_likes() -> "UPDATE posts SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); auto_method!(incr_post_dislikes() -> "UPDATE posts SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index ee1ef04..d59cd76 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -21,10 +21,10 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> Reaction { Reaction { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - owner: get!(x->2(i64)) as usize, - asset: get!(x->3(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, + owner: get!(x->2(isize)) as usize, + asset: get!(x->3(isize)) as usize, asset_type: serde_json::from_str(&get!(x->4(String))).unwrap(), is_like: if get!(x->5(i8)) == 1 { true } else { false }, } diff --git a/crates/core/src/database/userblocks.rs b/crates/core/src/database/userblocks.rs index 2bc7ef6..ab4ef46 100644 --- a/crates/core/src/database/userblocks.rs +++ b/crates/core/src/database/userblocks.rs @@ -16,10 +16,10 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> UserBlock { UserBlock { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - initiator: get!(x->2(i64)) as usize, - receiver: get!(x->3(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, + initiator: get!(x->2(isize)) as usize, + receiver: get!(x->3(isize)) as usize, } } diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 0e4528c..3cbec49 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -16,10 +16,10 @@ impl DataManager { #[cfg(feature = "postgres")] x: &Row, ) -> UserFollow { UserFollow { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - initiator: get!(x->2(i64)) as usize, - receiver: get!(x->3(i64)) as usize, + id: get!(x->0(isize)) as usize, + created: get!(x->1(isize)) as usize, + initiator: get!(x->2(isize)) as usize, + receiver: get!(x->3(isize)) as usize, } } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 86d21f4..91b5aef 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -45,6 +45,12 @@ impl Default for UserSettings { } } +impl Default for User { + fn default() -> Self { + Self::new("".to_string(), String::new()) + } +} + impl User { /// Create a new [`User`]. pub fn new(username: String, password: String) -> Self { @@ -70,6 +76,15 @@ impl User { } } + /// Deleted user profile. + pub fn deleted() -> Self { + Self { + username: "".to_string(), + id: 0, + ..Default::default() + } + } + /// Create a new token /// /// # Returns diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index df1eb4d..5147a05 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -18,8 +18,11 @@ pub struct Community { /// The owner of the community page (and moderators) are the ***only*** people /// capable of removing posts. pub write_access: CommunityWriteAccess, + // likes pub likes: isize, pub dislikes: isize, + // counts + pub member_count: usize, } impl Community { @@ -31,25 +34,31 @@ impl Community { .parse::() .unwrap(), created: unix_epoch_timestamp() as usize, - title, - context: CommunityContext::default(), + title: title.clone(), + context: CommunityContext { + display_name: title, + ..Default::default() + }, owner, read_access: CommunityReadAccess::default(), write_access: CommunityWriteAccess::default(), likes: 0, dislikes: 0, + member_count: 0, } } } #[derive(Serialize, Deserialize)] pub struct CommunityContext { + pub display_name: String, pub description: String, } impl Default for CommunityContext { fn default() -> Self { Self { + display_name: String::new(), description: String::new(), } } diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index a26d31b..e5a0ee1 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -10,8 +10,8 @@ bitflags! { pub struct FinePermission: u32 { const DEFAULT = 1 << 0; const ADMINISTRATOR = 1 << 1; - const MANAGE_COMMUNITY_PAGES = 1 << 2; - const MANAGE_COMMUNITY_ENTRIES = 1 << 3; + const MANAGE_COMMUNITIES = 1 << 2; + const MANAGE_POSTS = 1 << 3; const MANAGE_POST_REPLIES = 1 << 4; const MANAGE_USERS = 1 << 5; const MANAGE_BANS = 1 << 6; // includes managing IP bans @@ -106,8 +106,8 @@ impl FinePermission { /// Check if the given [`FinePermission`] qualifies as "Helper" status. pub fn check_helper(self) -> bool { - self.check(FinePermission::MANAGE_COMMUNITY_ENTRIES) - && self.check(FinePermission::MANAGE_COMMUNITY_PAGES) + self.check(FinePermission::MANAGE_COMMUNITIES) + && self.check(FinePermission::MANAGE_POSTS) && self.check(FinePermission::MANAGE_POST_REPLIES) && self.check(FinePermission::MANAGE_WARNINGS) && self.check(FinePermission::VIEW_REPORTS)