Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Cordiale also covers:
`/connect`/`/disconnect`/`/reconnect`/`/quit`, `/ignore`, `/notify`,
`/hilight`, `/ame`/`/amsg`, `/alias`/`/unalias` with Cicchetto's alias
expansion, `/kb` with a default ban type of nick, host or user@host
chosen in Settings and kept on the device, `/beep`, the services
chosen in Settings and kept in your Grappa account, `/beep`, the services
shortcuts), `/np` for the radio,
and a bare `/umode` that opens the user-mode toggles; a bare `/topic` or
`/mode` shows the channel's topic or modes in the status bar.
Expand Down
34 changes: 32 additions & 2 deletions crates/cordiale-core/src/ban.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
//! Ban masks for `/kb` and `/kickban`: which form the user prefers and how
//! each one is built from what is known about the target.
//!
//! The choice is a device-local preference (`Settings::default_ban_type`):
//! Grappa has no account-level field for it yet. A mask is only ever built
//! The choice is an account setting kept by Grappa (`ban_mask_form`, see
//! `GrappaClient::fetch_ban_mask_form`). A mask is only ever built
//! in the exact form asked for; when a part it needs is missing there is
//! no mask, never a broader or different one.

Expand Down Expand Up @@ -57,6 +57,25 @@ impl BanType {
!matches!(self, BanType::Nick)
}

/// The value Grappa stores in `ban_mask_form`.
pub fn wire_name(self) -> &'static str {
match self {
BanType::Nick => "nick",
BanType::Host => "host",
BanType::UserHost => "user_host",
}
}

/// The form for a `ban_mask_form` value; `None` for anything else.
pub fn from_wire_name(name: &str) -> Option<Self> {
match name {
"nick" => Some(BanType::Nick),
"host" => Some(BanType::Host),
"user_host" => Some(BanType::UserHost),
_ => None,
}
}

