Initial version

This commit is contained in:
Cameron Cross 2026-03-03 18:51:35 +11:00
commit fb89412448
6 changed files with 3009 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
config.toml

2722
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "banPropagator"
version = "0.1.0"
edition = "2024"
[dependencies]
toml = "1.0.3+spec-1.1.0"
lemmy-client = "1.0.5"
serde = { version = "1.0.228", features = ["derive"] }
rpassword = "7.4.0"
anyhow = "1.0.102"
tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros"] }
reqwest = "0.12.24"

29
README.md Normal file
View file

@ -0,0 +1,29 @@
Propagate Lemmy Bans
====================
`config.toml`:
```toml
instance = "instance.com"
admin_name = "Admin"
exclusions = ["admin1@instance.com", "admin2@instance.com"]
admin_password = "[ADMIN_PASSWORD]"
[[lemmy_modlog_instances]]
instance = "lemmy.blahaj.zone"
[[lemmy_modlog_instances]]
instance = "piefed.blahaj.zone"
[[lemmy_modlog_instances]]
instance = "lemmy.dbzer0.com"
```
Gets a list of the 10 most recent PurgeUser events from the Lemmy instances.
Bans those users locally. Repeats every 60 seconds.
Could do with a lot of work and features, but this is a straw-man for further development.
# Features missing:
- Piefed support
- Kbin/Mbin/other support

34
src/config.rs Normal file
View file

@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Deserialize, Serialize)]
pub(crate) struct LemmyModlogInstance {
pub(crate) instance: String,
}
#[derive(Deserialize, Serialize)]
pub(crate) struct Config {
/// The instance to ban users from
pub(crate) instance: String,
/// The admin user to use
pub(crate) admin_name: String,
/// The admin password to use, if not provided, will be prompted for
pub(crate) admin_password: Option<String>,
/// Any users to exclude from banning
pub(crate) exclusions: Vec<String>,
/// The lemmy instances to get modlogs from
pub(crate) lemmy_modlog_instances: Vec<LemmyModlogInstance>,
}
impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let file_contents = std::fs::read_to_string(path)?;
let mut config: Config = toml::from_str(&file_contents)?;
if config.admin_password.is_none() {
let password = rpassword::prompt_password("Enter your password: ")?;
config.admin_password = Some(password);
}
Ok(config)
}
}

209
src/main.rs Normal file
View file

