diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 45f40f1..877161c 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -113,7 +113,7 @@ pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.lisp") pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp"); pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp"); -pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.lisp"); +pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp"); pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp"); pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp"); @@ -401,7 +401,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config --lisp plugins); write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins); - write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config --lisp plugins); + write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins); write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins); write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index d32ef2e..270feec 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -204,6 +204,8 @@ version = "1.0.0" "stacks:tab.users" = "Users" "stacks:label.add_user" = "Add user" "stacks:label.remove" = "Remove" +"stacks:label.block_all" = "Block all" +"stacks:label.unblock_all" = "Unblock all" "forge:label.my_forges" = "My forges" "forge:label.create_new" = "Create new forge" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index a990ee1..6377581 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -203,11 +203,16 @@ macro_rules! check_user_blocked_or_private { // check if we're blocked if let Some(ref ua) = $user { - if $data + if ($data .0 .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() + | $data + .0 + .get_user_stack_blocked_users($other_user.id) + .await + .contains(&ua.id)) && !ua.permissions.check(FinePermission::MANAGE_USERS) { let lang = get_lang!($jar, $data.0); @@ -291,10 +296,14 @@ macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, @api) => { // check if we're blocked if let Some(ref ua) = $user { - if $data + if ($data .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() + | $data + .get_user_stack_blocked_users($other_user.id) + .await + .contains(&ua.id)) && !ua .permissions .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) @@ -338,6 +347,7 @@ macro_rules! ignore_users_gen { [ $data.0.get_userblocks_receivers(ua.id).await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, + $data.0.get_user_stack_blocked_users(ua.id).await, ] .concat() } else { @@ -345,16 +355,17 @@ macro_rules! ignore_users_gen { } }; - ($user:ident!, $data:ident) => { + ($user:ident!, $data:ident) => {{ [ $data.0.get_userblocks_receivers($user.id).await, $data .0 .get_userblocks_initiator_by_receivers($user.id) .await, + $data.0.get_user_stack_blocked_users($user.id).await, ] .concat() - }; + }}; ($user:ident!, #$data:ident) => { [ diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 154f64c..fef2659 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -56,7 +56,8 @@ animation: popin ease-in-out 1 0.15s forwards running; } -.lightbox a { +.lightbox a, +.lightbox img { --padding: 2rem; cursor: zoom-in; max-height: calc(100dvh - var(--padding)); @@ -684,6 +685,9 @@ nav .button:not(.title):not(.active):hover { gap: var(--pad-2); align-items: center; justify-content: space-between; + position: sticky; + top: 0; + z-index: 1; } /* mobile nav chip nav */ diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 8a4c953..239d376 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1817,3 +1817,20 @@ ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}")))) (text "{%- endmacro %}") + +(text "{% macro stack_listing(stack) -%}") +(a + ("href" "/stacks/{{ stack.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"list\" }}") + (b + (text "{{ stack.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ stack.created }}")) + (text "; {{ stack.privacy }}; {{ stack.users|length }} users"))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index ffc68b8..3a9543f 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -101,15 +101,15 @@ (li (a ("href" "https://trisua.com/t/tetratto") (text "Source code"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/index.html") (text "Source code reference"))) + (a ("href" "https://tetratto.com/reference/tetratto/index.html") ("data-turbo" "false") (text "Source code reference"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") (text "API response structure"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") ("data-turbo" "false") (text "API response structure"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") (text "App scopes"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") ("data-turbo" "false") (text "App scopes"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") (text "User permissions"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") ("data-turbo" "false") (text "User permissions"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") (text "Community member permissions"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") ("data-turbo" "false") (text "Community member permissions"))) (li (a ("href" "https://tetratto.com/forge/tetratto") (text "Report issues"))))))) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8514e34..d317d95 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -388,6 +388,22 @@ (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) + + ; stack blocks + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"layers\" }}") + (span + (text "{{ text \"stacks:link.stacks\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for stack in stackblocks %}") + (text "{{ components::stack_listing(stack=stack) }}") + (text "{% endfor %}"))) + + ; user blocks (div ("class" "card-nest") (div diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp new file mode 100644 index 0000000..6107546 --- /dev/null +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -0,0 +1,85 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ stack.name }} - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{{ macros::timelines_nav(selected=\"stacks\") }}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"list\" }}") + (span + (text "{{ stack.name }}"))) + (text "{% if user and user.id == stack.owner -%}") + (a + ("href" "/stacks/{{ stack.id }}/manage") + ("class" "button lowered small") + (text "{{ icon \"pencil\" }}") + (span + (text "{{ text \"general:action.manage\" }}"))) + (text "{%- endif %}")) + (div + ("class" "card w-full flex flex-col gap-2") + (text "{% if list|length == 0 -%}") + (p + (text "No items yet! Maybe ") + (a + ("href" "/stacks/{{ stack.id }}/manage#/users") + (text "add a user to this stack")) + (text "!")) + (text "{%- endif %}") + + (text "{% if stack.mode == 'BlockList' -%}") + (text "{% if not is_blocked -%}") + (button + ("onclick" "block_all()") + (str (text "stacks:label.block_all"))) + (text "{% else %}") + (button + ("onclick" "block_all(false)") + (str (text "stacks:label.unblock_all"))) + (text "{%- endif %}") + (div + ("class" "flex gap-2 flex-wrap w-full") + (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) + (text "{% else %}") + (text "{% for post in list %} + {% if post[2].read_access == \"Everybody\" -%} + {% if post[0].context.repost and post[0].context.repost.reposting -%} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} + {%- endif %} {%- endif %} {% endfor %}") + (text "{%- endif %} {{ components::pagination(page=page, items=list|length) }}")))) + +(script + (text "async function block_all(block = true) { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/stacks/{{ stack.id }}/block\", { + method: block ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.href = \"/settings#/account/blocks\"; + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 6ef2cab..50246ef 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -44,20 +44,7 @@ (div ("class" "card flex flex-col gap-2") (text "{% for item in list %}") - (a - ("href" "/stacks/{{ item.id }}") - ("class" "card secondary flex flex-col gap-2") - (div - ("class" "flex items-center gap-2") - (text "{{ icon \"list\" }}") - (b - (text "{{ item.name }}"))) - (span - (text "Created ") - (span - ("class" "date") - (text "{{ item.created }}")) - (text "; {{ item.privacy }}; {{ item.users|length }} users"))) + (text "{{ components::stack_listing(stack=item) }}") (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index a05e680..ef608c5 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -63,7 +63,11 @@ (option ("value" "Exclude") ("selected" "{% if stack.mode == 'Exclude' -%}true{% else %}false{%- endif %}") - (text "Exclude"))))) + (text "Exclude")) + (option + ("value" "BlockList") + ("selected" "{% if stack.mode == 'BlockList' -%}true{% else %}false{%- endif %}") + (text "Block list"))))) (div ("class" "card-nest") ("ui_ident" "sort") diff --git a/crates/app/src/public/html/stacks/posts.lisp b/crates/app/src/public/html/stacks/posts.lisp deleted file mode 100644 index cddf9c7..0000000 --- a/crates/app/src/public/html/stacks/posts.lisp +++ /dev/null @@ -1,37 +0,0 @@ -(text "{% extends \"root.html\" %} {% block head %}") -(title - (text "{{ stack.name }} - {{ config.name }}")) - -(text "{% endblock %} {% block body %} {{ macros::nav() }}") -(main - ("class" "flex flex-col gap-2") - (text "{{ macros::timelines_nav(selected=\"stacks\") }}") - (div - ("class" "card-nest w-full") - (div - ("class" "card small flex items-center justify-between gap-2") - (div - ("class" "flex items-center gap-2") - (text "{{ icon \"list\" }}") - (span - (text "{{ stack.name }}"))) - (text "{% if user and user.id == stack.owner -%}") - (a - ("href" "/stacks/{{ stack.id }}/manage") - ("class" "button lowered small") - (text "{{ icon \"pencil\" }}") - (span - (text "{{ text \"general:action.manage\" }}"))) - (text "{%- endif %}")) - (div - ("class" "card w-full flex flex-col gap-2") - (text "{% if list|length == 0 -%}") - (p - (text "No posts yet! Maybe ") - (a - ("href" "/stacks/{{ stack.id }}/manage#/users") - (text "add a user to this stack")) - (text "!")) - (text "{%- endif %} {% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))) - -(text "{% endblock %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 1e34ef2..28cd3fc 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1104,24 +1104,9 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", async (_, src) => { + self.define("lightbox_open", (_, src) => { document.getElementById("lightbox_img").src = src; document.getElementById("lightbox_img_a").href = src; - - await (async () => { - return new Promise((resolve, reject) => { - let idx = 0; - const inter = setInterval(() => { - idx += 1; - if (document.getElementById("lightbox_img").complete) { - console.log(`img loaded (took ${idx})`); - clearInterval(inter); - return resolve(); - } - }, 25); - }); - })(); - document.getElementById("lightbox").classList.remove("hidden"); }); diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ca5f747..4b81eb5 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -537,8 +537,11 @@ pub fn routes() -> Router { .route("/stacks/{id}/privacy", post(stacks::update_privacy_request)) .route("/stacks/{id}/mode", post(stacks::update_mode_request)) .route("/stacks/{id}/sort", post(stacks::update_sort_request)) + .route("/stacks/{id}/users", get(stacks::get_users_request)) .route("/stacks/{id}/users", post(stacks::add_user_request)) .route("/stacks/{id}/users", delete(stacks::remove_user_request)) + .route("/stacks/{id}/block", post(stacks::block_request)) + .route("/stacks/{id}/block", delete(stacks::unblock_request)) .route("/stacks/{id}", delete(stacks::delete_request)) // uploads .route("/uploads/{id}", get(uploads::get_request)) diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 8faab1f..94b2c5a 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -1,7 +1,16 @@ -use crate::{State, get_user_from_token}; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use crate::{get_user_from_token, routes::pages::PaginatedQuery, State}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{oauth, stacks::UserStack, ApiReturn, Error}; +use tetratto_core::model::{ + oauth, + permissions::FinePermission, + stacks::{StackBlock, StackPrivacy, UserStack}, + ApiReturn, Error, +}; use super::{ AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy, UpdateStackSort, @@ -221,3 +230,93 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn get_users_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let stack = match data.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Json(e.into()), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Json(Error::NotAllowed.into()); + } + + match data.get_stack_users(id, 12, props.page).await { + Ok(users) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some({ + let mut out = Vec::new(); + + for mut u in users.clone() { + u.clean(); + out.push(u) + } + + out + }), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn block_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.create_stackblock(StackBlock::new(user.id, id)).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unblock_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let block = match data.get_stackblock_by_initiator_stack(user.id, id).await { + Ok(b) => b, + Err(e) => return Json(e.into()), + }; + + match data.delete_stackblock(block.id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index f7d542f..3a67aa7 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -126,7 +126,7 @@ pub fn routes() -> Router { .route("/developer/app/{id}", get(developer::app_request)) // stacks .route("/stacks", get(stacks::list_request)) - .route("/stacks/{id}", get(stacks::posts_request)) + .route("/stacks/{id}", get(stacks::feed_request)) .route("/stacks/{id}/manage", get(stacks::manage_request)) } diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index f559b3c..2713b26 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -80,6 +80,19 @@ pub async fn settings_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), }; + let stackblocks = { + let mut out = Vec::new(); + + for block in data.0.get_stackblocks_by_initiator(profile.id).await { + out.push(match data.0.get_stack_by_id(block.stack).await { + Ok(s) => s, + Err(_) => continue, + }); + } + + out + }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { Ok(ua) => ua, Err(e) => { @@ -98,6 +111,7 @@ pub async fn settings_request( context.insert("stacks", &stacks); context.insert("following", &following); context.insert("blocks", &blocks); + context.insert("stackblocks", &stackblocks); context.insert( "user_tokens_serde", &serde_json::to_string(&tokens) @@ -128,59 +142,37 @@ pub async fn settings_request( ) .unwrap(); - let light = serde_json::Value::from("Light"); - let mut profile_theme = settings_map - .get("profile_theme") - .unwrap_or(&light) - .as_str() - .unwrap(); + if let Some(color_surface) = settings_map.get("theme_color_surface") { + let color_surface = color_surface.as_str().unwrap(); + for setting in &settings_map { + if !setting.0.starts_with("theme_color_text") + | (setting.0 == "theme_color_text_primary") + | (setting.0 == "theme_color_text_secondary") + { + continue; + } - if profile_theme.is_empty() | (profile_theme == "Auto") { - profile_theme = "Light"; - } + let value = setting.1.as_str().unwrap(); - let default_surface = serde_json::Value::from(if profile_theme == "Light" { - "#f3f2f1" + if !value.starts_with("#") { + // we can only parse hex right now + continue; + } + + let c1 = Color::from(color_surface); + let c2 = Color::from(value); + let contrast = c1.contrast(&c2); + + if contrast < MINIMUM_CONTRAST_THRESHOLD { + failing_color_keys.push((setting.0, contrast)); + } + } + + context.insert("failing_color_keys", &failing_color_keys); } else { - "#19171c" - }); - - let mut color_surface = settings_map - .get("theme_color_surface") - .unwrap_or(&default_surface) - .as_str() - .unwrap(); - - if color_surface.is_empty() { - color_surface = default_surface.as_str().unwrap(); + context.insert("failing_color_keys", &Vec::<&str>::new()); } - for setting in &settings_map { - if !setting.0.starts_with("theme_color_text") - | (setting.0 == "theme_color_text_primary") - | (setting.0 == "theme_color_text_secondary") - { - continue; - } - - let value = setting.1.as_str().unwrap(); - - if !value.starts_with("#") { - // we can only parse hex right now - continue; - } - - let c1 = Color::from(color_surface); - let c2 = Color::from(value); - let contrast = c1.contrast(&c2); - - if contrast < MINIMUM_CONTRAST_THRESHOLD { - failing_color_keys.push((setting.0, contrast)); - } - } - - context.insert("failing_color_keys", &failing_color_keys); - // return Ok(Html( data.1.render("profile/settings.html", &context).unwrap(), diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index 48bfdb1..656fee3 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -4,7 +4,12 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, stacks::StackPrivacy, Error, auth::User}; +use tetratto_core::model::{ + auth::User, + permissions::FinePermission, + stacks::{StackMode, StackPrivacy}, + Error, +}; use crate::{assets::initial_context, get_lang, get_user_from_token, State}; use super::{render_error, PaginatedQuery}; @@ -35,7 +40,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } /// `/stacks/{id}` -pub async fn posts_request( +pub async fn feed_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, @@ -65,32 +70,50 @@ pub async fn posts_request( )); } - let ignore_users = crate::ignore_users_gen!(user!, data); - let list = match data - .0 - .get_stack_posts( - user.id, - stack.id, - 12, - req.page, - &ignore_users, - &Some(user.clone()), - ) - .await - { - Ok(l) => l, - Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), - }; - let lang = get_lang!(jar, data.0); - let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await; context.insert("page", &req.page); context.insert("stack", &stack); - context.insert("list", &list); + + if stack.mode == StackMode::BlockList { + let list = match data.0.get_stack_users(stack.id, 12, req.page).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + context.insert("list", &list); + context.insert( + "is_blocked", + &data + .0 + .get_stackblock_by_initiator_stack(user.id, stack.id) + .await + .is_ok(), + ); + } else { + let ignore_users = crate::ignore_users_gen!(user!, data); + let list = match data + .0 + .get_stack_posts( + user.id, + stack.id, + 12, + req.page, + &ignore_users, + &Some(user.clone()), + ) + .await + { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + context.insert("list", &list); + } // return - Ok(Html(data.1.render("stacks/posts.html", &context).unwrap())) + Ok(Html(data.1.render("stacks/feed.html", &context).unwrap())) } /// `/stacks/{id}/manage` diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index cc62849..8536b88 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -35,6 +35,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_POLLS).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); + execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); self.0 .1 @@ -363,7 +364,10 @@ macro_rules! auto_method { } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!("invoked `{}` with x value `{x:?}`", stringify!($name)), + format!( + "invoked `{}` with x value `{id}` and y value `{x:?}`", + stringify!($name) + ), )) .await? } diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 9d8df41..94cc123 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -22,3 +22,4 @@ pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); +pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql"); diff --git a/crates/core/src/database/drivers/sql/create_stackblocks.sql b/crates/core/src/database/drivers/sql/create_stackblocks.sql new file mode 100644 index 0000000..6e30beb --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_stackblocks.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS stackblocks ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + initiator BIGINT NOT NULL, + stack BIGINT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index c7290bf..458897f 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -18,6 +18,7 @@ mod questions; mod reactions; mod reports; mod requests; +mod stackblocks; mod stacks; mod uploads; mod user_warnings; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index ac28744..7e06d67 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1507,6 +1507,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(rt.owner) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } @@ -1552,6 +1556,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(rt.owner) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } @@ -1571,6 +1579,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(user.id, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(user.id) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 438f282..4dce267 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -145,10 +145,14 @@ impl DataManager { if data.asset_type == AssetType::Post { let post = self.get_post_by_id(data.asset).await?; - if self + if (self .get_userblock_by_initiator_receiver(post.owner, user.id) .await .is_ok() + | self + .get_user_stack_blocked_users(post.owner) + .await + .contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); @@ -156,10 +160,14 @@ impl DataManager { } else if data.asset_type == AssetType::Question { let question = self.get_question_by_id(data.asset).await?; - if self + if (self .get_userblock_by_initiator_receiver(question.owner, user.id) .await .is_ok() + | self + .get_user_stack_blocked_users(question.owner) + .await + .contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); diff --git a/crates/core/src/database/stackblocks.rs b/crates/core/src/database/stackblocks.rs new file mode 100644 index 0000000..0ae9e82 --- /dev/null +++ b/crates/core/src/database/stackblocks.rs @@ -0,0 +1,224 @@ +use oiseau::cache::Cache; +use crate::model::stacks::StackPrivacy; +use crate::model::{Error, Result, auth::User, stacks::StackBlock, permissions::FinePermission}; +use crate::{auto_method, DataManager}; + +#[cfg(feature = "sqlite")] +use oiseau::SqliteRow; + +#[cfg(feature = "postgres")] +use oiseau::PostgresRow; + +use oiseau::{execute, get, params, query_row, query_rows}; + +impl DataManager { + /// Get a [`StackBlock`] from an SQL row. + pub(crate) fn get_stackblock_from_row( + #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, + #[cfg(feature = "postgres")] x: &PostgresRow, + ) -> StackBlock { + StackBlock { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + initiator: get!(x->2(i64)) as usize, + stack: get!(x->3(i64)) as usize, + } + } + + auto_method!(get_stackblock_by_id()@get_stackblock_from_row -> "SELECT * FROM stackblocks WHERE id = $1" --name="stack block" --returns=StackBlock --cache-key-tmpl="atto.stackblock:{}"); + + pub async fn get_user_stack_blocked_users(&self, user_id: usize) -> Vec { + let mut stack_block_users = Vec::new(); + + for block in self.get_stackblocks_by_initiator(user_id).await { + for user in match self.fill_stackblocks_receivers(block.stack).await { + Ok(ul) => ul, + Err(_) => continue, + } { + stack_block_users.push(user); + } + } + + stack_block_users + } + + /// Fill a vector of stack blocks with their receivers (by pulling the stack). + pub async fn fill_stackblocks_receivers(&self, stack: usize) -> Result> { + let stack = self.get_stack_by_id(stack).await?; + let mut out = Vec::new(); + + for block in stack.users { + out.push(block); + } + + Ok(out) + } + + /// Get all stack blocks created by the given `initiator`. + pub async fn get_stackblocks_by_initiator(&self, initiator: usize) -> Vec { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM stackblocks WHERE initiator = $1", + &[&(initiator as i64)], + |x| { Self::get_stackblock_from_row(x) } + ); + + if res.is_err() { + return Vec::new(); + } + + // make sure all stacks still exist + let list = res.unwrap(); + + for block in &list { + if self.get_stack_by_id(block.stack).await.is_err() { + if self.delete_stackblock_sudo(block.id).await.is_err() { + continue; + } + } + } + + // return + list + } + + /// Get a stack block by `initiator` and `stack` (in that order). + pub async fn get_stackblock_by_initiator_stack( + &self, + initiator: usize, + stack: usize, + ) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM stackblocks WHERE initiator = $1 AND stack = $2", + &[&(initiator as i64), &(stack as i64)], + |x| { Ok(Self::get_stackblock_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("stack block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new stack block in the database. + /// + /// # Arguments + /// * `data` - a mock [`StackBlock`] object to insert + pub async fn create_stackblock(&self, data: StackBlock) -> Result<()> { + let initiator = self.get_user_by_id(data.initiator).await?; + let stack = self.get_stack_by_id(data.stack).await?; + + if initiator.id != stack.owner + && stack.privacy == StackPrivacy::Private + && !initiator.permissions.check(FinePermission::MANAGE_STACKS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO stackblocks VALUES ($1, $2, $3, $4)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.initiator as i64), + &(data.stack as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // unfollow/remove follower + for user in stack.users { + if let Ok(f) = self + .get_userfollow_by_initiator_receiver(data.initiator, user) + .await + { + self.delete_userfollow_sudo(f.id, data.initiator).await?; + } + + if let Ok(f) = self + .get_userfollow_by_receiver_initiator(data.initiator, user) + .await + { + self.delete_userfollow_sudo(f.id, data.initiator).await?; + } + } + + // return + Ok(()) + } + + pub async fn delete_stackblock(&self, id: usize, user: User) -> Result<()> { + let block = self.get_stackblock_by_id(id).await?; + + if user.id != block.initiator { + // only the initiator (or moderators) can delete stack blocks! + if !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { + return Err(Error::NotAllowed); + } + } + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.stackblock:{}", id)).await; + + // return + Ok(()) + } + + pub async fn delete_stackblock_sudo(&self, id: usize) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.stackblock:{}", id)).await; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 2407807..e0789c7 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -91,9 +91,39 @@ impl DataManager { } } } + StackMode::BlockList => { + return Err(Error::MiscError( + "You should use `get_stack_users` for this type".to_string(), + )); + } }) } + pub async fn get_stack_users(&self, id: usize, batch: usize, page: usize) -> Result> { + let stack = self.get_stack_by_id(id).await?; + + if stack.mode != StackMode::BlockList { + return Err(Error::MiscError( + "You should use `get_stack_posts` for this type".to_string(), + )); + } + + // build list + let mut out = Vec::new(); + let mut i = 0; + + for user in stack.users.iter().skip(batch * page) { + if i == batch { + break; + } + + out.push(self.get_user_by_id(user.to_owned()).await?); + i += 1; + } + + Ok(out) + } + /// Get all stacks by user. /// /// # Arguments diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 12046c5..4a4889b 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -346,4 +346,41 @@ impl DataManager { // return Ok(()) } + + pub async fn delete_userfollow_sudo(&self, id: usize, user_id: usize) -> Result<()> { + let follow = self.get_userfollow_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM userfollows WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.userfollow:{}", id)).await; + + // decr counts + if follow.initiator != user_id { + self.decr_user_following_count(follow.initiator) + .await + .unwrap(); + } + + if follow.receiver != user_id { + self.decr_user_follower_count(follow.receiver) + .await + .unwrap(); + } + + // return + Ok(()) + } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 438f5fd..b2a3798 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -76,6 +76,8 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, + /// Create circles on behalf of the user. + UserCreateCircles, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -106,6 +108,8 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, + /// Manage the user's circles (add/remove users or delete). + UserManageCircles, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. @@ -142,68 +146,6 @@ pub enum AppScope { CommunityManageChannels, } -impl AppScope { - /// Parse the given input string as a list of scopes. - pub fn parse(input: &str) -> Vec { - let mut out: Vec = Vec::new(); - for scope in input.split(" ") { - out.push(match scope { - "user-read-profiles" => Self::UserReadProfiles, - "user-read-profile" => Self::UserReadProfile, - "user-read-settings" => Self::UserReadSettings, - "user-read-sessions" => Self::UserReadSessions, - "user-read-posts" => Self::UserReadPosts, - "user-read-messages" => Self::UserReadMessages, - "user-read-drafts" => Self::UserReadDrafts, - "user-read-communities" => Self::UserReadCommunities, - "user-read-sockets" => Self::UserReadSockets, - "user-read-notifications" => Self::UserReadNotifications, - "user-read-requests" => Self::UserReadRequests, - "user-read-questions" => Self::UserReadQuestions, - "user-create-posts" => Self::UserCreatePosts, - "user-create-messages" => Self::UserCreateMessages, - "user-create-questions" => Self::UserCreateQuestions, - "user-create-ip-blocks" => Self::UserCreateIpBlock, - "user-create-drafts" => Self::UserCreateDrafts, - "user-create-communities" => Self::UserCreateCommunities, - "user-delete-posts" => Self::UserDeletePosts, - "user-delete-messages" => Self::UserDeleteMessages, - "user-delete-questions" => Self::UserDeleteQuestions, - "user-delete-drafts" => Self::UserDeleteDrafts, - "user-manage-profile" => Self::UserManageProfile, - "user-manage-stacks" => Self::UserManageStacks, - "user-manage-relationships" => Self::UserManageRelationships, - "user-manage-memberships" => Self::UserManageMemberships, - "user-manage-following" => Self::UserManageFollowing, - "user-manage-followers" => Self::UserManageFollowers, - "user-manage-blocks" => Self::UserManageBlocks, - "user-manage-notifications" => Self::UserManageNotifications, - "user-manage-requests" => Self::UserManageRequests, - "user-manage-uploads" => Self::UserManageUploads, - "user-edit-posts" => Self::UserEditPosts, - "user-edit-drafts" => Self::UserEditDrafts, - "user-vote" => Self::UserVote, - "user-react" => Self::UserReact, - "user-join-communities" => Self::UserJoinCommunities, - "mod-purge-posts" => Self::ModPurgePosts, - "mod-delete-posts" => Self::ModDeletePosts, - "mod-manage-warnings" => Self::ModManageWarnings, - "user-read-emojis" => Self::UserReadEmojis, - "community-create-emojis" => Self::CommunityCreateEmojis, - "community-manage-emojis" => Self::CommunityManageEmojis, - "community-delete" => Self::CommunityDelete, - "community-manage" => Self::CommunityManage, - "community-transfer-ownership" => Self::CommunityTransferOwnership, - "community-read-memberships" => Self::CommunityReadMemberships, - "community-create-channels" => Self::CommunityCreateChannels, - "community-manage-channels" => Self::CommunityManageChannels, - _ => continue, - }) - } - out - } -} - impl AuthGrant { /// Check a verifier against the stored challenge (using the given [`PkceChallengeMethod`]). pub fn check_verifier(&self, verifier: &str) -> Result<()> { diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs index 809a0e0..a2e7487 100644 --- a/crates/core/src/model/stacks.rs +++ b/crates/core/src/model/stacks.rs @@ -23,6 +23,11 @@ pub enum StackMode { /// `users` vec contains ID of users to EXCLUDE from the timeline; /// every other user is included Exclude, + /// `users` vec contains ID of users to show in a user listing on the stack's + /// page (instead of a timeline). + /// + /// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`). + BlockList, } impl Default for StackMode { @@ -70,3 +75,23 @@ impl UserStack { } } } + +#[derive(Serialize, Deserialize)] +pub struct StackBlock { + pub id: usize, + pub created: usize, + pub initiator: usize, + pub stack: usize, +} + +impl StackBlock { + /// Create a new [`StackBlock`]. + pub fn new(initiator: usize, stack: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + initiator, + stack, + } + } +}