/// The position in the Settings picker.
pub fn index(self) -> i32 {
match self {
Expand Down Expand Up @@ -111,6 +130,17 @@ fn mask_part_is_valid(value: &str) -> bool {
mod tests {
use super::*;

#[test]
fn wire_names_round_trip() {
for ban_type in [BanType::Nick, BanType::Host, BanType::UserHost] {
assert_eq!(
BanType::from_wire_name(ban_type.wire_name()),
Some(ban_type)
);
}
assert_eq!(BanType::from_wire_name("mask"), None);
}

#[test]
fn builds_each_form() {
assert_eq!(
Expand Down
137 changes: 137 additions & 0 deletions crates/cordiale-core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,59 @@ impl GrappaClient {
.await
}

/// `GET /me/settings/ban-mask-form` (protocol v38): the account's
/// default ban form. An absent or unknown value, and a server without
/// the endpoint (404), read as `host`, the server default.
pub async fn fetch_ban_mask_form(
&self,
token: &str,
) -> Result<crate::ban::BanType, GrappaClientError> {
match self
.fetch_setting(token, "ban-mask-form", "ban_mask_form")
.await
{
Ok(value) => Ok(value
.as_str()
.and_then(crate::ban::BanType::from_wire_name)
.unwrap_or_default()),
Err(err) if err.status() == Some(StatusCode::NOT_FOUND) => {
Ok(crate::ban::BanType::default())
}
Err(err) => Err(err),
}
}

/// `PUT /me/settings/ban-mask-form`: returns the form the server
/// stored, read from its response (the sent one if the response
/// carries none).
pub async fn set_ban_mask_form(
&self,
token: &str,
ban_type: crate::ban::BanType,
) -> Result<crate::ban::BanType, GrappaClientError> {
let url = format!("{}/me/settings/ban-mask-form", self.base_url);
let body = serde_json::json!({ "ban_mask_form": ban_type.wire_name() });
let response = self
.http
.put(url)
.bearer_auth(token)
.json(&body)
.send()
.await?
.error_for_status()?;
let stored = response
.json::<Value>()
.await
.ok()
.and_then(|body| {
body.get("ban_mask_form")
.and_then(Value::as_str)
.and_then(crate::ban::BanType::from_wire_name)
})
.unwrap_or(ban_type);
Ok(stored)
}

/// The text sent when the bouncer marks the user away (`None`: the
/// server's own).
pub async fn fetch_auto_away_reason(
Expand Down Expand Up @@ -4664,6 +4717,90 @@ mod tests {
.expect("dcc");
}

#[tokio::test]
async fn ban_mask_form_reads_and_saves_all_three_forms() {
use crate::ban::BanType;
for (wire, expected) in [
("nick", BanType::Nick),
("host", BanType::Host),
("user_host", BanType::UserHost),
] {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/me/settings/ban-mask-form"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "ban_mask_form": wire })),
)
.mount(&mock_server)
.await;
Mock::given(method("PUT"))
.and(path("/me/settings/ban-mask-form"))
.and(body_json(serde_json::json!({ "ban_mask_form": wire })))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "ban_mask_form": wire })),
)
.expect(1)
.mount(&mock_server)
.await;
let client = GrappaClient::new(mock_server.uri());
assert_eq!(
client.fetch_ban_mask_form("t").await.expect("get"),
expected
);
assert_eq!(
client.set_ban_mask_form("t", expected).await.expect("put"),
expected
);
}
}

#[tokio::test]
async fn ban_mask_form_falls_back_to_host_and_reports_failures() {
use crate::ban::BanType;
// Pre-38 server: no endpoint at all.
let old_server = MockServer::start().await;
let client = GrappaClient::new(old_server.uri());
assert_eq!(
client.fetch_ban_mask_form("t").await.expect("404 is host"),
BanType::Host
);
assert!(client.set_ban_mask_form("t", BanType::Nick).await.is_err());

// Absent or unknown value reads as host.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/me/settings/ban-mask-form"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let client = GrappaClient::new(server.uri());
assert_eq!(
client.fetch_ban_mask_form("t").await.expect("absent"),
BanType::Host
);

// Any other failure is an error, not a silent host.
let failing = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/me/settings/ban-mask-form"))
.respond_with(ResponseTemplate::new(500))
.mount(&failing)
.await;
Mock::given(method("PUT"))
.and(path("/me/settings/ban-mask-form"))
.respond_with(ResponseTemplate::new(422))
.mount(&failing)
.await;
let client = GrappaClient::new(failing.uri());
assert!(client.fetch_ban_mask_form("t").await.is_err());
assert!(client
.set_ban_mask_form("t", BanType::UserHost)
.await
.is_err());
}

#[tokio::test]
async fn admin_account_and_network_writes_use_their_contracts() {
// Built at run time: the test only checks the value is passed
Expand Down
19 changes: 6 additions & 13 deletions crates/cordiale-core/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::ban::BanType;
use crate::domain::{Profile, Server};
use crate::passkey_origin::{passkey_origin, OverrideCheck};
use crate::presence::PresencePref;
Expand Down Expand Up @@ -133,10 +132,6 @@ pub struct Settings {
/// shown above the compose box lives here.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub mute_since: BTreeMap<String, i64>,
/// Ban form used by `/kb` and `/kickban`. Kept on this device only:
/// Grappa has no account-level field for it.
#[serde(default)]
pub default_ban_type: BanType,
/// "Shrink videos before sending": the default for video uploads, kept on
/// this device only (never sent to Grappa). Off until the user turns it
/// on; the upload popup starts from it and never writes it back.
Expand Down Expand Up @@ -185,7 +180,6 @@ impl Default for Settings {
presence_pins: BTreeMap::new(),
presence_unsynced: BTreeSet::new(),
mute_since: BTreeMap::new(),
default_ban_type: BanType::default(),
shrink_videos: false,
}
}
Expand Down Expand Up @@ -1079,7 +1073,6 @@ mod tests {
presence_pins: BTreeMap::from([("libera #rust".to_string(), PresencePref::Hide)]),
presence_unsynced: BTreeSet::from(["libera #rust".to_string()]),
mute_since: BTreeMap::from([("libera #rust".to_string(), 1_700_000_000)]),
default_ban_type: BanType::UserHost,
shrink_videos: true,
};

Expand All @@ -1100,13 +1093,13 @@ mod tests {
}

#[test]
fn settings_without_a_ban_type_default_to_host() {
let decoded: Settings =
serde_json::from_str(r#"{"schema_version":1,"theme":"light"}"#).expect("deserialize");
assert_eq!(decoded.default_ban_type, BanType::Host);
fn settings_with_the_retired_ban_type_still_load() {
let decoded: Settings =
serde_json::from_str(r#"{"default_ban_type":"user_host"}"#).expect("deserialize");
assert_eq!(decoded.default_ban_type, BanType::UserHost);
serde_json::from_str(r#"{"default_ban_type":"user_host","theme":"dark"}"#)
.expect("deserialize");
assert_eq!(decoded.theme, Theme::Dark);
let json = serde_json::to_value(&decoded).expect("serialize");
assert!(json.get("default_ban_type").is_none());
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions crates/cordiale-ui/lang/de/LC_MESSAGES/cordiale-ui.po
Original file line number Diff line number Diff line change
Expand Up @@ -1229,8 +1229,8 @@ msgid "User and host (*!user@host)"
msgstr "Benutzer und Host (*!benutzer@host)"

msgctxt "AppWindow"
msgid "Used by /kb and /kickban. Saved on this device only."
msgstr "Wird von /kb und /kickban verwendet. Nur auf diesem Gerät gespeichert."
msgid "Used by /kb and /kickban. Saved to your account."
msgstr "Wird von /kb und /kickban verwendet. In deinem Konto gespeichert."

msgctxt "AppWindow"
msgid "Done."
Expand Down
4 changes: 2 additions & 2 deletions crates/cordiale-ui/lang/es/LC_MESSAGES/cordiale-ui.po
Original file line number Diff line number Diff line change
Expand Up @@ -1229,8 +1229,8 @@ msgid "User and host (*!user@host)"
msgstr "Usuario y host (*!usuario@host)"

msgctxt "AppWindow"
msgid "Used by /kb and /kickban. Saved on this device only."
msgstr "Lo usan /kb y /kickban. Se guarda solo en este dispositivo."
msgid "Used by /kb and /kickban. Saved to your account."
msgstr "Lo usan /kb y /kickban. Se guarda en tu cuenta."

msgctxt "AppWindow"
msgid "Done."
Expand Down
4 changes: 2 additions & 2 deletions crates/cordiale-ui/lang/fr/LC_MESSAGES/cordiale-ui.po
Original file line number Diff line number Diff line change
Expand Up @@ -1229,8 +1229,8 @@ msgid "User and host (*!user@host)"
msgstr "Utilisateur et hôte (*!utilisateur@hôte)"

msgctxt "AppWindow"
msgid "Used by /kb and /kickban. Saved on this device only."
msgstr "Utilisé par /kb et /kickban. Enregistré uniquement sur cet appareil."
msgid "Used by /kb and /kickban. Saved to your account."
msgstr "Utilisé par /kb et /kickban. Enregistré dans votre compte."

msgctxt "AppWindow"
msgid "Done."
Expand Down
4 changes: 2 additions & 2 deletions crates/cordiale-ui/lang/it/LC_MESSAGES/cordiale-ui.po
Original file line number Diff line number Diff line change
Expand Up @@ -1229,8 +1229,8 @@ msgid "User and host (*!user@host)"
msgstr "Utente e host (*!utente@host)"

msgctxt "AppWindow"
msgid "Used by /kb and /kickban. Saved on this device only."
msgstr "Usato da /kb e /kickban. Salvato solo su questo dispositivo."
msgid "Used by /kb and /kickban. Saved to your account."
msgstr "Usato da /kb e /kickban. Salvato nel tuo account."

msgctxt "AppWindow"
msgid "Done."
Expand Down
Loading
Loading