@ -0,0 +1,209 @@
mod config;
use crate::config::Config;
use anyhow::{Context, bail};
use lemmy_client::lemmy_api_common::lemmy_db_schema::ModlogActionType;
use lemmy_client::lemmy_api_common::lemmy_db_schema::newtypes::{InstanceId, PersonId};
use lemmy_client::lemmy_api_common::lemmy_db_schema::sensitive::SensitiveString;
use lemmy_client::lemmy_api_common::person::{BanPerson, GetPersonDetails, Login};
use lemmy_client::lemmy_api_common::site::{
FederatedInstances, GetFederatedInstancesResponse, GetModlog, PurgePerson,
};
use lemmy_client::{ClientOptions, LemmyClient, LemmyRequest, lemmy_api_common};
use reqwest;
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::path::Path;
use std::time::Duration;
use tokio::time::sleep;
/// Bans a user from a given instance
async fn ban_user(id: i32, client: &LemmyClient, token: &SensitiveString) {
client
.ban_from_site(LemmyRequest {
body: BanPerson {
person_id: PersonId(id),
ban: true,
remove_data: Some(false),
reason: Some(String::from("Propagated ban")),
expires: None,
},
jwt: Some(token.to_string()),
})
.await
.unwrap();
}
/// Gets the modlog for a given instance
async fn get_modlog(url: &str) -> anyhow::Result<Vec<(i32, String)>> {
let client = LemmyClient::new(ClientOptions {
domain: String::from(url),
secure: true,
});
let modlog_result = client
.get_modlog(GetModlog {
mod_person_id: None,
community_id: None,
page: None,
limit: Some(10),
type_: Some(ModlogActionType::AdminPurgePerson),
other_person_id: None,
post_id: None,
comment_id: None,
})
.await;
let modlog = match modlog_result {
Ok(modlog) => modlog,
Err(e) => {
bail!("Getting modlog failed: {:?}", e);
}
};
let purged_people = modlog
.admin_purged_persons
.iter()
.map(|appv| {
let reason = appv
.admin_purge_person
.reason
.clone()
.unwrap_or_else(|| String::from("No reason provided"));
let userid = appv.admin_purge_person.id;
(userid, reason)
})
.collect::<Vec<(i32, String)>>();
Ok(purged_people)
}
async fn get_all_instances(
instance_domain: &str,
token: &SensitiveString,
) -> anyhow::Result<HashMap<i32, String>> {
let mut instances: HashMap<i32, String> = HashMap::new();
let response = reqwest::get(format!(
"https://{}/api/v3/federated_instances",
instance_domain
))
.await?;
let response = response.json::<GetFederatedInstancesResponse>().await?;
if let Some(federated_instances) = response.federated_instances {
federated_instances.allowed.iter().for_each(|i| {
let id = i.instance.id;
let name = i.instance.domain.clone();
instances.insert(id.0, name);
});
federated_instances.blocked.iter().for_each(|i| {
let id = i.instance.id;
let name = i.instance.domain.clone();
instances.insert(id.0, name);
});
federated_instances.linked.iter().for_each(|i| {
let id = i.instance.id;
let name = i.instance.domain.clone();
instances.insert(id.0, name);
});
} else {
bail!("No federated instances found");
}
Ok(instances)
}
async fn get_username(
user_id: i32,
client: &LemmyClient,
token: &SensitiveString,
instances: &HashMap<i32, String>,
) -> anyhow::Result<String> {
let response = match client
.get_person(GetPersonDetails {
person_id: Some(PersonId(user_id)),
username: None,
sort: None,
page: None,
limit: None,
community_id: None,
saved_only: None,
})
.await
{
Ok(response) => response,
Err(e) => {
bail!("Failed to get person details for user ID: {user_id} - {e}");
}
};
let username = response.person_view.person.name;
let instance_id = response.person_view.person.instance_id;
let Some(instance) = instances.get(&instance_id.0) else {
bail!("No instance found for instanceid ID: {}", instance_id.0);
};
println!(
"User ID {} belongs to username: {}@{}",
user_id, username, instance
);
Ok(format!("{}@{}", username, instance))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Config::load(Path::new("config.toml"))?;
let client = LemmyClient::new(ClientOptions {
domain: String::from(&config.instance),
secure: true,
});
let Some(admin_password) = &config.admin_password else {
bail!("No admin_password provided")
};
let auth = client
.login(Login {
username_or_email: SensitiveString::from(config.admin_name),
password: SensitiveString::from(admin_password.clone()),
totp_2fa_token: None,
})
.await
.unwrap();
let Some(token) = auth.jwt else {
bail!("No JWT returned from login")
};
let instances = get_all_instances(&config.instance, &token).await?;
let mut previously_banned: HashSet<i32> = HashSet::new();
loop {
for instance in &config.lemmy_modlog_instances {
println!("Checking modlog for instance: {}", instance.instance);
let Ok(modlog) = get_modlog(&instance.instance).await else {
println!("Failed to get modlog for instance: {}", instance.instance);
continue;
};
for (userid, reason) in modlog {
if previously_banned.contains(&userid) {
continue;
}
let username = match get_username(userid, &client, &token, &instances).await {
Ok(u) => u,
Err(e) => {
println!("Error: {e}");
previously_banned.insert(userid);
continue;
}
};
println!("{}: {}", userid, reason);
if !config.exclusions.contains(&username) {
// println!("Propagating Ban for User: {}", username);
// ban_user(userid, &client, &token).await;
}
previously_banned.insert(userid);
}
}
sleep(Duration::from_secs(60)).await;
}
}