pleroma groups!!!!!! try it -> https://piggo.space/hob
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
group-actor/src/store/group_config.rs

732 lines
23 KiB

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use elefren::AppData;
use crate::error::GroupError;
use crate::store::{DEFAULT_LOCALE_NAME, CommonConfig};
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<String>,
/// List of admin account "acct" names, e.g. piggo@piggo.space
admin_users: HashSet<String>,
/// List of users allowed to post to the group, if it is member-only
member_users: HashSet<String>,
/// List of users banned from posting to the group
banned_users: HashSet<String>,
/// Users who decided they don't want to be shared to the group (does not apply to members)
optout_users: HashSet<String>,
/// True if only members should be allowed to write
member_only: bool,
/// Banned domain names, e.g. kiwifarms.cc
banned_servers: HashSet<String>,
#[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<bool, GroupError> {
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<Path>) -> Result<MutableConfig, GroupError> {
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<Path>) -> Result<StateConfig, GroupError> {
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<Path>) -> Result<Option<TranslationTable>, 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<Self, GroupError> {
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<Item = &String> {
self.control.admin_users.iter()
}
pub(crate) fn get_members(&self) -> impl Iterator<Item = &String> {
self.control.member_users.iter()
}
pub(crate) fn get_tags(&self) -> impl Iterator<Item = &String> {
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) -> &str {
acct.split('@').nth(1).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", acct_to_server("raichu@pikachu.rocks"));
assert_eq!("pikachu.rocks", acct_to_server("m@pikachu.rocks"));
assert_eq!("", 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"), "still member even if banned");
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"), "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"
);
}
}