use std::collections::HashSet; use std::path::{Path, PathBuf}; use elefren::AppData; use crate::error::GroupError; use crate::store::{CommonConfig, DEFAULT_LOCALE_NAME}; use crate::tr::TranslationTable; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] struct FixedConfig { enabled: bool, /// Group actor's acct acct: String, /// elefren data appdata: AppData, /// configured locale to use locale: String, /// Server's character limit character_limit: usize, #[serde(skip)] _dirty: bool, #[serde(skip)] _path: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] struct MutableConfig { /// Hashtags the group will auto-boost from it's members group_tags: HashSet, /// List of admin account "acct" names, e.g. piggo@piggo.space admin_users: HashSet, /// List of users allowed to post to the group, if it is member-only member_users: HashSet, /// List of users banned from posting to the group banned_users: HashSet, /// Users who decided they don't want to be shared to the group (does not apply to members) optout_users: HashSet, /// True if only members should be allowed to write member_only: bool, /// Banned domain names, e.g. kiwifarms.cc banned_servers: HashSet, #[serde(skip)] _dirty: bool, #[serde(skip)] _path: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] struct StateConfig { /// Last seen notification timestamp (millis) last_notif_ts: u64, /// Last seen status timestamp (millis) last_status_ts: u64, #[serde(skip)] _dirty: bool, #[serde(skip)] _path: PathBuf, } /// This is the inner data struct holding a group's config #[derive(Debug, Clone)] pub struct GroupConfig { /// Fixed config that we only read config: FixedConfig, /// Mutable config we can write control: MutableConfig, /// State config with timestamps and transient data that is changed frequently state: StateConfig, /// Group-specific translation table; this is a clone of the global table with group-specific overrides applied. _group_tr: TranslationTable, } impl Default for FixedConfig { fn default() -> Self { Self { enabled: true, acct: "".to_string(), locale: DEFAULT_LOCALE_NAME.to_string(), appdata: AppData { base: Default::default(), client_id: Default::default(), client_secret: Default::default(), redirect: Default::default(), token: Default::default(), }, character_limit: 5000, _dirty: false, _path: PathBuf::default(), } } } impl Default for MutableConfig { fn default() -> Self { Self { group_tags: Default::default(), admin_users: Default::default(), member_users: Default::default(), banned_users: Default::default(), optout_users: Default::default(), member_only: false, banned_servers: Default::default(), _dirty: false, _path: PathBuf::default(), } } } impl Default for StateConfig { fn default() -> Self { Self { last_notif_ts: 0, last_status_ts: 0, _dirty: false, _path: PathBuf::default(), } } } macro_rules! impl_change_tracking { ($struc:ident) => { impl $struc { pub(crate) fn mark_dirty(&mut self) { self._dirty = true; } pub(crate) fn is_dirty(&self) -> bool { self._dirty } pub(crate) fn clear_dirty_status(&mut self) { self._dirty = false; } pub(crate) async fn save_if_needed(&mut self) -> Result { if self.is_dirty() { self.save().await?; Ok(true) } else { Ok(false) } } pub(crate) async fn save(&mut self) -> Result<(), GroupError> { tokio::fs::write(&self._path, serde_json::to_string_pretty(&self)?.as_bytes()).await?; self.clear_dirty_status(); Ok(()) } } }; } impl_change_tracking!(FixedConfig); impl_change_tracking!(MutableConfig); impl_change_tracking!(StateConfig); async fn load_or_create_control_file(control_path: impl AsRef) -> Result { let control_path = control_path.as_ref(); let mut dirty = false; let mut control: MutableConfig = if control_path.is_file() { let f = tokio::fs::read(&control_path).await?; let mut control: MutableConfig = json5::from_str(&String::from_utf8_lossy(&f))?; control._path = control_path.to_owned(); control } else { debug!("control file missing, creating empty"); dirty = true; MutableConfig { _path: control_path.to_owned(), ..Default::default() } }; if dirty { control.save().await?; } Ok(control) } async fn load_or_create_state_file(state_path: impl AsRef) -> Result { let state_path = state_path.as_ref(); let mut dirty = false; let mut state: StateConfig = if state_path.is_file() { let f = tokio::fs::read(&state_path).await?; let mut control: StateConfig = json5::from_str(&String::from_utf8_lossy(&f))?; control._path = state_path.to_owned(); control } else { debug!("state file missing, creating empty"); dirty = true; StateConfig { _path: state_path.to_owned(), ..Default::default() } }; if dirty { state.save().await?; } Ok(state) } async fn load_locale_override_file(locale_path: impl AsRef) -> Result, GroupError> { let locale_path = locale_path.as_ref(); if locale_path.is_file() { let f = tokio::fs::read(&locale_path).await?; let opt: TranslationTable = json5::from_str(&String::from_utf8_lossy(&f))?; Ok(Some(opt)) } else { Ok(None) } } impl GroupConfig { pub fn tr(&self) -> &TranslationTable { &self._group_tr } pub(crate) fn is_dirty(&self) -> bool { self.config.is_dirty() || self.control.is_dirty() || self.state.is_dirty() } /// Save only what changed pub(crate) async fn save_if_needed(&mut self, danger_allow_overwriting_config: bool) -> Result<(), GroupError> { #[allow(clippy::collapsible_if)] if danger_allow_overwriting_config { if self.config.save_if_needed().await? { debug!( "Written {} config file {}", self.config.acct, self.config._path.display() ); } } if self.control.save_if_needed().await? { debug!( "Written {} control file {}", self.config.acct, self.control._path.display() ); } if self.state.save_if_needed().await? { debug!("Written {} state file {}", self.config.acct, self.state._path.display()); } Ok(()) } /// Save all unconditionally #[allow(unused)] pub(crate) async fn save(&mut self, danger_allow_overwriting_config: bool) -> Result<(), GroupError> { if danger_allow_overwriting_config { self.config.save().await?; } self.control.save().await?; self.state.save().await?; Ok(()) } /// (re)init using new authorization pub(crate) async fn initialize_by_appdata( acct: String, appdata: AppData, group_dir: PathBuf, ) -> Result<(), GroupError> { if !group_dir.is_dir() { debug!("Creating group directory"); tokio::fs::create_dir_all(&group_dir).await?; } let config_path = group_dir.join("config.json"); let control_path = group_dir.join("control.json"); let state_path = group_dir.join("state.json"); // try to reuse content of the files, if present /* config */ let mut dirty = false; let mut config: FixedConfig = if config_path.is_file() { let f = tokio::fs::read(&config_path).await?; let mut config: FixedConfig = json5::from_str(&String::from_utf8_lossy(&f))?; config._path = config_path; if config.appdata != appdata { config.appdata = appdata; dirty = true; } if config.acct != acct { config.acct = acct.clone(); dirty = true; } config } else { dirty = true; FixedConfig { acct: acct.clone(), appdata, _path: config_path, ..Default::default() } }; if dirty { debug!("config file for {} changed, creating/updating", acct); config.save().await?; } /* control */ let control = load_or_create_control_file(control_path).await?; /* state */ let state = load_or_create_state_file(state_path).await?; let g = GroupConfig { config, control, state, _group_tr: TranslationTable::new(), }; g.warn_of_bad_config(); Ok(()) } pub(crate) async fn from_dir(group_dir: PathBuf, cc: &CommonConfig) -> Result { let config_path = group_dir.join("config.json"); let control_path = group_dir.join("control.json"); let state_path = group_dir.join("state.json"); let locale_path = group_dir.join("messages.json"); // try to reuse content of the files, if present /* config */ let f = tokio::fs::read(&config_path).await?; let mut config: FixedConfig = json5::from_str(&String::from_utf8_lossy(&f))?; config._path = config_path; /* control */ let control = load_or_create_control_file(control_path).await?; /* state */ let state = load_or_create_state_file(state_path).await?; /* translation table */ let mut tr = cc.tr(&config.locale).clone(); if let Some(locale_overrides) = load_locale_override_file(locale_path).await? { for (k, v) in locale_overrides.entries() { tr.add_translation(k, v); } } let g = GroupConfig { config, control, state, _group_tr: tr, }; g.warn_of_bad_config(); Ok(g) } fn warn_of_bad_config(&self) { for t in &self.control.group_tags { if &t.to_lowercase() != t { warn!( "Group {} hashtag \"{}\" is not lowercase, it won't work!", self.config.acct, t ); } } for u in self .control .admin_users .iter() .chain(self.control.member_users.iter()) .chain(self.control.banned_users.iter()) .chain(self.control.optout_users.iter()) { if &u.to_lowercase() != u { warn!( "Group {} config contains a user with non-lowercase name \"{}\", it won't work!", self.config.acct, u ); } if u.starts_with('@') || u.chars().filter(|c| *c == '@').count() != 1 { warn!("Group {} config contains an invalid user name: {}", self.config.acct, u); } } } pub(crate) fn get_character_limit(&self) -> usize { self.config.character_limit } pub(crate) fn is_enabled(&self) -> bool { self.config.enabled } pub(crate) fn get_appdata(&self) -> &AppData { &self.config.appdata } pub(crate) fn set_appdata(&mut self, appdata: AppData) { if self.config.appdata != appdata { self.config.mark_dirty(); } self.config.appdata = appdata; } pub(crate) fn get_admins(&self) -> impl Iterator { self.control.admin_users.iter() } pub(crate) fn get_members(&self) -> impl Iterator { self.control.member_users.iter() } pub(crate) fn get_tags(&self) -> impl Iterator { self.control.group_tags.iter() } pub(crate) fn set_last_notif(&mut self, ts: u64) { if self.state.last_notif_ts != ts { self.state.mark_dirty(); } self.state.last_notif_ts = self.state.last_notif_ts.max(ts); } pub(crate) fn get_last_notif(&self) -> u64 { self.state.last_notif_ts } pub(crate) fn set_last_status(&mut self, ts: u64) { if self.state.last_status_ts != ts { self.state.mark_dirty(); } self.state.last_status_ts = self.state.last_status_ts.max(ts); } pub(crate) fn get_last_status(&self) -> u64 { self.state.last_status_ts } pub(crate) fn get_acct(&self) -> &str { &self.config.acct } pub(crate) fn is_optout(&self, acct: &str) -> bool { self.control.optout_users.contains(acct) } pub(crate) fn is_admin(&self, acct: &str) -> bool { self.control.admin_users.contains(acct) } pub(crate) fn is_member(&self, acct: &str) -> bool { self.control.member_users.contains(acct) } pub(crate) fn is_member_or_admin(&self, acct: &str) -> bool { self.is_member(acct) || self.is_admin(acct) } pub(crate) fn is_banned(&self, acct: &str) -> bool { self.control.banned_users.contains(acct) || self.is_users_server_banned(acct) } pub(crate) fn is_server_banned(&self, server: &str) -> bool { self.control.banned_servers.contains(server) } /// Check if the user's server is banned fn is_users_server_banned(&self, acct: &str) -> bool { let server = acct_to_server(acct); self.is_server_banned(&server) } pub(crate) fn can_write(&self, acct: &str) -> bool { if self.is_admin(acct) { true } else { !self.is_banned(acct) && (!self.is_member_only() || self.is_member(acct)) } } pub(crate) fn set_admin(&mut self, acct: &str, admin: bool) -> Result<(), GroupError> { let change = if admin { if self.is_banned(acct) { return Err(GroupError::UserIsBanned); } self.control.admin_users.insert(acct.to_owned()) } else { self.control.admin_users.remove(acct) }; if change { self.control.mark_dirty(); } Ok(()) } pub(crate) fn set_member(&mut self, acct: &str, member: bool) -> Result<(), GroupError> { let change = if member { if self.is_banned(acct) { return Err(GroupError::UserIsBanned); } self.control.member_users.insert(acct.to_owned()) } else { self.control.member_users.remove(acct) }; if change { self.control.mark_dirty(); } Ok(()) } pub(crate) fn set_optout(&mut self, acct: &str, optout: bool) { let change = if optout { self.control.optout_users.insert(acct.to_owned()) } else { self.control.optout_users.remove(acct) }; if change { self.control.mark_dirty(); } } pub(crate) fn ban_user(&mut self, acct: &str, ban: bool) -> Result<(), GroupError> { let mut change = false; if ban { if self.is_admin(acct) { return Err(GroupError::UserIsAdmin); } // Banned user is also kicked change |= self.control.member_users.remove(acct); change |= self.control.banned_users.insert(acct.to_owned()); } else { change |= self.control.banned_users.remove(acct); } if change { self.control.mark_dirty(); } Ok(()) } pub(crate) fn ban_server(&mut self, server: &str, ban: bool) -> Result<(), GroupError> { let changed = if ban { for acct in &self.control.admin_users { let acct_server = acct_to_server(acct); if acct_server == server { return Err(GroupError::AdminsOnServer); } } self.control.banned_servers.insert(server.to_owned()) } else { self.control.banned_servers.remove(server) }; if changed { self.control.mark_dirty(); } Ok(()) } pub(crate) fn add_tag(&mut self, tag: &str) { if self.control.group_tags.insert(tag.to_string()) { self.control.mark_dirty(); } } pub(crate) fn remove_tag(&mut self, tag: &str) { if self.control.group_tags.remove(tag) { self.control.mark_dirty(); } } pub(crate) fn is_tag_followed(&self, tag: &str) -> bool { self.control.group_tags.contains(tag) } pub(crate) fn set_member_only(&mut self, member_only: bool) { if self.control.member_only != member_only { self.control.mark_dirty(); } self.control.member_only = member_only; } pub(crate) fn is_member_only(&self) -> bool { self.control.member_only } } fn acct_to_server(acct: &str) -> String { crate::utils::acct_to_server(acct).unwrap_or_default() } #[cfg(test)] mod tests { use crate::error::GroupError; use crate::store::group_config::{acct_to_server, GroupConfig}; fn empty_group_config() -> GroupConfig { GroupConfig { config: Default::default(), control: Default::default(), state: Default::default(), _group_tr: Default::default(), } } #[test] fn test_acct_to_server() { assert_eq!("pikachu.rocks".to_string(), acct_to_server("raichu@pikachu.rocks")); assert_eq!("pikachu.rocks".to_string(), acct_to_server("m@pikachu.rocks")); assert_eq!("".to_string(), acct_to_server("what")); } #[test] fn test_default_rules() { let group = empty_group_config(); assert!(!group.is_member_only()); assert!(!group.is_member("piggo@piggo.space")); assert!(!group.is_admin("piggo@piggo.space")); assert!(group.can_write("piggo@piggo.space"), "anyone can post by default"); } #[test] fn test_member_only() { let mut group = empty_group_config(); assert!(group.can_write("piggo@piggo.space"), "rando can write in public group"); group.set_member_only(true); assert!( !group.can_write("piggo@piggo.space"), "rando can't write in member-only group" ); // Admin in member only group.set_admin("piggo@piggo.space", true).unwrap(); assert!( group.can_write("piggo@piggo.space"), "admin non-member can write in member-only group" ); group.set_admin("piggo@piggo.space", false).unwrap(); assert!( !group.can_write("piggo@piggo.space"), "removed admin removes privileged write access" ); // Member in member only group.set_member("piggo@piggo.space", true).unwrap(); assert!( group.can_write("piggo@piggo.space"), "member can post in member-only group" ); group.set_admin("piggo@piggo.space", true).unwrap(); assert!( group.can_write("piggo@piggo.space"), "member+admin can post in member-only group" ); } #[test] fn test_banned_users() { // Banning single user let mut group = empty_group_config(); group.ban_user("piggo@piggo.space", true).unwrap(); assert!(!group.can_write("piggo@piggo.space"), "banned user can't post"); group.ban_user("piggo@piggo.space", false).unwrap(); assert!(group.can_write("piggo@piggo.space"), "un-ban works"); } #[test] fn test_banned_members() { // Banning single user let mut group = empty_group_config(); group.set_member_only(true); group.set_member("piggo@piggo.space", true).unwrap(); assert!(group.can_write("piggo@piggo.space"), "member can write"); assert!(group.is_member("piggo@piggo.space"), "member is member"); assert!(!group.is_banned("piggo@piggo.space"), "user not banned by default"); group.ban_user("piggo@piggo.space", true).unwrap(); assert!(!group.is_member("piggo@piggo.space"), "banned user is kicked"); assert!(group.is_banned("piggo@piggo.space"), "banned user is banned"); assert!(!group.can_write("piggo@piggo.space"), "banned member can't post"); // unban group.ban_user("piggo@piggo.space", false).unwrap(); assert!(!group.can_write("piggo@piggo.space"), "unbanned member is still kicked"); group.set_member("piggo@piggo.space", true).unwrap(); assert!(group.can_write("piggo@piggo.space"), "un-ban works"); } #[test] fn test_server_ban() { let mut group = empty_group_config(); assert!(group.can_write("hitler@nazi.camp"), "randos can write"); group.ban_server("nazi.camp", true).unwrap(); assert!( !group.can_write("hitler@nazi.camp"), "users from banned server can't write" ); assert!( !group.can_write("1488@nazi.camp"), "users from banned server can't write" ); assert!(group.can_write("troll@freezepeach.xyz"), "other users can still write"); group.ban_server("nazi.camp", false).unwrap(); assert!(group.can_write("hitler@nazi.camp"), "server unban works"); } #[test] fn test_sanity() { let mut group = empty_group_config(); group.set_admin("piggo@piggo.space", true).unwrap(); assert_eq!( Err(GroupError::UserIsAdmin), group.ban_user("piggo@piggo.space", true), "can't bad admin users" ); group.ban_user("piggo@piggo.space", false).expect("can unbad admin"); group.ban_user("hitler@nazi.camp", true).unwrap(); assert_eq!( Err(GroupError::UserIsBanned), group.set_admin("hitler@nazi.camp", true), "can't make banned users admins" ); group.ban_server("freespeechextremist.com", true).unwrap(); assert_eq!( Err(GroupError::UserIsBanned), group.set_admin("nibber@freespeechextremist.com", true), "can't make server-banned users admins" ); assert!(group.is_admin("piggo@piggo.space")); assert_eq!( Err(GroupError::AdminsOnServer), group.ban_server("piggo.space", true), "can't bad server with admins" ); } }