Adding Modlog RSS feed. (#5708)

* Adding Modlog RSS feed.

- Fixes #3179

* Addressing PR comments

* Fixing clippy.

* Fixing markdown test.

* Creating common format_actor_url function.

* Clippy

* Adding boolean strings for remove/restore

* Addressing PR comments
This commit is contained in:
Dessalines 2025-06-04 10:18:53 -04:00 committed by GitHub
parent ced74b40cc
commit 079ca69312
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 413 additions and 74 deletions

1
Cargo.lock generated
View file

@ -3860,6 +3860,7 @@ dependencies = [
"lemmy_db_schema_file",
"lemmy_db_views_inbox_combined",
"lemmy_db_views_local_user",
"lemmy_db_views_modlog_combined",
"lemmy_db_views_person_content_combined",
"lemmy_db_views_post",
"lemmy_db_views_site",

View file

@ -82,7 +82,7 @@ pub async fn create_community(
check_community_visibility_allowed(data.visibility, &local_user_view)?;
// Double check for duplicate community actor_ids
let community_ap_id = Community::local_url(&data.name, context.settings())?;
let community_ap_id = Community::generate_local_actor_url(&data.name, context.settings())?;
let community_dupe = Community::read_from_apub_id(&mut context.pool(), &community_ap_id).await?;
if community_dupe.is_some() {
Err(LemmyErrorType::CommunityAlreadyExists)?

View file

@ -29,7 +29,7 @@ use lemmy_db_schema::{
person::{Person, PersonInsertForm},
registration_application::{RegistrationApplication, RegistrationApplicationInsertForm},
},
traits::Crud,
traits::{ApubActor, Crud},
utils::get_conn,
};
use lemmy_db_schema_file::enums::RegistrationMode;
@ -443,7 +443,7 @@ async fn create_person(
conn: &mut AsyncPgConnection,
) -> Result<Person, LemmyError> {
is_valid_actor_name(&username, site_view.local_site.actor_name_max_length)?;
let ap_id = Person::local_url(&username, context.settings())?;
let ap_id = Person::generate_local_actor_url(&username, context.settings())?;
// Register the new person
let person_form = PersonInsertForm {

View file

@ -1,11 +1,8 @@
use crate::objects::{PostOrComment, SearchableObjects, UserOrCommunity};
use activitypub_federation::{config::Data, fetch::object_id::ObjectId};
use lemmy_api_common::context::LemmyContext;
use lemmy_db_schema::{newtypes::InstanceId, source::instance::Instance};
use lemmy_utils::{
error::LemmyResult,
utils::markdown::image_links::{markdown_find_links, markdown_handle_title},
};
use lemmy_db_schema::traits::ApubActor;
use lemmy_utils::utils::markdown::image_links::{markdown_find_links, markdown_handle_title};
use url::Url;
pub async fn markdown_rewrite_remote_links_opt(
@ -51,7 +48,8 @@ pub async fn markdown_rewrite_remote_links(
pub(crate) async fn to_local_url(url: &str, context: &Data<LemmyContext>) -> Option<Url> {
let local_domain = &context.settings().get_protocol_and_hostname();
let object_id = ObjectId::<SearchableObjects>::parse(url).ok()?;
if object_id.inner().domain() == Some(local_domain) {
let object_domain = object_id.inner().domain();
if object_domain == Some(local_domain) {
return None;
}
let dereferenced = object_id.dereference_local(context).await.ok()?;
@ -63,49 +61,27 @@ pub(crate) async fn to_local_url(url: &str, context: &Data<LemmyContext>) -> Opt
.ok()
.map(Into::into),
SearchableObjects::Right(pc) => match pc {
UserOrCommunity::Left(user) => {
format_actor_url(&user.name, "u", user.instance_id, context).await
}
UserOrCommunity::Right(community) => {
format_actor_url(&community.name, "c", community.instance_id, context).await
}
UserOrCommunity::Left(user) => user.actor_url(context.settings()),
UserOrCommunity::Right(community) => community.actor_url(context.settings()),
}
.ok(),
}
}
async fn format_actor_url(
name: &str,
kind: &str,
instance_id: InstanceId,
context: &LemmyContext,
) -> LemmyResult<Url> {
let local_protocol_and_hostname = context.settings().get_protocol_and_hostname();
let local_hostname = &context.settings().hostname;
let instance = Instance::read(&mut context.pool(), instance_id).await?;
let url = if &instance.domain != local_hostname {
format!(
"{local_protocol_and_hostname}/{kind}/{name}@{}",
instance.domain
)
} else {
format!("{local_protocol_and_hostname}/{kind}/{name}")
};
Ok(Url::parse(&url)?)
}
#[cfg(test)]
mod tests {
use super::*;
use lemmy_db_schema::{
source::{
community::{Community, CommunityInsertForm},
instance::Instance,
post::{Post, PostInsertForm},
},
traits::Crud,
};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_site::impls::create_test_instance;
use lemmy_utils::error::LemmyResult;
use pretty_assertions::assert_eq;
use serial_test::serial;
@ -114,16 +90,16 @@ mod tests {
async fn test_markdown_rewrite_remote_links() -> LemmyResult<()> {
let context = LemmyContext::init_test_context().await;
let instance = create_test_instance(&mut context.pool()).await?;
let community = Community::create(
&mut context.pool(),
&CommunityInsertForm::new(
let community_form = CommunityInsertForm {
ap_id: Some(Url::parse("https://example.com/c/my_community")?.into()),
..CommunityInsertForm::new(
instance.id,
"my_community".to_string(),
"My Community".to_string(),
"pubkey".to_string(),
),
)
.await?;
)
};
let community = Community::create(&mut context.pool(), &community_form).await?;
let user =
LocalUserView::create_test_user(&mut context.pool(), "garda", "garda bio", false).await?;

View file

@ -17,6 +17,7 @@ use crate::{
},
traits::{ApubActor, Bannable, Blockable, Crud, Followable, Joinable},
utils::{
format_actor_url,
functions::{coalesce, coalesce_2_nullable, lower, random_smallint},
get_conn,
uplete,
@ -277,11 +278,6 @@ impl Community {
Ok(Url::parse(&format!("{}/tag/{}", self.ap_id, &id_slug))?.into())
}
pub fn local_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {
let domain = settings.get_protocol_and_hostname();
Ok(Url::parse(&format!("{domain}/c/{name}"))?.into())
}
pub async fn update_federated_followers(
pool: &mut DbPool<'_>,
for_community_id: CommunityId,
@ -651,6 +647,21 @@ impl ApubActor for Community {
.optional()
.with_lemmy_type(LemmyErrorType::NotFound)
}
fn actor_url(&self, settings: &Settings) -> LemmyResult<Url> {
let domain = self
.ap_id
.inner()
.domain()
.ok_or(LemmyErrorType::NotFound)?;
format_actor_url(&self.name, domain, 'c', settings)
}
fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {
let domain = settings.get_protocol_and_hostname();
Ok(Url::parse(&format!("{domain}/c/{name}"))?.into())
}
}
#[cfg(test)]

View file

@ -10,7 +10,7 @@ use crate::{
PersonUpdateForm,
},
traits::{ApubActor, Blockable, Crud, Followable},
utils::{functions::lower, get_conn, uplete, DbPool},
utils::{format_actor_url, functions::lower, get_conn, uplete, DbPool},
};
use chrono::Utc;
use diesel::{
@ -146,11 +146,6 @@ impl Person {
.then_some(())
.ok_or(LemmyErrorType::UsernameAlreadyExists.into())
}
pub fn local_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {
let domain = settings.get_protocol_and_hostname();
Ok(Url::parse(&format!("{domain}/u/{name}"))?.into())
}
}
impl PersonInsertForm {
@ -210,6 +205,21 @@ impl ApubActor for Person {
.optional()
.with_lemmy_type(LemmyErrorType::NotFound)
}
fn actor_url(&self, settings: &Settings) -> LemmyResult<Url> {
let domain = self
.ap_id
.inner()
.domain()
.ok_or(LemmyErrorType::NotFound)?;
format_actor_url(&self.name, domain, 'u', settings)
}
fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {
let domain = settings.get_protocol_and_hostname();
Ok(Url::parse(&format!("{domain}/u/{name}"))?.into())
}
}
impl Followable for PersonActions {

View file

@ -14,8 +14,12 @@ use diesel_async::{
AsyncPgConnection,
RunQueryDsl,
};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::structs::Settings,
};
use std::future::Future;
use url::Url;
/// Returned by `diesel::delete`
pub type Delete<T> = DeleteStatement<<T as HasTable>::Table, <T as IntoUpdateTarget>::WhereClause>;
@ -326,6 +330,9 @@ pub trait ApubActor {
) -> impl Future<Output = LemmyResult<Option<Self>>> + Send
where
Self: Sized;
fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl>;
fn actor_url(&self, settings: &Settings) -> LemmyResult<Url>;
}
pub trait InternalToCombinedView {

View file

@ -35,7 +35,7 @@ use i_love_jesus::{CursorKey, PaginatedQueryBuilder, SortDirection};
use lemmy_db_schema_file::schema_setup;
use lemmy_utils::{
error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::SETTINGS,
settings::{structs::Settings, SETTINGS},
utils::validation::clean_url,
};
use regex::Regex;
@ -624,6 +624,22 @@ pub fn paginate<Q, C>(
query
}
pub(crate) fn format_actor_url(
name: &str,
domain: &str,
prefix: char,
settings: &Settings,
) -> LemmyResult<Url> {
let local_protocol_and_hostname = settings.get_protocol_and_hostname();
let local_hostname = &settings.hostname;
let url = if domain != local_hostname {
format!("{local_protocol_and_hostname}/{prefix}/{name}@{domain}",)
} else {
format!("{local_protocol_and_hostname}/{prefix}/{name}")
};
Ok(Url::parse(&url)?)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -19,6 +19,7 @@ workspace = true
lemmy_db_views_post = { workspace = true, features = ["full"] }
lemmy_db_views_local_user = { workspace = true, features = ["full"] }
lemmy_db_views_inbox_combined = { workspace = true, features = ["full"] }
lemmy_db_views_modlog_combined = { workspace = true, features = ["full"] }
lemmy_db_views_person_content_combined = { workspace = true, features = [
"full",
] }

View file

@ -12,6 +12,7 @@ use lemmy_db_schema::{
};
use lemmy_db_schema_file::enums::{ListingType, PostSortType};
use lemmy_db_views_inbox_combined::{impls::InboxCombinedQuery, InboxCombinedView};
use lemmy_db_views_modlog_combined::{impls::ModlogCombinedQuery, ModlogCombinedView};
use lemmy_db_views_person_content_combined::impls::PersonContentCombinedQuery;
use lemmy_db_views_post::{impls::PostQuery, PostView};
use lemmy_db_views_site::SiteView;
@ -58,6 +59,7 @@ enum RequestType {
User,
Front,
Inbox,
Modlog,
}
pub fn config(cfg: &mut web::ServiceConfig) {
@ -169,6 +171,7 @@ async fn get_feed(
"c" => RequestType::Community,
"front" => RequestType::Front,
"inbox" => RequestType::Inbox,
"modlog" => RequestType::Modlog,
_ => return Err(ErrorBadRequest(LemmyError::from(anyhow!("wrong_type")))),
};
@ -181,6 +184,7 @@ async fn get_feed(
get_feed_front(&context, &info.sort_type()?, &info.get_limit(), &param).await
}
RequestType::Inbox => get_feed_inbox(&context, &param).await,
RequestType::Modlog => get_feed_modlog(&context, &param).await,
}
.map_err(ErrorBadRequest)?;
@ -331,7 +335,7 @@ async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> LemmyResult<Channe
.await?;
let protocol_and_hostname = context.settings().get_protocol_and_hostname();
let items = create_reply_and_mention_items(inbox, &protocol_and_hostname, context)?;
let items = create_reply_and_mention_items(inbox, context)?;
let mut channel = Channel {
namespaces: RSS_NAMESPACE.clone(),
@ -348,9 +352,41 @@ async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> LemmyResult<Channe
Ok(channel)
}
/// Gets your ModeratorView modlog
async fn get_feed_modlog(context: &LemmyContext, jwt: &str) -> LemmyResult<Channel> {
let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_user = local_user_view_from_jwt(jwt, context).await?;
check_private_instance(&Some(local_user.clone()), &site_view.local_site)?;
let modlog = ModlogCombinedQuery {
listing_type: Some(ListingType::ModeratorView),
local_user: Some(&local_user.local_user),
hide_modlog_names: Some(false),
..Default::default()
}
.list(&mut context.pool())
.await?;
let protocol_and_hostname = context.settings().get_protocol_and_hostname();
let items = create_modlog_items(modlog, context.settings())?;
let mut channel = Channel {
namespaces: RSS_NAMESPACE.clone(),
title: format!("{} - Modlog", local_user.person.name),
link: format!("{protocol_and_hostname}/modlog"),
items,
..Default::default()
};
if let Some(site_desc) = site_view.site.description {
channel.set_description(&site_desc);
}
Ok(channel)
}
fn create_reply_and_mention_items(
inbox: Vec<InboxCombinedView>,
protocol_and_hostname: &str,
context: &LemmyContext,
) -> LemmyResult<Vec<Item>> {
let reply_items: Vec<Item> = inbox
@ -359,41 +395,41 @@ fn create_reply_and_mention_items(
InboxCombinedView::CommentReply(v) => {
let reply_url = v.comment.local_url(context.settings())?;
build_item(
&v.creator.name,
&v.creator,
&v.comment.published,
reply_url.as_str(),
&v.comment.content,
protocol_and_hostname,
context.settings(),
)
}
InboxCombinedView::CommentMention(v) => {
let mention_url = v.comment.local_url(context.settings())?;
build_item(
&v.creator.name,
&v.creator,
&v.comment.published,
mention_url.as_str(),
&v.comment.content,
protocol_and_hostname,
context.settings(),
)
}
InboxCombinedView::PostMention(v) => {
let mention_url = v.post.local_url(context.settings())?;
build_item(
&v.creator.name,
&v.creator,
&v.post.published,
mention_url.as_str(),
&v.post.body.clone().unwrap_or_default(),
protocol_and_hostname,
context.settings(),
)
}
InboxCombinedView::PrivateMessage(v) => {
let inbox_url = format!("{}/inbox", protocol_and_hostname);
let inbox_url = format!("{}/inbox", context.settings().get_protocol_and_hostname());
build_item(
&v.creator.name,
&v.creator,
&v.private_message.published,
&inbox_url,
&v.private_message.content,
protocol_and_hostname,
context.settings(),
)
}
})
@ -402,12 +438,292 @@ fn create_reply_and_mention_items(
Ok(reply_items)
}
fn create_modlog_items(
modlog: Vec<ModlogCombinedView>,
settings: &Settings,
) -> LemmyResult<Vec<Item>> {
// All of these go to your modlog url
let modlog_url = format!(
"{}/modlog?listing_type=ModeratorView",
settings.get_protocol_and_hostname()
);
let modlog_items: Vec<Item> = modlog
.iter()
.map(|r| match r {
ModlogCombinedView::AdminAllowInstance(v) => build_modlog_item(
&v.admin,
&v.admin_allow_instance.published,
&modlog_url,
&format!(
"Admin {} instance - {}",
if v.admin_allow_instance.allowed {
"allowed"
} else {
"disallowed"
},
&v.instance.domain
),
&v.admin_allow_instance.reason,
settings,
),
ModlogCombinedView::AdminBlockInstance(v) => build_modlog_item(
&v.admin,
&v.admin_block_instance.published,
&modlog_url,
&format!(
"Admin {} instance - {}",
if v.admin_block_instance.blocked {
"blocked"
} else {
"unblocked"
},
&v.instance.domain
),
&v.admin_block_instance.reason,
settings,
),
ModlogCombinedView::AdminPurgeComment(v) => build_modlog_item(
&v.admin,
&v.admin_purge_comment.published,
&modlog_url,
"Admin purged comment",
&v.admin_purge_comment.reason,
settings,
),
ModlogCombinedView::AdminPurgeCommunity(v) => build_modlog_item(
&v.admin,
&v.admin_purge_community.published,
&modlog_url,
"Admin purged community",
&v.admin_purge_community.reason,
settings,
),
ModlogCombinedView::AdminPurgePerson(v) => build_modlog_item(
&v.admin,
&v.admin_purge_person.published,
&modlog_url,
"Admin purged person",
&v.admin_purge_person.reason,
settings,
),
ModlogCombinedView::AdminPurgePost(v) => build_modlog_item(
&v.admin,
&v.admin_purge_post.published,
&modlog_url,
"Admin purged post",
&v.admin_purge_post.reason,
settings,
),
ModlogCombinedView::ModAdd(v) => build_modlog_item(
&v.moderator,
&v.mod_add.published,
&modlog_url,
&format!(
"{} admin {}",
removed_added_str(v.mod_add.removed),
&v.other_person.name
),
&None,
settings,
),
ModlogCombinedView::ModAddCommunity(v) => build_modlog_item(
&v.moderator,
&v.mod_add_community.published,
&modlog_url,
&format!(
"{} mod {} to /c/{}",
removed_added_str(v.mod_add_community.removed),
&v.other_person.name,
&v.community.name
),
&None,
settings,
),
ModlogCombinedView::ModBan(v) => build_modlog_item(
&v.moderator,
&v.mod_ban.published,
&modlog_url,
&format!(
"{} {}",
banned_unbanned_str(v.mod_ban.banned),
&v.other_person.name
),
&v.mod_ban.reason,
settings,
),
ModlogCombinedView::ModBanFromCommunity(v) => build_modlog_item(
&v.moderator,
&v.mod_ban_from_community.published,
&modlog_url,
&format!(
"{} {} from /c/{}",
banned_unbanned_str(v.mod_ban_from_community.banned),
&v.other_person.name,
&v.community.name
),
&v.mod_ban_from_community.reason,
settings,
),
ModlogCombinedView::ModFeaturePost(v) => build_modlog_item(
&v.moderator,
&v.mod_feature_post.published,
&modlog_url,
&format!(
"{} post {}",
if v.mod_feature_post.featured {
"Featured"
} else {
"Unfeatured"
},
&v.post.name
),
&None,
settings,
),
ModlogCombinedView::ModChangeCommunityVisibility(v) => build_modlog_item(
&v.moderator,
&v.mod_change_community_visibility.published,
&modlog_url,
&format!(
"Changed /c/{} visibility to {}",
&v.community.name, &v.mod_change_community_visibility.visibility
),
&None,
settings,
),
ModlogCombinedView::ModLockPost(v) => build_modlog_item(
&v.moderator,
&v.mod_lock_post.published,
&modlog_url,
&format!(
"{} post {}",
if v.mod_lock_post.locked {
"Locked"
} else {
"Unlocked"
},
&v.post.name
),
&v.mod_lock_post.reason,
settings,
),
ModlogCombinedView::ModRemoveComment(v) => build_modlog_item(
&v.moderator,
&v.mod_remove_comment.published,
&modlog_url,
&format!(
"{} comment {}",
removed_restored_str(v.mod_remove_comment.removed),
&v.comment.content
),
&v.mod_remove_comment.reason,
settings,
),
ModlogCombinedView::ModRemoveCommunity(v) => build_modlog_item(
&v.moderator,
&v.mod_remove_community.published,
&modlog_url,
&format!(
"{} community /c/{}",
removed_restored_str(v.mod_remove_community.removed),
&v.community.name
),
&v.mod_remove_community.reason,
settings,
),
ModlogCombinedView::ModRemovePost(v) => build_modlog_item(
&v.moderator,
&v.mod_remove_post.published,
&modlog_url,
&format!(
"{} post {}",
removed_restored_str(v.mod_remove_post.removed),
&v.post.name
),
&v.mod_remove_post.reason,
settings,
),
ModlogCombinedView::ModTransferCommunity(v) => build_modlog_item(
&v.moderator,
&v.mod_transfer_community.published,
&modlog_url,
&format!(
"Tranferred /c/{} to /u/{}",
&v.community.name, &v.other_person.name
),
&None,
settings,
),
})
.collect::<LemmyResult<Vec<Item>>>()?;
Ok(modlog_items)
}
fn removed_added_str(removed: bool) -> &'static str {
if removed {
"Removed"
} else {
"Added"
}
}
fn banned_unbanned_str(banned: bool) -> &'static str {
if banned {
"Banned"
} else {
"Unbanned"
}
}
fn removed_restored_str(removed: bool) -> &'static str {
if removed {
"Removed"
} else {
"Restored"
}
}
fn build_modlog_item(
mod_: &Option<Person>,
published: &DateTime<Utc>,
url: &str,
action: &str,
reason: &Option<String>,
settings: &Settings,
) -> LemmyResult<Item> {
let guid = Some(Guid {
permalink: true,
value: action.to_owned(),
});
let author = if let Some(mod_) = mod_ {
Some(format!(
"/u/{} <a href=\"{}\">(link)</a>",
mod_.name,
mod_.actor_url(settings)?
))
} else {
None
};
Ok(Item {
title: Some(action.to_string()),
author,
pub_date: Some(published.to_rfc2822()),
link: Some(url.to_owned()),
guid,
description: reason.clone(),
..Default::default()
})
}
fn build_item(
creator_name: &str,
creator: &Person,
published: &DateTime<Utc>,
url: &str,
content: &str,
protocol_and_hostname: &str,
settings: &Settings,
) -> LemmyResult<Item> {
// TODO add images
let guid = Some(Guid {
@ -417,10 +733,11 @@ fn build_item(
let description = Some(markdown_to_html(content));
Ok(Item {
title: Some(format!("Reply from {creator_name}")),
title: Some(format!("Reply from {}", creator.name)),
author: Some(format!(
"/u/{creator_name} <a href=\"{}\">(link)</a>",
format_args!("{protocol_and_hostname}/u/{creator_name}")
"/u/{} <a href=\"{}\">(link)</a>",
creator.name,
creator.actor_url(settings)?
)),
pub_date: Some(published.to_rfc2822()),
comments: Some(url.to_owned()),
@ -436,7 +753,7 @@ fn create_post_items(posts: Vec<PostView>, settings: &Settings) -> LemmyResult<V
for p in posts {
let post_url = p.post.local_url(settings)?;
let community_url = Community::local_url(&p.community.name, settings)?;
let community_url = &p.community.actor_url(settings)?;
let dublin_core_ext = Some(DublinCoreExtension {
creators: vec![p.creator.ap_id.to_string()],
..DublinCoreExtension::default()
@ -446,7 +763,7 @@ fn create_post_items(posts: Vec<PostView>, settings: &Settings) -> LemmyResult<V
value: post_url.to_string(),
});
let mut description = format!("submitted by <a href=\"{}\">{}</a> to <a href=\"{}\">{}</a><br>{} points | <a href=\"{}\">{} comments</a>",
p.creator.ap_id,
p.creator.actor_url(settings)?,
&p.creator.name,
community_url,
&p.community.name,

View file

@ -10,7 +10,7 @@ use lemmy_db_schema::{
person::{Person, PersonInsertForm},
site::{Site, SiteInsertForm},
},
traits::Crud,
traits::{ApubActor, Crud},
utils::DbPool,
};
use lemmy_db_views_site::SiteView;
@ -37,7 +37,7 @@ pub async fn setup_local_site(pool: &mut DbPool<'_>, settings: &Settings) -> Lem
if let Some(setup) = &settings.setup {
let person_keypair = generate_actor_keypair()?;
let person_ap_id = Person::local_url(&setup.admin_username, settings)?;
let person_ap_id = Person::generate_local_actor_url(&setup.admin_username, settings)?;
// Register the user if there's a site setup
let person_form = PersonInsertForm {