add: blocked users page

This commit is contained in:
trisua 2025-05-09 22:36:16 -04:00
parent 6893aeedb5
commit 3706764437
18 changed files with 427 additions and 178 deletions

View file

@ -15,7 +15,7 @@ serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tower-http = { version = "0.6.3", features = ["trace", "fs", "catch-panic"] }
tower-http = { version = "0.6.2", features = ["trace", "fs", "catch-panic"] }
axum = { version = "0.8.4", features = ["macros", "ws"] }
tokio = { version = "1.45.0", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
@ -35,7 +35,7 @@ cf-turnstile = "0.2.0"
contrasted = "0.1.2"
futures-util = "0.3.31"
redis = { version = "0.30.0", features = [
redis = { version = "0.31.0", features = [
"aio",
"tokio-comp",
], optional = true }

View file

@ -33,6 +33,9 @@ version = "1.0.0"
"general:label.account_banned_body" = "Your account has been banned for violating our policies."
"general:label.better_with_account" = "It's better with an account! Login or sign up to explore more."
"general:label.supporter_motivation" = "Become a supporter!"
"general:action.become_supporter" = "Become supporter"
"dialog:action.okay" = "Ok"
"dialog:action.continue" = "Continue"
"dialog:action.cancel" = "Cancel"
@ -136,6 +139,11 @@ version = "1.0.0"
"settings:label.theme_lit" = "Theme lit"
"settings:label.import" = "Import"
"settings:label.export" = "Export"
"settings:label.manage_blocks" = "Manage blocks"
"settings:label.users" = "Users"
"settings:tab.security" = "Security"
"settings:tab.blocks" = "Blocks"
"settings:tab.billing" = "Billing"
"mod_panel:label.open_reported_content" = "Open reported content"
"mod_panel:label.manage_profile" = "Manage profile"

View file

@ -461,6 +461,39 @@ table ol {
border-top-right-radius: 0;
}
/* supporter card */
@property --border-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.supporter_ad {
--border-angle: 0deg;
padding: 2px;
background-image:
linear-gradient(var(--color-raised), var(--color-raised)),
repeating-conic-gradient(
from var(--border-angle),
var(--color-primary) 0%,
var(--color-primary-lowered) 50%
);
background-origin: border-box;
background-clip: content-box, border-box;
animation: 10s rotate_angle linear infinite;
cursor: pointer;
}
@keyframes rotate_angle {
from {
--border-angle: 0deg;
}
to {
--border-angle: 360deg;
}
}
/* buttons */
button,
.button {

View file

@ -30,7 +30,9 @@
</button>
</form>
</div>
{% endif %}
{% if list|length >= 4 %} {{ components::supporter_ad(body="Become a
supporter to create up to 10 communities!") }} {% endif %} {% endif %}
<div class="card-nest w-full">
<div class="card small flex items-center justify-between gap-2">

View file

@ -1242,4 +1242,24 @@ show_kick=false, secondary=false) -%}
</div>
</div>
</dialog>
{% endif %} {%- endmacro %} {% macro supporter_ad(body="") -%} {% if
config.stripe and not is_supporter %}
<div
class="card w-full supporter_ad"
ui_ident="supporter_ad"
onclick="window.location.href = '/settings#/account/billing'"
>
<div class="card w-full flex flex-wrap items-center gap-2 justify-between">
{% if body %}
<b>{{ body }}</b>
{% else %}
<b>{{ text "general:label.supporter_motivation" }}</b>
{% endif %}
<a href="/settings#/account/billing" class="button small">
{{ icon "heart" }}
<span>{{ text "general:action.become_supporter" }}</span>
</a>
</div>
</div>
{% endif %} {%- endmacro %}

View file

@ -38,36 +38,24 @@
<div class="w-full flex flex-col gap-2" data-tab="account">
<div class="card tertiary flex flex-col gap-2" id="account_settings">
{% if config.stripe %}
<div class="card-nest" ui_ident="supporter_card">
<div class="card small flex items-center gap-2">
{{ icon "star" }}
<b>Supporter status</b>
</div>
<div class="pillmenu" ui_ident="account_settings_tabs">
<a data-tab-button="account/security" href="#/account/security">
{{ icon "user-lock" }}
<span>{{ text "settings:tab.security" }}</span>
</a>
<div class="card">
{% if is_supporter %}
<p>You <b>are</b> a supporter! Thank you for all that you do. You can manage your billing information below. <b>Please use your email address you supplied when paying to login to the billing portal.</b></p>
<a href="{{ config.stripe.billing_portal_url }}" class="button quaternary" target="_blank">Manage billing</a>
{% else %}
<p>You're <b>not</b> currently a supporter! No pressure, but it helps us do some pretty cool things! As a supporter, you'll get:</p>
<a data-tab-button="account/blocks" href="#/account/blocks">
{{ icon "shield" }}
<span>{{ text "settings:tab.blocks" }}</span>
</a>
<ul style="margin-bottom: 1rem">
<li>Vanity badge on profile</li>
<li>Ability to upload gif avatars/banners</li>
<li>Be an admin/owner of up to 10 communities</li>
<li>Use custom CSS on your profile</li>
<li>Ability to use community emojis outside of their community <b>(soon)</b></li>
<li>Ability to upload and use gif emojis <b>(soon)</b></li>
<li><b>Create infinite stack timelines</b></li>
</ul>
<a href="{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}" class="button" target="_blank">Become a supporter</a>
<span class="fade">Please use your <b>real email</b> when completing payment. It is required to manage your billing settings.</span>
{% endif %}
</div>
{% if config.stripe %}
<a data-tab-button="account/billing" href="#/account/billing">
{{ icon "credit-card" }}
<span>{{ text "settings:tab.billing" }}</span>
</a>
{% endif %}
</div>
{% endif %}
<div class="card-nest" ui_ident="home_timeline">
<div class="card small">
@ -153,62 +141,22 @@
<div class="card flex flex-col gap-2">
<button id="notifications_button"></button>
<span class="fade">Notifications require you to keep {{ config.name }} open in your browser for real-time updates. This setting does not sync across browsers.</span>
<span class="fade"
>Notifications require you to keep {{ config.name }}
open in your browser for real-time updates. This setting
does not sync across browsers.</span
>
</div>
</div>
<script>
setTimeout(() => {
trigger("me::notifications_button", [document.getElementById("notifications_button")]);
}, 150)
trigger("me::notifications_button", [
document.getElementById("notifications_button"),
]);
}, 150);
</script>
<div class="card-nest" ui_ident="change_password">
<div class="card small">
<b>{{ text "settings:label.change_password" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="change_password(event)"
>
<div class="flex flex-col gap-1">
<label for="current_password"
>{{ text "settings:label.current_password" }}</label
>
<input
type="password"
name="current_password"
id="current_password"
placeholder="current_password"
required
minlength="6"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-1">
<label for="new_password"
>{{ text "settings:label.new_password" }}</label
>
<input
type="password"
name="new_password"
id="new_password"
placeholder="new_password"
required
minlength="6"
autocomplete="off"
/>
</div>
<button class="primary">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
</form>
</div>
<div class="card-nest" ui_ident="change_username">
<div class="card small">
<b>{{ text "settings:label.change_username" }}</b>
@ -238,63 +186,9 @@
</button>
</form>
</div>
<div class="card-nest" ui_ident="two_factor_authentication">
<div class="card small">
<b>{{ text "settings:label.two_factor_authentication" }}</b>
</div>
<div class="card flex flex-col gap-2">
{% if profile.totp|length == 0 %}
<div id="totp_stuff" style="display: none">
<span
>Scan this QR code in a TOTP authenticator app (like
Google Authenticator):
</span>
<img id="totp_qr" style="max-width: 250px" />
<span>TOTP secret (do NOT share):</span>
<pre id="totp_secret"></pre>
<span
>Recovery codes (STORE SAFELY, these can only be
viewed once):</span
>
<pre id="totp_recovery_codes"></pre>
</div>
<button
class="quaternary green"
onclick="enable_totp(event)"
>
Enable TOTP 2FA
</button>
{% else %}
<pre id="totp_recovery_codes" style="display: none"></pre>
<div class="flex gap-2 flex-wrap">
<button
class="quaternary red"
onclick="refresh_totp_codes(event)"
>
Refresh recovery codes
</button>
<button
class="quaternary red"
onclick="disable_totp(event)"
>
Disable TOTP 2FA
</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="card-nest" ui_ident="change_password">
<div class="card-nest" ui_ident="delete_account">
<div class="card small flex items-center gap-2 red">
{{ icon "skull" }}
<b>{{ text "settings:label.delete_account" }}</b>
@ -332,8 +226,262 @@
</button>
</div>
<div class="w-full flex flex-col gap-2" data-tab="account/security">
<div class="card tertiary flex flex-col gap-2">
<a href="#/account" class="button secondary">
{{ icon "arrow-left" }}
<span>{{ text "general:action.back" }}</span>
</a>
<div class="card-nest">
<div class="card flex items-center gap-2 small">
{{ icon "user-lock" }}
<span>{{ text "settings:tab.security" }}</span>
</div>
<div class="card flex flex-col gap-2 secondary">
<div class="card-nest" ui_ident="two_factor_authentication">
<div class="card small">
<b
>{{ text
"settings:label.two_factor_authentication" }}</b
>
</div>
<div class="card flex flex-col gap-2">
{% if profile.totp|length == 0 %}
<div id="totp_stuff" style="display: none">
<span
>Scan this QR code in a TOTP authenticator
app (like Google Authenticator):
</span>
<img id="totp_qr" style="max-width: 250px" />
<span>TOTP secret (do NOT share):</span>
<pre id="totp_secret"></pre>
<span
>Recovery codes (STORE SAFELY, these can
only be viewed once):</span
>
<pre id="totp_recovery_codes"></pre>
</div>
<button
class="quaternary green"
onclick="enable_totp(event)"
>
Enable TOTP 2FA
</button>
{% else %}
<pre
id="totp_recovery_codes"
style="display: none"
></pre>
<div class="flex gap-2 flex-wrap">
<button
class="quaternary red"
onclick="refresh_totp_codes(event)"
>
Refresh recovery codes
</button>
<button
class="quaternary red"
onclick="disable_totp(event)"
>
Disable TOTP 2FA
</button>
</div>
{% endif %}
</div>
</div>
<div class="card-nest" ui_ident="change_password">
<div class="card small">
<b>{{ text "settings:label.change_password" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="change_password(event)"
>
<div class="flex flex-col gap-1">
<label for="current_password"
>{{ text "settings:label.current_password"
}}</label
>
<input
type="password"
name="current_password"
id="current_password"
placeholder="current_password"
required
minlength="6"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-1">
<label for="new_password"
>{{ text "settings:label.new_password"
}}</label
>
<input
type="password"
name="new_password"
id="new_password"
placeholder="new_password"
required
minlength="6"
autocomplete="off"
/>
</div>
<button class="primary">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="w-full flex flex-col gap-2" data-tab="account/blocks">
<div class="card tertiary flex flex-col gap-2">
<a href="#/account" class="button secondary">
{{ icon "arrow-left" }}
<span>{{ text "general:action.back" }}</span>
</a>
<div class="card-nest">
<div class="card flex items-center gap-2 small">
{{ icon "users-round" }}
<span>{{ text "settings:label.users" }}</span>
</div>
<div class="card flex flex-col gap-2">
{% for user in blocks %}
<div
class="card secondary flex flex-wrap gap-2 items-center justify-between"
>
<div class="flex gap-2">
{{ components::avatar(username=user.username) }} {{
components::full_username(user=user) }}
</div>
<a
href="/@{{ user.username }}"
class="button quaternary small"
>
{{ icon "external-link" }}
<span
>{{ text "requests:action.view_profile" }}</span
>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="w-full flex flex-col gap-2" data-tab="account/billing">
<div class="card tertiary flex flex-col gap-2">
<a href="#/account" class="button secondary">
{{ icon "arrow-left" }}
<span>{{ text "general:action.back" }}</span>
</a>
<div class="card-nest">
<div class="card flex items-center gap-2 small">
{{ icon "credit-card" }}
<span>{{ text "settings:tab.billing" }}</span>
</div>
<div class="card flex flex-col gap-2 secondary">
{% if config.stripe %}
<div class="card-nest" ui_ident="supporter_card">
<div class="card small flex items-center gap-2">
{{ icon "star" }}
<b>Supporter status</b>
</div>
<div class="card flex flex-col gap-2">
{% if is_supporter %}
<p>
You <b>are</b> a supporter! Thank you for all
that you do. You can manage your billing
information below.
<b
>Please use your email address you supplied
when paying to login to the billing
portal.</b
>
</p>
<a
href="{{ config.stripe.billing_portal_url }}"
class="button quaternary"
target="_blank"
>Manage billing</a
>
{% else %}
<p>
You're <b>not</b> currently a supporter! No
pressure, but it helps us do some pretty cool
things! As a supporter, you'll get:
</p>
<ul style="margin-bottom: 1rem">
<li>Vanity badge on profile</li>
<li>No more supporter ads (duh)</li>
<li>Ability to upload gif avatars/banners</li>
<li>
Be an admin/owner of up to 10 communities
</li>
<li>Use custom CSS on your profile</li>
<li>
Ability to use community emojis outside of
their community <b>(soon)</b>
</li>
<li>
Ability to upload and use gif emojis
<b>(soon)</b>
</li>
<li>Create infinite stack timelines</li>
</ul>
<a
href="{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}"
class="button"
target="_blank"
>Become a supporter</a
>
<span class="fade"
>Please use your <b>real email</b> when
completing payment. It is required to manage
your billing settings.</span
>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="w-full hidden flex flex-col gap-2" data-tab="profile">
<div class="card tertiary flex flex-col gap-2" id="profile_settings">
{{ components::supporter_ad(body="Become a supporter to upload GIF
images!") }}
<div class="card-nest" ui_ident="change_avatar">
<div class="card small">
<b>{{ text "settings:label.change_avatar" }}</b>
@ -358,7 +506,9 @@
</div>
<span class="fade"
>Images must be less than 8 MB large. Animated GIFs are only supported for supporter users. GIFs can be at most 2 MB large.</span
>Images must be less than 8 MB large. Animated GIFs are
only supported for supporter users. GIFs can be at most
2 MB large.</span
>
</form>
</div>
@ -477,6 +627,9 @@
</button>
</div>
{{ components::supporter_ad(body="Become a supporter to add custom
CSS!") }}
<div class="card-nest" ui_ident="theme_preference">
<div class="card small">
<b>Theme preference</b>
@ -607,9 +760,9 @@
</button>
<label for="{{ key }}-shown" class="flex items-center gap-2">
<!-- prettier-ignore -->
<input
type="checkbox"
<!-- prettier-ignore -->
{% if value[0].show_on_profile %}checked{% endif %}
id="{{ key }}-shown"
onchange="trigger('connections::push_con_shown', ['{{ key }}', event.target.checked])"
@ -933,18 +1086,20 @@
const theme_settings = document.getElementById("theme_settings");
ui.refresh_container(account_settings, [
"supporter_card",
"supporter_ad",
"account_settings_tabs",
"home_timeline",
"notifications",
"change_password",
"change_username",
"two_factor_authentication",
"delete_account",
]);
ui.refresh_container(profile_settings, [
"supporter_ad",
"change_avatar",
"change_banner",
]);
ui.refresh_container(theme_settings, [
"supporter_ad",
"awful_contrast",
"import_export",
"theme_preference",
@ -964,11 +1119,7 @@
settings.biography,
"textarea",
],
[
["status", "Status"],
settings.status,
"textarea",
],
[["status", "Status"], settings.status, "textarea"],
[
["warning", "Profile warning"],
settings.warning,

View file

@ -23,7 +23,7 @@ pub async fn stripe_webhook(
let req = match stripe::Webhook::construct_event(
&body,
&sig.to_str().unwrap(),
sig.to_str().unwrap(),
&data.0.stripe.as_ref().unwrap().webhook_signing_secret,
) {
Ok(e) => e,

View file

@ -216,15 +216,13 @@ pub async fn handle_socket(socket: WebSocket, db: DataManager, community_id: Str
if !cs {
continue;
}
} else {
if !headers.is_channel {
// since we didn't select by just a channel, there HAS to be
// an entry for the channel for us to check this message
continue;
// we don't need to check messages when we're subscribed to
// a channel, since that is checked on headers submission when
// we subscribe to a channel
}
} else if !headers.is_channel {
// since we didn't select by just a channel, there HAS to be
// an entry for the channel for us to check this message
continue;
// we don't need to check messages when we're subscribed to
// a channel, since that is checked on headers submission when
// we subscribe to a channel
}
if sink.send(WsMessage::Text(smsg.into())).await.is_err() {

View file

@ -67,7 +67,7 @@ pub async fn app_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
if let Some(ref channel) = channels.get(0) {
if let Some(channel) = channels.first() {
return Ok(Html(format!(
"<!doctype html><html><head><meta http-equiv=\"refresh\" content=\"0; url=/chats/{}/{}?nav={}\" /></head></html>",
selected_community, channel.id, props.nav

View file

@ -56,6 +56,15 @@ pub async fn settings_request(
}
};
let blocks = match data
.0
.fill_userblocks_receivers(data.0.get_userblocks_by_initiator(profile.id).await)
.await
{
Ok(r) => r,
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
};
let tokens = profile.tokens.clone();
let lang = get_lang!(jar, data.0);
@ -63,6 +72,7 @@ pub async fn settings_request(
context.insert("profile", &profile);
context.insert("stacks", &stacks);
context.insert("blocks", &blocks);
context.insert("user_settings_serde", &clean_settings(&profile.settings));
context.insert(
"user_tokens_serde",

View file

@ -23,7 +23,7 @@ async-recursion = "1.1.1"
md-5 = "0.10.6"
base16ct = { version = "0.2.0", features = ["alloc"] }
redis = { version = "0.30.0", features = [
redis = { version = "0.31.0", features = [
"aio",
"tokio-comp",
], optional = true }

View file

@ -43,7 +43,7 @@ impl DataManager {
let mut out = Vec::new();
for member in members {
if ignore_users.contains(&member) {
if ignore_users.contains(member) {
continue;
}

View file

@ -60,7 +60,7 @@ impl DataManager {
let mut users: HashMap<usize, User> = HashMap::new();
for (i, message) in messages.iter().enumerate() {
let next_owner: usize = match messages.get(i + 1) {
Some(ref m) => m.owner,
Some(m) => m.owner,
None => 0,
};

View file

@ -171,10 +171,8 @@ impl DataManager {
let stack = self.get_stack_by_id(id).await?;
// check user permission
if user.id != stack.owner {
if !user.permissions.check(FinePermission::MANAGE_STACKS) {
return Err(Error::NotAllowed);
}
if user.id != stack.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) {
return Err(Error::NotAllowed);
}
// ...

View file

@ -25,6 +25,17 @@ impl DataManager {
auto_method!(get_userblock_by_id()@get_userblock_from_row -> "SELECT * FROM userblocks WHERE id = $1" --name="user block" --returns=UserBlock --cache-key-tmpl="atto.userblock:{}");
/// Fill a vector of user blocks with their receivers.
pub async fn fill_userblocks_receivers(&self, list: Vec<UserBlock>) -> Result<Vec<User>> {
let mut out = Vec::new();
for block in list {
out.push(self.get_user_by_id(block.receiver).await?);
}
Ok(out)
}
/// Get a user block by `initiator` and `receiver` (in that order).
pub async fn get_userblock_by_initiator_receiver(
&self,
@ -104,6 +115,28 @@ impl DataManager {
out
}
/// Get all user blocks created by the given `initiator`.
pub async fn get_userblocks_by_initiator(&self, initiator: usize) -> Vec<UserBlock> {
let conn = match self.connect().await {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let res = query_rows!(
&conn,
"SELECT * FROM userblocks WHERE initiator = $1",
&[&(initiator as i64)],
|x| { Self::get_userblock_from_row(x) }
);
if res.is_err() {
return Vec::new();
}
// return
res.unwrap()
}
/// Create a new user block in the database.
///
/// # Arguments

View file

@ -111,22 +111,21 @@ impl CustomEmoji {
let mut chars = input.chars();
while let Some(char) = chars.next() {
if char == '\\' && escape == false {
if char == '\\' && !escape {
escape = true;
continue;
} else if char == ':' && escape == false {
} else if char == ':' && !escape {
let mut community_id: String = String::new();
let mut accepting_community_id_chars: bool = true;
let mut emoji_name: String = String::new();
let mut char_count: u32 = 0; // if we're past the first 4 characters and we haven't hit a digit, stop accepting community id
while let Some(char) = chars.next() {
for (char_count, char) in (0_u32..).zip(chars.by_ref()) {
if (char == ':') | (char == ' ') {
in_emoji = false;
break;
}
if char.is_digit(10) && accepting_community_id_chars {
if char.is_ascii_digit() && accepting_community_id_chars {
community_id.push(char);
} else if char == '.' {
// the period closes the community id
@ -138,8 +137,6 @@ impl CustomEmoji {
if char_count >= 4 && community_id.is_empty() {
accepting_community_id_chars = false;
}
char_count += 1;
}
out.push((

View file

@ -7,6 +7,12 @@ pub const EPOCH_2024: u64 = 1704067200000;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Snowflake(String);
impl Default for Snowflake {
fn default() -> Self {
Self::new()
}
}
impl Snowflake {
pub fn builder() -> Builder {
Builder::new().epoch(UNIX_EPOCH + Duration::from_millis(EPOCH_2024))