Merge remote-tracking branch 'upstream/main' into migration-runner

This commit is contained in:
Dull Bananas 2024-07-26 19:50:46 +00:00
commit 4428c61bea
101 changed files with 3197 additions and 1607 deletions

View file

@ -195,6 +195,7 @@ steps:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
LEMMY_TEST_FAST_FEDERATION: "1"
commands:
# Install pg_dump for the schema setup test (must match server version)
- apt update && apt install -y lsb-release
@ -288,7 +289,7 @@ steps:
services:
database:
image: postgres:16-alpine
image: pgautoupgrade/pgautoupgrade:16-alpine
environment:
POSTGRES_DB: lemmy
POSTGRES_USER: postgres

1313
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -99,7 +99,7 @@ lemmy_db_views = { version = "=0.19.5", path = "./crates/db_views" }
lemmy_db_views_actor = { version = "=0.19.5", path = "./crates/db_views_actor" }
lemmy_db_views_moderator = { version = "=0.19.5", path = "./crates/db_views_moderator" }
lemmy_federate = { version = "=0.19.5", path = "./crates/federate" }
activitypub_federation = { version = "0.5.6", default-features = false, features = [
activitypub_federation = { version = "0.5.8", default-features = false, features = [
"actix-web",
] }
diesel = "2.1.6"
@ -121,7 +121,12 @@ tracing-error = "0.2.0"
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
url = { version = "2.5.0", features = ["serde"] }
reqwest = { version = "0.11.27", features = ["json", "blocking", "gzip"] }
reqwest = { version = "0.11.27", default-features = false, features = [
"json",
"blocking",
"gzip",
"rustls-tls",
] }
reqwest-middleware = "0.2.5"
reqwest-tracing = "0.4.8"
clokwerk = "0.4.0"
@ -144,8 +149,7 @@ regex = "1.10.4"
once_cell = "1.19.0"
diesel-derive-newtype = "2.1.2"
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
strum = "0.26.2"
strum_macros = "0.26.4"
strum = { version = "0.26.3", features = ["derive"] }
itertools = "0.13.0"
futures = "0.3.30"
http = "0.2.12"
@ -198,6 +202,7 @@ opentelemetry = { workspace = true, optional = true }
console-subscriber = { version = "0.3.0", optional = true }
opentelemetry-otlp = { version = "0.12.0", optional = true }
pict-rs = { version = "0.5.15", optional = true }
rustls = { workspace = true }
tokio.workspace = true
actix-cors = "0.7.0"
futures-util = { workspace = true }

View file

@ -11,6 +11,7 @@ import {
betaUrl,
registerUser,
unfollows,
delay,
} from "./shared";
beforeAll(setupLogins);
@ -21,39 +22,48 @@ test("Follow local community", async () => {
let user = await registerUser(beta, betaUrl);
let community = (await resolveBetaCommunity(user)).community!;
expect(community.counts.subscribers).toBe(1);
expect(community.counts.subscribers_local).toBe(1);
let follow = await followCommunity(user, true, community.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(true);
expect(follow.community_view.subscribed).toBe("Subscribed");
expect(follow.community_view.counts.subscribers).toBe(2);
expect(follow.community_view.counts.subscribers_local).toBe(2);
expect(follow.community_view.counts.subscribers).toBe(
community.counts.subscribers + 1,
);
expect(follow.community_view.counts.subscribers_local).toBe(
community.counts.subscribers_local + 1,
);
// Test an unfollow
let unfollow = await followCommunity(user, false, community.community.id);
expect(unfollow.community_view.subscribed).toBe("NotSubscribed");
expect(unfollow.community_view.counts.subscribers).toBe(1);
expect(unfollow.community_view.counts.subscribers_local).toBe(1);
expect(unfollow.community_view.counts.subscribers).toBe(
community.counts.subscribers,
);
expect(unfollow.community_view.counts.subscribers_local).toBe(
community.counts.subscribers_local,
);
});
test("Follow federated community", async () => {
// It takes about 1 second for the community aggregates to federate
let betaCommunity = (
await delay(2000); // if this is the second test run, we don't have a way to wait for the correct number of subscribers
const betaCommunityInitial = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c =>
c.community?.counts.subscribers === 1 &&
c.community.counts.subscribers_local === 0,
c => !!c.community && c.community?.counts.subscribers >= 1,
)
).community;
if (!betaCommunity) {
if (!betaCommunityInitial) {
throw "Missing beta community";
}
let follow = await followCommunity(alpha, true, betaCommunity.community.id);
let follow = await followCommunity(
alpha,
true,
betaCommunityInitial.community.id,
);
expect(follow.community_view.subscribed).toBe("Pending");
betaCommunity = (
const betaCommunity = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c => c.community?.subscribed === "Subscribed",
@ -64,20 +74,24 @@ test("Follow federated community", async () => {
expect(betaCommunity?.community.local).toBe(false);
expect(betaCommunity?.community.name).toBe("main");
expect(betaCommunity?.subscribed).toBe("Subscribed");
expect(betaCommunity?.counts.subscribers_local).toBe(1);
expect(betaCommunity?.counts.subscribers_local).toBe(
betaCommunityInitial.counts.subscribers_local + 1,
);
// check that unfollow was federated
let communityOnBeta1 = await resolveBetaCommunity(beta);
expect(communityOnBeta1.community?.counts.subscribers).toBe(2);
expect(communityOnBeta1.community?.counts.subscribers_local).toBe(1);
expect(communityOnBeta1.community?.counts.subscribers).toBe(
betaCommunityInitial.counts.subscribers + 1,
);
// Check it from local
let site = await getSite(alpha);
let remoteCommunityId = site.my_user?.follows.find(
c => c.community.local == false,
c =>
c.community.local == false &&
c.community.id === betaCommunityInitial.community.id,
)?.community.id;
expect(remoteCommunityId).toBeDefined();
expect(site.my_user?.follows.length).toBe(2);
if (!remoteCommunityId) {
throw "Missing remote community id";
@ -89,10 +103,21 @@ test("Follow federated community", async () => {
// Make sure you are unsubbed locally
let siteUnfollowCheck = await getSite(alpha);
expect(siteUnfollowCheck.my_user?.follows.length).toBe(1);
expect(
siteUnfollowCheck.my_user?.follows.find(
c => c.community.id === betaCommunityInitial.community.id,
),
).toBe(undefined);
// check that unfollow was federated
let communityOnBeta2 = await resolveBetaCommunity(beta);
expect(communityOnBeta2.community?.counts.subscribers).toBe(1);
let communityOnBeta2 = await waitUntil(
() => resolveBetaCommunity(beta),
c =>
c.community?.counts.subscribers ===
betaCommunityInitial.counts.subscribers,
);
expect(communityOnBeta2.community?.counts.subscribers).toBe(
betaCommunityInitial.counts.subscribers,
);
expect(communityOnBeta2.community?.counts.subscribers_local).toBe(1);
});

View file

@ -52,17 +52,23 @@ beforeAll(async () => {
afterAll(unfollows);
async function assertPostFederation(postOne: PostView, postTwo: PostView) {
async function assertPostFederation(
postOne: PostView,
postTwo: PostView,
waitForMeta = true,
) {
// Link metadata is generated in background task and may not be ready yet at this time,
// so wait for it explicitly. For removed posts we cant refetch anything.
postOne = await waitForPost(beta, postOne.post, res => {
return res === null || res?.post.embed_title !== null;
});
postTwo = await waitForPost(
beta,
postTwo.post,
res => res === null || res?.post.embed_title !== null,
);
if (waitForMeta) {
postOne = await waitForPost(beta, postOne.post, res => {
return res === null || !!res?.post.embed_title;
});
postTwo = await waitForPost(
beta,
postTwo.post,
res => res === null || !!res?.post.embed_title,
);
}
expect(postOne?.post.ap_id).toBe(postTwo?.post.ap_id);
expect(postOne?.post.name).toBe(postTwo?.post.name);
@ -408,7 +414,11 @@ test("Remove a post from admin and community on same instance", async () => {
p => p?.post_view.post.removed ?? false,
);
expect(alphaPost?.post_view.post.removed).toBe(true);
await assertPostFederation(alphaPost.post_view, removePostRes.post_view);
await assertPostFederation(
alphaPost.post_view,
removePostRes.post_view,
false,
);
// Undelete
let undeletedPost = await removePost(beta, false, betaPost.post);

View file

@ -131,7 +131,11 @@ test("Requests with invalid auth should be treated as unauthenticated", async ()
});
test("Create user with Arabic name", async () => {
let user = await registerUser(alpha, alphaUrl, "تجريب");
let user = await registerUser(
alpha,
alphaUrl,
"تجريب" + Math.random().toString().slice(2, 10), // less than actor_name_max_length
);
let site = await getSite(user);
expect(site.my_user).toBeDefined();

View file

@ -108,10 +108,12 @@
port: 8536
# Whether the site is available over TLS. Needs to be true for federation to work.
tls_enabled: true
# The number of activitypub federation workers that can be in-flight concurrently
worker_count: 0
# The number of activitypub federation retry workers that can be in-flight concurrently
retry_count: 0
federation: {
# Limit to the number of concurrent outgoing federation requests per target instance.
# Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities
# per second) and if a receiving instance is not keeping up.
concurrent_sends_per_instance: 1
}
prometheus: {
bind: "127.0.0.1"
port: 10002

View file

@ -17,9 +17,13 @@ pub async fn distinguish_comment(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let orig_comment = CommentView::read(&mut context.pool(), data.comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let orig_comment = CommentView::read(
&mut context.pool(),
data.comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,
@ -54,7 +58,7 @@ pub async fn distinguish_comment(
let comment_view = CommentView::read(
&mut context.pool(),
data.comment_id,
Some(local_user_view.person.id),
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;

View file

@ -35,9 +35,13 @@ pub async fn like_comment(
check_bot_account(&local_user_view.person)?;
let comment_id = data.comment_id;
let orig_comment = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let orig_comment = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,

View file

@ -17,7 +17,7 @@ pub async fn list_comment_likes(
let comment_view = CommentView::read(
&mut context.pool(),
data.comment_id,
Some(local_user_view.person.id),
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;

View file

@ -32,10 +32,13 @@ pub async fn save_comment(
}
let comment_id = data.comment_id;
let person_id = local_user_view.person.id;
let comment_view = CommentView::read(&mut context.pool(), comment_id, Some(person_id))
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let comment_view = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
Ok(Json(CommentResponse {
comment_view,

View file

@ -35,9 +35,13 @@ pub async fn create_comment_report(
let person_id = local_user_view.person.id;
let comment_id = data.comment_id;
let comment_view = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let comment_view = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,

View file

@ -9,6 +9,7 @@ use lemmy_api_common::{
use lemmy_db_schema::{
source::{
community::{Community, CommunityModerator, CommunityModeratorForm},
local_user::LocalUser,
moderator::{ModAddCommunity, ModAddCommunityForm},
},
traits::{Crud, Joinable},
@ -33,6 +34,18 @@ pub async fn add_mod_to_community(
&mut context.pool(),
)
.await?;
// If its a mod removal, also check that you're a higher mod.
if !data.added {
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
community_id,
local_user_view.person.id,
vec![data.person_id],
)
.await?;
}
let community = Community::read(&mut context.pool(), community_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;

View file

@ -14,6 +14,7 @@ use lemmy_db_schema::{
CommunityPersonBan,
CommunityPersonBanForm,
},
local_user::LocalUser,
moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
},
traits::{Bannable, Crud, Followable},
@ -44,6 +45,14 @@ pub async fn ban_from_community(
)
.await?;
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
data.community_id,
local_user_view.person.id,
vec![data.person_id],
)
.await?;
if let Some(reason) = &data.reason {
is_valid_body_field(reason, false)?;
}

View file

@ -50,10 +50,14 @@ pub async fn block_community(
.with_lemmy_type(LemmyErrorType::CommunityBlockAlreadyExists)?;
}
let community_view =
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
Some(&local_user_view.local_user),
false,
)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
ActivityChannel::submit_activity(
SendActivityData::FollowCommunity(

View file

@ -62,11 +62,14 @@ pub async fn follow_community(
}
let community_id = data.community_id;
let person_id = local_user_view.person.id;
let community_view =
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
Some(&local_user_view.local_user),
false,
)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;

View file

@ -76,11 +76,14 @@ pub async fn transfer_community(
ModTransferCommunity::create(&mut context.pool(), &form).await?;
let community_id = data.community_id;
let person_id = local_user_view.person.id;
let community_view =
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
Some(&local_user_view.local_user),
false,
)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let community_id = data.community_id;
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id)

View file

@ -24,6 +24,16 @@ pub async fn add_admin(
// Make sure user is an admin
is_admin(&local_user_view)?;
// If its an admin removal, also check that you're a higher admin
if !data.added {
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
}
// Make sure that the person_id added is local
let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id)
.await?

View file

@ -9,6 +9,7 @@ use lemmy_api_common::{
};
use lemmy_db_schema::{
source::{
local_user::LocalUser,
login_token::LoginToken,
moderator::{ModBan, ModBanForm},
person::{Person, PersonUpdateForm},
@ -31,6 +32,14 @@ pub async fn ban_from_site(
// Make sure user is an admin
is_admin(&local_user_view)?;
// Also make sure you're a higher admin than the target
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
if let Some(reason) = &data.reason {
is_valid_body_field(reason, false)?;
}

View file

@ -72,11 +72,5 @@ pub async fn feature_post(
)
.await?;
build_post_response(
&context,
orig_post.community_id,
&local_user_view.person,
post_id,
)
.await
build_post_response(&context, orig_post.community_id, local_user_view, post_id).await
}

View file

@ -85,11 +85,5 @@ pub async fn like_post(
)
.await?;
build_post_response(
context.deref(),
post.community_id,
&local_user_view.person,
post_id,
)
.await
build_post_response(context.deref(), post.community_id, local_user_view, post_id).await
}

View file

@ -63,11 +63,5 @@ pub async fn lock_post(
)
.await?;
build_post_response(
&context,
orig_post.community_id,
&local_user_view.person,
post_id,
)
.await
build_post_response(&context, orig_post.community_id, local_user_view, post_id).await
}

View file

@ -34,9 +34,14 @@ pub async fn save_post(
let post_id = data.post_id;
let person_id = local_user_view.person.id;
let post_view = PostView::read(&mut context.pool(), post_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
let post_view = PostView::read(
&mut context.pool(),
post_id,
Some(&local_user_view.local_user),
false,
)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
mark_post_as_read(person_id, post_id, &mut context.pool()).await?;

View file

@ -10,6 +10,7 @@ use lemmy_api_common::{
use lemmy_db_schema::{
source::{
comment::Comment,
local_user::LocalUser,
moderator::{AdminPurgeComment, AdminPurgeCommentForm},
},
traits::Crud,
@ -29,9 +30,21 @@ pub async fn purge_comment(
let comment_id = data.comment_id;
// Read the comment to get the post_id and community
let comment_view = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let comment_view = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
// Also check that you're a higher admin
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![comment_view.creator.id],
)
.await?;
let post_id = comment_view.comment.post_id;

View file

@ -9,13 +9,16 @@ use lemmy_api_common::{
SuccessResponse,
};
use lemmy_db_schema::{
newtypes::PersonId,
source::{
community::Community,
local_user::LocalUser,
moderator::{AdminPurgeCommunity, AdminPurgeCommunityForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::CommunityModeratorView;
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
#[tracing::instrument(skip(context))]
@ -32,6 +35,21 @@ pub async fn purge_community(
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
// Also check that you're a higher admin than all the mods
let community_mod_person_ids =
CommunityModeratorView::for_community(&mut context.pool(), community.id)
.await?
.iter()
.map(|cmv| cmv.moderator.id)
.collect::<Vec<PersonId>>();
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
community_mod_person_ids,
)
.await?;
if let Some(banner) = &community.banner {
purge_image_from_pictrs(banner, &context).await.ok();
}

View file

@ -10,6 +10,7 @@ use lemmy_api_common::{
};
use lemmy_db_schema::{
source::{
local_user::LocalUser,
moderator::{AdminPurgePerson, AdminPurgePersonForm},
person::{Person, PersonUpdateForm},
},
@ -27,9 +28,18 @@ pub async fn purge_person(
// Only let admin purge an item
is_admin(&local_user_view)?;
// Also check that you're a higher admin
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
let person = Person::read(&mut context.pool(), data.person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPerson)?;
ban_nonlocal_user_from_local_communities(
&local_user_view,
&person,

View file

@ -10,6 +10,7 @@ use lemmy_api_common::{
};
use lemmy_db_schema::{
source::{
local_user::LocalUser,
moderator::{AdminPurgePost, AdminPurgePostForm},
post::Post,
},
@ -32,6 +33,14 @@ pub async fn purge_post(
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
// Also check that you're a higher admin
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![post.creator_id],
)
.await?;
// Purge image
if let Some(url) = &post.url {
purge_image_from_pictrs(url, &context).await.ok();

View file

@ -0,0 +1,28 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
site::{GetRegistrationApplication, RegistrationApplicationResponse},
utils::is_admin,
};
use lemmy_db_views::structs::{LocalUserView, RegistrationApplicationView};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
/// Lists registration applications, filterable by undenied only.
pub async fn get_registration_application(
data: Query<GetRegistrationApplication>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<RegistrationApplicationResponse>> {
// Make sure user is an admin
is_admin(&local_user_view)?;
// Read the view
let registration_application =
RegistrationApplicationView::read_by_person(&mut context.pool(), data.person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindRegistrationApplication)?;
Ok(Json(RegistrationApplicationResponse {
registration_application,
}))
}

View file

@ -1,3 +1,4 @@
pub mod approve;
pub mod get;
pub mod list;
pub mod unread_count;

View file

@ -36,8 +36,8 @@ pub async fn build_comment_response(
local_user_view: Option<LocalUserView>,
recipient_ids: Vec<LocalUserId>,
) -> LemmyResult<CommentResponse> {
let person_id = local_user_view.map(|l| l.person.id);
let comment_view = CommentView::read(&mut context.pool(), comment_id, person_id)
let local_user = local_user_view.map(|l| l.local_user);
let comment_view = CommentView::read(&mut context.pool(), comment_id, local_user.as_ref())
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
Ok(CommentResponse {
@ -54,11 +54,11 @@ pub async fn build_community_response(
let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view.person, community_id)
.await
.is_ok();
let person_id = local_user_view.person.id;
let local_user = local_user_view.local_user;
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
Some(person_id),
Some(&local_user),
is_mod_or_admin,
)
.await?
@ -74,16 +74,17 @@ pub async fn build_community_response(
pub async fn build_post_response(
context: &LemmyContext,
community_id: CommunityId,
person: &Person,
local_user_view: LocalUserView,
post_id: PostId,
) -> LemmyResult<Json<PostResponse>> {
let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), person, community_id)
let local_user = local_user_view.local_user;
let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view.person, community_id)
.await
.is_ok();
let post_view = PostView::read(
&mut context.pool(),
post_id,
Some(person.id),
Some(&local_user),
is_mod_or_admin,
)
.await?
@ -103,6 +104,7 @@ pub async fn send_local_notifs(
let mut recipient_ids = Vec::new();
let inbox_link = format!("{}/inbox", context.settings().get_protocol_and_hostname());
// let person = my_local_user.person;
// Read the comment view to get extra info
let comment_view = CommentView::read(&mut context.pool(), comment_id, None)
.await?

View file

@ -116,10 +116,7 @@ mod tests {
let inserted_person = Person::create(pool, &new_person).await.unwrap();
let local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_person.id)
.password_encrypted("123456".to_string())
.build();
let local_user_form = LocalUserInsertForm::test_form(inserted_person.id);
let inserted_local_user = LocalUser::create(pool, &local_user_form, vec![])
.await

View file

@ -55,7 +55,7 @@ impl LemmyContext {
/// Initialize a context for use in tests which blocks federation network calls.
///
/// Do not use this in production code.
pub async fn init_test_context() -> Data<LemmyContext> {
pub async fn init_test_federation_config() -> FederationConfig<LemmyContext> {
// call this to run migrations
let pool = build_db_pool_for_tests().await;
@ -70,14 +70,19 @@ impl LemmyContext {
let rate_limit_cell = RateLimitCell::with_test_config();
let context = LemmyContext::create(pool, client, secret, rate_limit_cell.clone());
let config = FederationConfig::builder()
FederationConfig::builder()
.domain(context.settings().hostname.clone())
.app_data(context)
.debug(true)
// Dont allow any network fetches
.http_fetch_limit(0)
.build()
.await
.expect("build federation config");
.expect("build federation config")
}
pub async fn init_test_context() -> Data<LemmyContext> {
let config = Self::init_test_federation_config().await;
config.to_request_data()
}
}

View file

@ -81,6 +81,8 @@ pub struct GetPosts {
pub show_hidden: Option<bool>,
/// If true, then show the read posts (even if your user setting is to hide them)
pub show_read: Option<bool>,
/// If true, then show the nsfw posts (even if your user setting is to hide them)
pub show_nsfw: Option<bool>,
pub page_cursor: Option<PaginationCursor>,
}

View file

@ -1,7 +1,15 @@
use crate::federate_retry_sleep_duration;
use chrono::{DateTime, Utc};
use lemmy_db_schema::{
newtypes::{CommentId, CommunityId, InstanceId, LanguageId, PersonId, PostId},
newtypes::{
CommentId,
CommunityId,
InstanceId,
LanguageId,
PersonId,
PostId,
RegistrationApplicationId,
},
source::{
federation_queue_state::FederationQueueState,
instance::Instance,
@ -440,13 +448,22 @@ pub struct ListRegistrationApplicationsResponse {
pub registration_applications: Vec<RegistrationApplicationView>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]
/// Gets a registration application for a person
pub struct GetRegistrationApplication {
pub person_id: PersonId,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]
/// Approves a registration application.
pub struct ApproveRegistrationApplication {
pub id: i32,
pub id: RegistrationApplicationId,
pub approve: bool,
pub deny_reason: Option<String>,
}

View file

@ -54,7 +54,7 @@ pub async fn create_comment(
let post_view = PostView::read(
&mut context.pool(),
post_id,
Some(local_user_view.person.id),
Some(&local_user_view.local_user),
true,
)
.await?

View file

@ -21,9 +21,13 @@ pub async fn delete_comment(
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let comment_id = data.comment_id;
let orig_comment = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let orig_comment = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
// Dont delete it if its already been deleted.
if orig_comment.comment.deleted == data.deleted {

View file

@ -11,6 +11,7 @@ use lemmy_db_schema::{
source::{
comment::{Comment, CommentUpdateForm},
comment_report::CommentReport,
local_user::LocalUser,
moderator::{ModRemoveComment, ModRemoveCommentForm},
},
traits::{Crud, Reportable},
@ -25,9 +26,13 @@ pub async fn remove_comment(
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let comment_id = data.comment_id;
let orig_comment = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let orig_comment = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_mod_action(
&local_user_view.person,
@ -37,6 +42,14 @@ pub async fn remove_comment(
)
.await?;
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
orig_comment.community.id,
local_user_view.person.id,
vec![orig_comment.creator.id],
)
.await?;
// Don't allow removing or restoring comment which was deleted by user, as it would reveal
// the comment text in mod log.
if orig_comment.comment.deleted {
@ -68,14 +81,8 @@ pub async fn remove_comment(
};
ModRemoveComment::create(&mut context.pool(), &form).await?;
let recipient_ids = send_local_notifs(
vec![],
comment_id,
&local_user_view.person.clone(),
false,
&context,
)
.await?;
let recipient_ids =
send_local_notifs(vec![], comment_id, &local_user_view.person, false, &context).await?;
let updated_comment_id = updated_comment.id;
ActivityChannel::submit_activity(

View file

@ -36,9 +36,13 @@ pub async fn update_comment(
let local_site = LocalSite::read(&mut context.pool()).await?;
let comment_id = data.comment_id;
let orig_comment = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
let orig_comment = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,

View file

@ -188,5 +188,5 @@ pub async fn create_post(
}
};
build_post_response(&context, community_id, &local_user_view.person, post_id).await
build_post_response(&context, community_id, local_user_view, post_id).await
}

View file

@ -62,7 +62,7 @@ pub async fn delete_post(
build_post_response(
&context,
orig_post.community_id,
&local_user_view.person,
local_user_view,
data.post_id,
)
.await

View file

@ -55,9 +55,15 @@ pub async fn get_post(
.await
.is_ok();
let post_view = PostView::read(&mut context.pool(), post_id, person_id, is_mod_or_admin)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
let local_user = local_user_view.map(|l| l.local_user);
let post_view = PostView::read(
&mut context.pool(),
post_id,
local_user.as_ref(),
is_mod_or_admin,
)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
let post_id = post_view.post.id;
if let Some(person_id) = person_id {
@ -76,20 +82,19 @@ pub async fn get_post(
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
person_id,
local_user.as_ref(),
is_mod_or_admin,
)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
let local_user = local_user_view.as_ref().map(|u| &u.local_user);
// Fetch the cross_posts
let cross_posts = if let Some(url) = &post_view.post.url {
let mut x_posts = PostQuery {
url_search: Some(url.inner().as_str().into()),
local_user,
local_user: local_user.as_ref(),
..Default::default()
}
.list(&local_site.site, &mut context.pool())

View file

@ -9,6 +9,7 @@ use lemmy_api_common::{
};
use lemmy_db_schema::{
source::{
local_user::LocalUser,
moderator::{ModRemovePost, ModRemovePostForm},
post::{Post, PostUpdateForm},
post_report::PostReport,
@ -37,6 +38,14 @@ pub async fn remove_post(
)
.await?;
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
orig_post.community_id,
local_user_view.person.id,
vec![orig_post.creator_id],
)
.await?;
// Update the post
let post_id = data.post_id;
let removed = data.removed;
@ -73,11 +82,5 @@ pub async fn remove_post(
)
.await?;
build_post_response(
&context,
orig_post.community_id,
&local_user_view.person,
post_id,
)
.await
build_post_response(&context, orig_post.community_id, local_user_view, post_id).await
}

View file

@ -137,7 +137,7 @@ pub async fn update_post(
build_post_response(
context.deref(),
orig_post.community_id,
&local_user_view.person,
local_user_view,
post_id,
)
.await

View file

@ -84,7 +84,7 @@ pub async fn get_site(
|pool| CommunityBlockView::for_person(pool, person_id),
|pool| InstanceBlockView::for_person(pool, person_id),
|pool| PersonBlockView::for_person(pool, person_id),
|pool| CommunityModeratorView::for_person(pool, person_id, true),
|pool| CommunityModeratorView::for_person(pool, person_id, Some(&local_user_view.local_user)),
|pool| LocalUserLanguage::read(pool, local_user_id)
))
.with_lemmy_type(LemmyErrorType::SystemErrLogin)?;

View file

@ -150,18 +150,18 @@ pub async fn register(
.unwrap_or(site_view.site.content_warning.is_some());
// Create the local user
let local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_person.id)
.email(data.email.as_deref().map(str::to_lowercase))
.password_encrypted(data.password.to_string())
.show_nsfw(Some(show_nsfw))
.accepted_application(accepted_application)
.default_listing_type(Some(local_site.default_post_listing_type))
.post_listing_mode(Some(local_site.default_post_listing_mode))
.interface_language(language_tags.first().cloned())
let local_user_form = LocalUserInsertForm {
email: data.email.as_deref().map(str::to_lowercase),
password_encrypted: data.password.to_string(),
show_nsfw: Some(show_nsfw),
accepted_application,
default_listing_type: Some(local_site.default_post_listing_type),
post_listing_mode: Some(local_site.default_post_listing_mode),
interface_language: language_tags.first().cloned(),
// If its the initial site setup, they are an admin
.admin(Some(!local_site.site_setup))
.build();
admin: Some(!local_site.site_setup),
..LocalUserInsertForm::new(inserted_person.id, data.password.to_string())
};
let all_languages = Language::read_all(&mut context.pool()).await?;
// use hashset to avoid duplicates

View file

@ -31,7 +31,7 @@ serde = { workspace = true }
actix-web = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
strum_macros = { workspace = true }
strum = { workspace = true }
url = { workspace = true }
http = { workspace = true }
futures = { workspace = true }

View file

@ -43,6 +43,7 @@ pub async fn list_posts(
let saved_only = data.saved_only;
let show_hidden = data.show_hidden;
let show_read = data.show_read;
let show_nsfw = data.show_nsfw;
let liked_only = data.liked_only;
let disliked_only = data.disliked_only;
@ -84,6 +85,7 @@ pub async fn list_posts(
limit,
show_hidden,
show_read,
show_nsfw,
..Default::default()
}
.list(&local_site.site, &mut context.pool())

View file

@ -29,7 +29,7 @@ pub async fn get_community(
check_private_instance(&local_user_view, &local_site)?;
let person_id = local_user_view.as_ref().map(|u| u.person.id);
let local_user = local_user_view.as_ref().map(|u| &u.local_user);
let community_id = match data.id {
Some(id) => id,
@ -53,7 +53,7 @@ pub async fn get_community(
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
person_id,
local_user,
is_mod_or_admin,
)
.await?

View file

@ -96,7 +96,7 @@ pub async fn read_person(
let moderates = CommunityModeratorView::for_person(
&mut context.pool(),
person_details_id,
local_user_view.is_some(),
local_user_view.map(|l| l.local_user).as_ref(),
)
.await?;

View file

@ -10,7 +10,7 @@ use lemmy_api_common::{
site::{ResolveObject, ResolveObjectResponse},
utils::check_private_instance,
};
use lemmy_db_schema::{newtypes::PersonId, source::local_site::LocalSite, utils::DbPool};
use lemmy_db_schema::{source::local_site::LocalSite, utils::DbPool};
use lemmy_db_views::structs::{CommentView, LocalUserView, PostView};
use lemmy_db_views_actor::structs::{CommunityView, PersonView};
use lemmy_utils::error::{LemmyErrorExt2, LemmyErrorType, LemmyResult};
@ -23,10 +23,9 @@ pub async fn resolve_object(
) -> LemmyResult<Json<ResolveObjectResponse>> {
let local_site = LocalSite::read(&mut context.pool()).await?;
check_private_instance(&local_user_view, &local_site)?;
let person_id = local_user_view.map(|v| v.person.id);
// If we get a valid personId back we can safely assume that the user is authenticated,
// if there's no personId then the JWT was missing or invalid.
let is_authenticated = person_id.is_some();
let is_authenticated = local_user_view.is_some();
let res = if is_authenticated {
// user is fully authenticated; allow remote lookups as well.
@ -37,24 +36,26 @@ pub async fn resolve_object(
}
.with_lemmy_type(LemmyErrorType::CouldntFindObject)?;
convert_response(res, person_id, &mut context.pool())
convert_response(res, local_user_view, &mut context.pool())
.await
.with_lemmy_type(LemmyErrorType::CouldntFindObject)
}
async fn convert_response(
object: SearchableObjects,
user_id: Option<PersonId>,
local_user_view: Option<LocalUserView>,
pool: &mut DbPool<'_>,
) -> LemmyResult<Json<ResolveObjectResponse>> {
use SearchableObjects::*;
let removed_or_deleted;
let mut res = ResolveObjectResponse::default();
let local_user = local_user_view.map(|l| l.local_user);
match object {
Post(p) => {
removed_or_deleted = p.deleted || p.removed;
res.post = Some(
PostView::read(pool, p.id, user_id, false)
PostView::read(pool, p.id, local_user.as_ref(), false)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?,
)
@ -62,7 +63,7 @@ async fn convert_response(
Comment(c) => {
removed_or_deleted = c.deleted || c.removed;
res.comment = Some(
CommentView::read(pool, c.id, user_id)
CommentView::read(pool, c.id, local_user.as_ref())
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?,
)
@ -79,7 +80,7 @@ async fn convert_response(
UserOrCommunity::Community(c) => {
removed_or_deleted = c.deleted || c.removed;
res.community = Some(
CommunityView::read(pool, c.id, user_id, false)
CommunityView::read(pool, c.id, local_user.as_ref(), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?,
)

View file

@ -345,10 +345,7 @@ mod tests {
};
let person = Person::create(&mut context.pool(), &person_form).await?;
let user_form = LocalUserInsertForm::builder()
.person_id(person.id)
.password_encrypted("pass".to_string())
.build();
let user_form = LocalUserInsertForm::test_form(person.id);
let local_user = LocalUser::create(&mut context.pool(), &user_form, vec![]).await?;
Ok(

View file

@ -109,7 +109,7 @@ impl Object for ApubSite {
icon: self.icon.clone().map(ImageObject::new),
image: self.banner.clone().map(ImageObject::new),
inbox: self.inbox_url.clone().into(),
outbox: Url::parse(&format!("{}/site_outbox", self.actor_id))?,
outbox: Url::parse(&format!("{}site_outbox", self.actor_id))?,
public_key: self.public_key(),
language,
content_warning: self.content_warning.clone(),

View file

@ -13,10 +13,10 @@ use lemmy_api_common::context::LemmyContext;
use lemmy_db_schema::{source::community::Community, traits::Crud};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
use serde::{Deserialize, Serialize};
use strum_macros::Display;
use strum::Display;
use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize, Display)]
#[derive(Clone, Debug, Display, Deserialize, Serialize)]
pub enum LockType {
Lock,
}

View file

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use strum_macros::Display;
use strum::Display;
pub mod block;
pub mod community;

View file

@ -8,7 +8,7 @@ use activitypub_federation::{config::Data, fetch::object_id::ObjectId};
use lemmy_api_common::context::LemmyContext;
use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};
use serde::{Deserialize, Serialize};
use strum_macros::Display;
use strum::Display;
use url::Url;
#[derive(Clone, Debug, Deserialize, Serialize)]

View file

@ -46,7 +46,6 @@ serde = { workspace = true }
serde_with = { workspace = true }
url = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
serde_json = { workspace = true, optional = true }
activitypub_federation = { workspace = true, optional = true }
lemmy_utils = { workspace = true, optional = true }

View file

@ -533,10 +533,7 @@ mod tests {
let person_form = PersonInsertForm::test_form(instance.id, "my test person");
let person = Person::create(pool, &person_form).await.unwrap();
let local_user_form = LocalUserInsertForm::builder()
.person_id(person.id)
.password_encrypted("my_pw".to_string())
.build();
let local_user_form = LocalUserInsertForm::test_form(person.id);
let local_user = LocalUser::create(pool, &local_user_form, vec![])
.await
@ -645,10 +642,7 @@ mod tests {
let person_form = PersonInsertForm::test_form(instance.id, "my test person");
let person = Person::create(pool, &person_form).await.unwrap();
let local_user_form = LocalUserInsertForm::builder()
.person_id(person.id)
.password_encrypted("my_pw".to_string())
.build();
let local_user_form = LocalUserInsertForm::test_form(person.id);
let local_user = LocalUser::create(pool, &local_user_form, vec![])
.await
.unwrap();

View file

@ -1,7 +1,14 @@
use crate::{
diesel::{DecoratableTarget, OptionalExtension},
newtypes::{CommunityId, DbUrl, PersonId},
schema::{community, community_follower, instance},
schema::{
community,
community_follower,
community_moderator,
community_person_ban,
instance,
post,
},
source::{
actor_language::CommunityLanguage,
community::{
@ -42,6 +49,7 @@ use diesel::{
Queryable,
};
use diesel_async::RunQueryDsl;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
#[async_trait]
impl Crud for Community {
@ -83,9 +91,8 @@ impl Joinable for CommunityModerator {
pool: &mut DbPool<'_>,
community_moderator_form: &CommunityModeratorForm,
) -> Result<Self, Error> {
use crate::schema::community_moderator::dsl::community_moderator;
let conn = &mut get_conn(pool).await?;
insert_into(community_moderator)
insert_into(community_moderator::table)
.values(community_moderator_form)
.get_result::<Self>(conn)
.await
@ -95,9 +102,8 @@ impl Joinable for CommunityModerator {
pool: &mut DbPool<'_>,
community_moderator_form: &CommunityModeratorForm,
) -> Result<usize, Error> {
use crate::schema::community_moderator::dsl::community_moderator;
let conn = &mut get_conn(pool).await?;
diesel::delete(community_moderator.find((
diesel::delete(community_moderator::table.find((
community_moderator_form.person_id,
community_moderator_form.community_id,
)))
@ -147,25 +153,23 @@ impl Community {
pool: &mut DbPool<'_>,
url: &DbUrl,
) -> Result<Option<(Community, CollectionType)>, Error> {
use crate::schema::community::dsl::{featured_url, moderators_url};
use CollectionType::*;
let conn = &mut get_conn(pool).await?;
let res = community::table
.filter(moderators_url.eq(url))
.filter(community::moderators_url.eq(url))
.first(conn)
.await
.optional()?;
if let Some(c) = res {
Ok(Some((c, Moderators)))
Ok(Some((c, CollectionType::Moderators)))
} else {
let res = community::table
.filter(featured_url.eq(url))
.filter(community::featured_url.eq(url))
.first(conn)
.await
.optional()?;
if let Some(c) = res {
Ok(Some((c, Featured)))
Ok(Some((c, CollectionType::Featured)))
} else {
Ok(None)
}
@ -177,7 +181,6 @@ impl Community {
posts: Vec<Post>,
pool: &mut DbPool<'_>,
) -> Result<(), Error> {
use crate::schema::post;
let conn = &mut get_conn(pool).await?;
for p in &posts {
debug_assert!(p.community_id == community_id);
@ -185,10 +188,10 @@ impl Community {
// Mark the given posts as featured and all other posts as not featured.
let post_ids = posts.iter().map(|p| p.id);
update(post::table)
.filter(post::dsl::community_id.eq(community_id))
.filter(post::community_id.eq(community_id))
// This filter is just for performance
.filter(post::dsl::featured_community.or(post::dsl::id.eq_any(post_ids.clone())))
.set(post::dsl::featured_community.eq(post::dsl::id.eq_any(post_ids)))
.filter(post::featured_community.or(post::id.eq_any(post_ids.clone())))
.set(post::featured_community.eq(post::id.eq_any(post_ids)))
.execute(conn)
.await?;
Ok(())
@ -200,37 +203,68 @@ impl CommunityModerator {
pool: &mut DbPool<'_>,
for_community_id: CommunityId,
) -> Result<usize, Error> {
use crate::schema::community_moderator::dsl::{community_id, community_moderator};
let conn = &mut get_conn(pool).await?;
diesel::delete(community_moderator.filter(community_id.eq(for_community_id)))
.execute(conn)
.await
diesel::delete(
community_moderator::table.filter(community_moderator::community_id.eq(for_community_id)),
)
.execute(conn)
.await
}
pub async fn leave_all_communities(
pool: &mut DbPool<'_>,
for_person_id: PersonId,
) -> Result<usize, Error> {
use crate::schema::community_moderator::dsl::{community_moderator, person_id};
let conn = &mut get_conn(pool).await?;
diesel::delete(community_moderator.filter(person_id.eq(for_person_id)))
.execute(conn)
.await
diesel::delete(
community_moderator::table.filter(community_moderator::person_id.eq(for_person_id)),
)
.execute(conn)
.await
}
pub async fn get_person_moderated_communities(
pool: &mut DbPool<'_>,
for_person_id: PersonId,
) -> Result<Vec<CommunityId>, Error> {
use crate::schema::community_moderator::dsl::{community_id, community_moderator, person_id};
let conn = &mut get_conn(pool).await?;
community_moderator
.filter(person_id.eq(for_person_id))
.select(community_id)
community_moderator::table
.filter(community_moderator::person_id.eq(for_person_id))
.select(community_moderator::community_id)
.load::<CommunityId>(conn)
.await
}
/// Checks to make sure the acting moderator was added earlier than the target moderator
pub async fn is_higher_mod_check(
pool: &mut DbPool<'_>,
for_community_id: CommunityId,
mod_person_id: PersonId,
target_person_ids: Vec<PersonId>,
) -> LemmyResult<()> {
let conn = &mut get_conn(pool).await?;
// Build the list of persons
let mut persons = target_person_ids;
persons.push(mod_person_id);
persons.dedup();
let res = community_moderator::table
.filter(community_moderator::community_id.eq(for_community_id))
.filter(community_moderator::person_id.eq_any(persons))
.order_by(community_moderator::published)
// This does a limit 1 select first
.first::<CommunityModerator>(conn)
.await?;
// If the first result sorted by published is the acting mod
if res.person_id == mod_person_id {
Ok(())
} else {
Err(LemmyErrorType::NotHigherMod)?
}
}
}
#[async_trait]
@ -240,11 +274,13 @@ impl Bannable for CommunityPersonBan {
pool: &mut DbPool<'_>,
community_person_ban_form: &CommunityPersonBanForm,
) -> Result<Self, Error> {
use crate::schema::community_person_ban::dsl::{community_id, community_person_ban, person_id};
let conn = &mut get_conn(pool).await?;
insert_into(community_person_ban)
insert_into(community_person_ban::table)
.values(community_person_ban_form)
.on_conflict((community_id, person_id))
.on_conflict((
community_person_ban::community_id,
community_person_ban::person_id,
))
.do_update()
.set(community_person_ban_form)
.get_result::<Self>(conn)
@ -255,9 +291,8 @@ impl Bannable for CommunityPersonBan {
pool: &mut DbPool<'_>,
community_person_ban_form: &CommunityPersonBanForm,
) -> Result<usize, Error> {
use crate::schema::community_person_ban::dsl::community_person_ban;
let conn = &mut get_conn(pool).await?;
diesel::delete(community_person_ban.find((
diesel::delete(community_person_ban::table.find((
community_person_ban_form.person_id,
community_person_ban_form.community_id,
)))
@ -291,11 +326,10 @@ impl CommunityFollower {
pool: &mut DbPool<'_>,
remote_community_id: CommunityId,
) -> Result<bool, Error> {
use crate::schema::community_follower::dsl::{community_follower, community_id};
let conn = &mut get_conn(pool).await?;
select(exists(
community_follower.filter(community_id.eq(remote_community_id)),
))
select(exists(community_follower::table.filter(
community_follower::community_id.eq(remote_community_id),
)))
.get_result(conn)
.await
}
@ -316,11 +350,13 @@ impl Queryable<sql_types::Nullable<sql_types::Bool>, Pg> for SubscribedType {
impl Followable for CommunityFollower {
type Form = CommunityFollowerForm;
async fn follow(pool: &mut DbPool<'_>, form: &CommunityFollowerForm) -> Result<Self, Error> {
use crate::schema::community_follower::dsl::{community_follower, community_id, person_id};
let conn = &mut get_conn(pool).await?;
insert_into(community_follower)
insert_into(community_follower::table)
.values(form)
.on_conflict((community_id, person_id))
.on_conflict((
community_follower::community_id,
community_follower::person_id,
))
.do_update()
.set(form)
.get_result::<Self>(conn)
@ -331,17 +367,16 @@ impl Followable for CommunityFollower {
community_id: CommunityId,
person_id: PersonId,
) -> Result<Self, Error> {
use crate::schema::community_follower::dsl::{community_follower, pending};
let conn = &mut get_conn(pool).await?;
diesel::update(community_follower.find((person_id, community_id)))
.set(pending.eq(false))
diesel::update(community_follower::table.find((person_id, community_id)))
.set(community_follower::pending.eq(false))
.get_result::<Self>(conn)
.await
}
async fn unfollow(pool: &mut DbPool<'_>, form: &CommunityFollowerForm) -> Result<usize, Error> {
use crate::schema::community_follower::dsl::community_follower;
let conn = &mut get_conn(pool).await?;
diesel::delete(community_follower.find((form.person_id, form.community_id)))
diesel::delete(community_follower::table.find((form.person_id, form.community_id)))
.execute(conn)
.await
}
@ -397,10 +432,8 @@ impl ApubActor for Community {
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod tests {
use crate::{
source::{
community::{
@ -415,28 +448,30 @@ mod tests {
CommunityUpdateForm,
},
instance::Instance,
local_user::LocalUser,
person::{Person, PersonInsertForm},
},
traits::{Bannable, Crud, Followable, Joinable},
utils::build_db_pool_for_tests,
CommunityVisibility,
};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
use pretty_assertions::assert_eq;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn test_crud() {
async fn test_crud() -> LemmyResult<()> {
let pool = &build_db_pool_for_tests().await;
let pool = &mut pool.into();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string())
.await
.unwrap();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?;
let new_person = PersonInsertForm::test_form(inserted_instance.id, "bobbee");
let bobby_person = PersonInsertForm::test_form(inserted_instance.id, "bobby");
let inserted_bobby = Person::create(pool, &bobby_person).await?;
let inserted_person = Person::create(pool, &new_person).await.unwrap();
let artemis_person = PersonInsertForm::test_form(inserted_instance.id, "artemis");
let inserted_artemis = Person::create(pool, &artemis_person).await?;
let new_community = CommunityInsertForm::builder()
.name("TIL".into())
@ -445,7 +480,7 @@ mod tests {
.instance_id(inserted_instance.id)
.build();
let inserted_community = Community::create(pool, &new_community).await.unwrap();
let inserted_community = Community::create(pool, &new_community).await?;
let expected_community = Community {
id: inserted_community.id,
@ -477,91 +512,120 @@ mod tests {
let community_follower_form = CommunityFollowerForm {
community_id: inserted_community.id,
person_id: inserted_person.id,
person_id: inserted_bobby.id,
pending: false,
};
let inserted_community_follower = CommunityFollower::follow(pool, &community_follower_form)
.await
.unwrap();
let inserted_community_follower =
CommunityFollower::follow(pool, &community_follower_form).await?;
let expected_community_follower = CommunityFollower {
community_id: inserted_community.id,
person_id: inserted_person.id,
person_id: inserted_bobby.id,
pending: false,
published: inserted_community_follower.published,
};
let community_moderator_form = CommunityModeratorForm {
let bobby_moderator_form = CommunityModeratorForm {
community_id: inserted_community.id,
person_id: inserted_person.id,
person_id: inserted_bobby.id,
};
let inserted_community_moderator = CommunityModerator::join(pool, &community_moderator_form)
.await
.unwrap();
let inserted_bobby_moderator = CommunityModerator::join(pool, &bobby_moderator_form).await?;
let artemis_moderator_form = CommunityModeratorForm {
community_id: inserted_community.id,
person_id: inserted_artemis.id,
};
let _inserted_artemis_moderator =
CommunityModerator::join(pool, &artemis_moderator_form).await?;
let expected_community_moderator = CommunityModerator {
community_id: inserted_community.id,
person_id: inserted_person.id,
published: inserted_community_moderator.published,
person_id: inserted_bobby.id,
published: inserted_bobby_moderator.published,
};
let moderator_person_ids = vec![inserted_bobby.id, inserted_artemis.id];
// Make sure bobby is marked as a higher mod than artemis, and vice versa
let bobby_higher_check = CommunityModerator::is_higher_mod_check(
pool,
inserted_community.id,
inserted_bobby.id,
moderator_person_ids.clone(),
)
.await;
assert!(bobby_higher_check.is_ok());
// Also check the other is_higher_mod_or_admin function just in case
let bobby_higher_check_2 = LocalUser::is_higher_mod_or_admin_check(
pool,
inserted_community.id,
inserted_bobby.id,
moderator_person_ids.clone(),
)
.await;
assert!(bobby_higher_check_2.is_ok());
// This should throw an error, since artemis was added later
let artemis_higher_check = CommunityModerator::is_higher_mod_check(
pool,
inserted_community.id,
inserted_artemis.id,
moderator_person_ids,
)
.await;
assert!(artemis_higher_check.is_err());
let community_person_ban_form = CommunityPersonBanForm {
community_id: inserted_community.id,
person_id: inserted_person.id,
person_id: inserted_bobby.id,
expires: None,
};
let inserted_community_person_ban = CommunityPersonBan::ban(pool, &community_person_ban_form)
.await
.unwrap();
let inserted_community_person_ban =
CommunityPersonBan::ban(pool, &community_person_ban_form).await?;
let expected_community_person_ban = CommunityPersonBan {
community_id: inserted_community.id,
person_id: inserted_person.id,
person_id: inserted_bobby.id,
published: inserted_community_person_ban.published,
expires: None,
};
let read_community = Community::read(pool, inserted_community.id)
.await
.unwrap()
.unwrap();
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let update_community_form = CommunityUpdateForm {
title: Some("nada".to_owned()),
..Default::default()
};
let updated_community = Community::update(pool, inserted_community.id, &update_community_form)
.await
.unwrap();
let updated_community =
Community::update(pool, inserted_community.id, &update_community_form).await?;
let ignored_community = CommunityFollower::unfollow(pool, &community_follower_form)
.await
.unwrap();
let left_community = CommunityModerator::leave(pool, &community_moderator_form)
.await
.unwrap();
let unban = CommunityPersonBan::unban(pool, &community_person_ban_form)
.await
.unwrap();
let num_deleted = Community::delete(pool, inserted_community.id)
.await
.unwrap();
Person::delete(pool, inserted_person.id).await.unwrap();
Instance::delete(pool, inserted_instance.id).await.unwrap();
let ignored_community = CommunityFollower::unfollow(pool, &community_follower_form).await?;
let left_community = CommunityModerator::leave(pool, &bobby_moderator_form).await?;
let unban = CommunityPersonBan::unban(pool, &community_person_ban_form).await?;
let num_deleted = Community::delete(pool, inserted_community.id).await?;
Person::delete(pool, inserted_bobby.id).await?;
Person::delete(pool, inserted_artemis.id).await?;
Instance::delete(pool, inserted_instance.id).await?;
assert_eq!(expected_community, read_community);
assert_eq!(expected_community, inserted_community);
assert_eq!(expected_community, updated_community);
assert_eq!(expected_community_follower, inserted_community_follower);
assert_eq!(expected_community_moderator, inserted_community_moderator);
assert_eq!(expected_community_moderator, inserted_bobby_moderator);
assert_eq!(expected_community_person_ban, inserted_community_person_ban);
assert_eq!(1, ignored_community);
assert_eq!(1, left_community);
assert_eq!(1, unban);
// assert_eq!(2, loaded_count);
assert_eq!(1, num_deleted);
Ok(())
}
}

View file

@ -1,6 +1,6 @@
use crate::{
newtypes::{DbUrl, LanguageId, LocalUserId, PersonId},
schema::{local_user, person, registration_application},
newtypes::{CommunityId, DbUrl, LanguageId, LocalUserId, PersonId},
schema::{community, community_moderator, local_user, person, registration_application},
source::{
actor_language::LocalUserLanguage,
local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm},
@ -13,16 +13,19 @@ use crate::{
now,
DbPool,
},
CommunityVisibility,
};
use bcrypt::{hash, DEFAULT_COST};
use diesel::{
dsl::{insert_into, not, IntervalDsl},
result::Error,
CombineDsl,
ExpressionMethods,
JoinOnDsl,
QueryDsl,
};
use diesel_async::RunQueryDsl;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
impl LocalUser {
pub async fn create(
@ -215,6 +218,72 @@ impl LocalUser {
blocked_instances,
})
}
/// Checks to make sure the acting admin is higher than the target admin
pub async fn is_higher_admin_check(
pool: &mut DbPool<'_>,
admin_person_id: PersonId,
target_person_ids: Vec<PersonId>,
) -> LemmyResult<()> {
let conn = &mut get_conn(pool).await?;
// Build the list of persons
let mut persons = target_person_ids;
persons.push(admin_person_id);
persons.dedup();
let res = local_user::table
.filter(local_user::admin.eq(true))
.filter(local_user::person_id.eq_any(persons))
.order_by(local_user::id)
// This does a limit 1 select first
.first::<LocalUser>(conn)
.await?;
// If the first result sorted by published is the acting admin
if res.person_id == admin_person_id {
Ok(())
} else {
Err(LemmyErrorType::NotHigherAdmin)?
}
}
/// Checks to make sure the acting moderator is higher than the target moderator
pub async fn is_higher_mod_or_admin_check(
pool: &mut DbPool<'_>,
for_community_id: CommunityId,
admin_person_id: PersonId,
target_person_ids: Vec<PersonId>,
) -> LemmyResult<()> {
let conn = &mut get_conn(pool).await?;
// Build the list of persons
let mut persons = target_person_ids;
persons.push(admin_person_id);
persons.dedup();
let admins = local_user::table
.filter(local_user::admin.eq(true))
.filter(local_user::person_id.eq_any(&persons))
.order_by(local_user::id)
.select(local_user::person_id);
let mods = community_moderator::table
.filter(community_moderator::community_id.eq(for_community_id))
.filter(community_moderator::person_id.eq_any(&persons))
.order_by(community_moderator::published)
.select(community_moderator::person_id);
let res = admins.union_all(mods).get_results::<PersonId>(conn).await?;
let first_person = res.as_slice().first().ok_or(LemmyErrorType::NotHigherMod)?;
// If the first result sorted by published is the acting mod
if *first_person == admin_person_id {
Ok(())
} else {
Err(LemmyErrorType::NotHigherMod)?
}
}
}
/// Adds some helper functions for an optional LocalUser
@ -225,6 +294,12 @@ pub trait LocalUserOptionHelper {
fn show_read_posts(&self) -> bool;
fn is_admin(&self) -> bool;
fn show_nsfw(&self, site: &Site) -> bool;
fn visible_communities_only<Q>(&self, query: Q) -> Q
where
Q: diesel::query_dsl::methods::FilterDsl<
diesel::dsl::Eq<community::visibility, CommunityVisibility>,
Output = Q,
>;
}
impl LocalUserOptionHelper for Option<&LocalUser> {
@ -253,14 +328,32 @@ impl LocalUserOptionHelper for Option<&LocalUser> {
.map(|l| l.show_nsfw)
.unwrap_or(site.content_warning.is_some())
}
fn visible_communities_only<Q>(&self, query: Q) -> Q
where
Q: diesel::query_dsl::methods::FilterDsl<
diesel::dsl::Eq<community::visibility, CommunityVisibility>,
Output = Q,
>,
{
if self.is_none() {
query.filter(community::visibility.eq(CommunityVisibility::Public))
} else {
query
}
}
}
impl LocalUserInsertForm {
pub fn test_form(person_id: PersonId) -> Self {
Self::builder()
.person_id(person_id)
.password_encrypted(String::new())
.build()
Self::new(person_id, String::new())
}
pub fn test_form_admin(person_id: PersonId) -> Self {
LocalUserInsertForm {
admin: Some(true),
..Self::test_form(person_id)
}
}
}
@ -272,3 +365,58 @@ pub struct UserBackupLists {
pub blocked_users: Vec<DbUrl>,
pub blocked_instances: Vec<String>,
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)]
mod tests {
use crate::{
source::{
instance::Instance,
local_user::{LocalUser, LocalUserInsertForm},
person::{Person, PersonInsertForm},
},
traits::Crud,
utils::build_db_pool_for_tests,
};
use lemmy_utils::error::LemmyResult;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn test_admin_higher_check() -> LemmyResult<()> {
let pool = &build_db_pool_for_tests().await;
let pool = &mut pool.into();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?;
let fiona_person = PersonInsertForm::test_form(inserted_instance.id, "fiona");
let inserted_fiona_person = Person::create(pool, &fiona_person).await?;
let fiona_local_user_form = LocalUserInsertForm::test_form_admin(inserted_fiona_person.id);
let _inserted_fiona_local_user =
LocalUser::create(pool, &fiona_local_user_form, vec![]).await?;
let delores_person = PersonInsertForm::test_form(inserted_instance.id, "delores");
let inserted_delores_person = Person::create(pool, &delores_person).await?;
let delores_local_user_form = LocalUserInsertForm::test_form_admin(inserted_delores_person.id);
let _inserted_delores_local_user =
LocalUser::create(pool, &delores_local_user_form, vec![]).await?;
let admin_person_ids = vec![inserted_fiona_person.id, inserted_delores_person.id];
// Make sure fiona is marked as a higher admin than delores, and vice versa
let fiona_higher_check =
LocalUser::is_higher_admin_check(pool, inserted_fiona_person.id, admin_person_ids.clone())
.await;
assert!(fiona_higher_check.is_ok());
// This should throw an error, since delores was added later
let delores_higher_check =
LocalUser::is_higher_admin_check(pool, inserted_delores_person.id, admin_person_ids).await;
assert!(delores_higher_check.is_err());
Instance::delete(pool, inserted_instance.id).await?;
Ok(())
}
}

View file

@ -31,6 +31,7 @@ impl LocalUserVoteDisplayMode {
.get_result::<Self>(conn)
.await
}
pub async fn update(
pool: &mut DbPool<'_>,
local_user_id: LocalUserId,

View file

@ -72,10 +72,7 @@ mod tests {
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?;
let new_person = PersonInsertForm::test_form(inserted_instance.id, "thommy prw");
let inserted_person = Person::create(pool, &new_person).await?;
let new_local_user = LocalUserInsertForm::builder()
.person_id(inserted_person.id)
.password_encrypted("pass".to_string())
.build();
let new_local_user = LocalUserInsertForm::test_form(inserted_person.id);
let inserted_local_user = LocalUser::create(pool, &new_local_user, vec![]).await?;
// Create password reset token

View file

@ -182,11 +182,10 @@ impl ApubActor for Person {
impl Followable for PersonFollower {
type Form = PersonFollowerForm;
async fn follow(pool: &mut DbPool<'_>, form: &PersonFollowerForm) -> Result<Self, Error> {
use crate::schema::person_follower::dsl::{follower_id, person_follower, person_id};
let conn = &mut get_conn(pool).await?;
insert_into(person_follower)
insert_into(person_follower::table)
.values(form)
.on_conflict((follower_id, person_id))
.on_conflict((person_follower::follower_id, person_follower::person_id))
.do_update()
.set(form)
.get_result::<Self>(conn)
@ -196,9 +195,8 @@ impl Followable for PersonFollower {
unimplemented!()
}
async fn unfollow(pool: &mut DbPool<'_>, form: &PersonFollowerForm) -> Result<usize, Error> {
use crate::schema::person_follower::dsl::person_follower;
let conn = &mut get_conn(pool).await?;
diesel::delete(person_follower.find((form.follower_id, form.person_id)))
diesel::delete(person_follower::table.find((form.follower_id, form.person_id)))
.execute(conn)
.await
}
@ -220,7 +218,6 @@ impl PersonFollower {
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod tests {
@ -232,22 +229,21 @@ mod tests {
traits::{Crud, Followable},
utils::build_db_pool_for_tests,
};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
use pretty_assertions::assert_eq;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn test_crud() {
async fn test_crud() -> LemmyResult<()> {
let pool = &build_db_pool_for_tests().await;
let pool = &mut pool.into();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string())
.await
.unwrap();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?;
let new_person = PersonInsertForm::test_form(inserted_instance.id, "holly");
let inserted_person = Person::create(pool, &new_person).await.unwrap();
let inserted_person = Person::create(pool, &new_person).await?;
let expected_person = Person {
id: inserted_person.id,
@ -274,57 +270,54 @@ mod tests {
};
let read_person = Person::read(pool, inserted_person.id)
.await
.unwrap()
.unwrap();
.await?
.ok_or(LemmyErrorType::CouldntFindPerson)?;
let update_person_form = PersonUpdateForm {
actor_id: Some(inserted_person.actor_id.clone()),
..Default::default()
};
let updated_person = Person::update(pool, inserted_person.id, &update_person_form)
.await
.unwrap();
let updated_person = Person::update(pool, inserted_person.id, &update_person_form).await?;
let num_deleted = Person::delete(pool, inserted_person.id).await.unwrap();
Instance::delete(pool, inserted_instance.id).await.unwrap();
let num_deleted = Person::delete(pool, inserted_person.id).await?;
Instance::delete(pool, inserted_instance.id).await?;
assert_eq!(expected_person, read_person);
assert_eq!(expected_person, inserted_person);
assert_eq!(expected_person, updated_person);
assert_eq!(1, num_deleted);
Ok(())
}
#[tokio::test]
#[serial]
async fn follow() {
async fn follow() -> LemmyResult<()> {
let pool = &build_db_pool_for_tests().await;
let pool = &mut pool.into();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string())
.await
.unwrap();
let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?;
let person_form_1 = PersonInsertForm::test_form(inserted_instance.id, "erich");
let person_1 = Person::create(pool, &person_form_1).await.unwrap();
let person_1 = Person::create(pool, &person_form_1).await?;
let person_form_2 = PersonInsertForm::test_form(inserted_instance.id, "michele");
let person_2 = Person::create(pool, &person_form_2).await.unwrap();
let person_2 = Person::create(pool, &person_form_2).await?;
let follow_form = PersonFollowerForm {
person_id: person_1.id,
follower_id: person_2.id,
pending: false,
};
let person_follower = PersonFollower::follow(pool, &follow_form).await.unwrap();
let person_follower = PersonFollower::follow(pool, &follow_form).await?;
assert_eq!(person_1.id, person_follower.person_id);
assert_eq!(person_2.id, person_follower.follower_id);
assert!(!person_follower.pending);
let followers = PersonFollower::list_followers(pool, person_1.id)
.await
.unwrap();
let followers = PersonFollower::list_followers(pool, person_1.id).await?;
assert_eq!(vec![person_2], followers);
let unfollow = PersonFollower::unfollow(pool, &follow_form).await.unwrap();
let unfollow = PersonFollower::unfollow(pool, &follow_form).await?;
assert_eq!(1, unfollow);
Ok(())
}
}

View file

@ -1,6 +1,6 @@
use crate::{
diesel::OptionalExtension,
newtypes::LocalUserId,
newtypes::{LocalUserId, RegistrationApplicationId},
schema::registration_application::dsl::{local_user_id, registration_application},
source::registration_application::{
RegistrationApplication,
@ -17,7 +17,7 @@ use diesel_async::RunQueryDsl;
impl Crud for RegistrationApplication {
type InsertForm = RegistrationApplicationInsertForm;
type UpdateForm = RegistrationApplicationUpdateForm;
type IdType = i32;
type IdType = RegistrationApplicationId;
async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;

View file

@ -43,7 +43,7 @@ pub mod utils;
pub mod schema_setup;
use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString};
use strum::{Display, EnumString};
#[cfg(feature = "full")]
use ts_rs::TS;

View file

@ -107,7 +107,7 @@ pub struct PrivateMessageReportId(i32);
#[cfg_attr(feature = "full", derive(DieselNewType, TS))]
#[cfg_attr(feature = "full", ts(export))]
/// The site id.
pub struct SiteId(i32);
pub struct SiteId(pub i32);
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "full", derive(DieselNewType, TS))]
@ -148,6 +148,12 @@ pub struct LocalSiteId(i32);
/// The custom emoji id.
pub struct CustomEmojiId(i32);
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "full", derive(DieselNewType, TS))]
#[cfg_attr(feature = "full", ts(export))]
/// The registration application id.
pub struct RegistrationApplicationId(i32);
#[cfg(feature = "full")]
#[derive(Serialize, Deserialize)]
#[serde(remote = "Ltree")]

View file

@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
#[cfg(feature = "full")]
use ts_rs::TS;
use typed_builder::TypedBuilder;
#[skip_serializing_none]
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
@ -69,38 +68,59 @@ pub struct LocalUser {
pub collapse_bot_comments: bool,
}
#[derive(Clone, TypedBuilder)]
#[builder(field_defaults(default))]
#[derive(Clone, derive_new::new)]
#[cfg_attr(feature = "full", derive(Insertable))]
#[cfg_attr(feature = "full", diesel(table_name = local_user))]
pub struct LocalUserInsertForm {
#[builder(!default)]
pub person_id: PersonId,
#[builder(!default)]
pub password_encrypted: String,
#[new(default)]
pub email: Option<String>,
#[new(default)]
pub show_nsfw: Option<bool>,
#[new(default)]
pub theme: Option<String>,
#[new(default)]
pub default_sort_type: Option<SortType>,
#[new(default)]
pub default_listing_type: Option<ListingType>,
#[new(default)]
pub interface_language: Option<String>,
#[new(default)]
pub show_avatars: Option<bool>,
#[new(default)]
pub send_notifications_to_email: Option<bool>,
#[new(default)]
pub show_bot_accounts: Option<bool>,
#[new(default)]
pub show_scores: Option<bool>,
#[new(default)]
pub show_read_posts: Option<bool>,
#[new(default)]
pub email_verified: Option<bool>,
#[new(default)]
pub accepted_application: Option<bool>,
#[new(default)]
pub totp_2fa_secret: Option<Option<String>>,
#[new(default)]
pub open_links_in_new_tab: Option<bool>,
#[new(default)]
pub blur_nsfw: Option<bool>,
#[new(default)]
pub auto_expand: Option<bool>,
#[new(default)]
pub infinite_scroll_enabled: Option<bool>,
#[new(default)]
pub admin: Option<bool>,
#[new(default)]
pub post_listing_mode: Option<PostListingMode>,
#[new(default)]
pub totp_2fa_enabled: Option<bool>,
#[new(default)]
pub enable_keyboard_navigation: Option<bool>,
#[new(default)]
pub enable_animated_images: Option<bool>,
#[new(default)]
pub collapse_bot_comments: Option<bool>,
}

View file

@ -1,4 +1,4 @@
use crate::newtypes::{LocalUserId, PersonId};
use crate::newtypes::{LocalUserId, PersonId, RegistrationApplicationId};
#[cfg(feature = "full")]
use crate::schema::registration_application;
use chrono::{DateTime, Utc};
@ -15,7 +15,7 @@ use ts_rs::TS;
#[cfg_attr(feature = "full", ts(export))]
/// A registration application.
pub struct RegistrationApplication {
pub id: i32,
pub id: RegistrationApplicationId,
pub local_user_id: LocalUserId,
pub answer: String,
pub admin_id: Option<PersonId>,

View file

@ -1,6 +1,6 @@
use crate::{
diesel::ExpressionMethods,
newtypes::{DbUrl, PersonId},
newtypes::{DbUrl, CommentSortType, PersonId},
schema::community,
schema_setup,
CommentSortType,
@ -10,7 +10,6 @@ use crate::{
use chrono::{DateTime, TimeDelta, Utc};
use deadpool::Runtime;
use diesel::{
dsl,
helper_types::AsExprOf,
pg::Pg,
query_builder::{Query, QueryFragment},
@ -336,10 +335,6 @@ fn establish_connection(config: &str) -> BoxFuture<ConnectionResult<AsyncPgConne
let fut = async {
// We only support TLS with sslmode=require currently
let mut conn = if config.contains("sslmode=require") {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let rustls_config = DangerousClientConfigBuilder {
cfg: ClientConfig::builder(),
}
@ -593,20 +588,6 @@ impl<RF, LF> Queries<RF, LF> {
}
}
pub fn visible_communities_only<Q>(my_person_id: Option<PersonId>, query: Q) -> Q
where
Q: diesel::query_dsl::methods::FilterDsl<
dsl::Eq<community::visibility, CommunityVisibility>,
Output = Q,
>,
{
if my_person_id.is_none() {
query.filter(community::visibility.eq(CommunityVisibility::Public))
} else {
query
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)]
mod tests {

View file

@ -301,10 +301,7 @@ mod tests {
let inserted_timmy = Person::create(pool, &new_person).await.unwrap();
let new_local_user = LocalUserInsertForm::builder()
.person_id(inserted_timmy.id)
.password_encrypted("123".to_string())
.build();
let new_local_user = LocalUserInsertForm::test_form(inserted_timmy.id);
let timmy_local_user = LocalUser::create(pool, &new_local_user, vec![])
.await
.unwrap();

View file

@ -36,22 +36,13 @@ use lemmy_db_schema::{
post,
},
source::local_user::LocalUser,
utils::{
fuzzy_search,
limit_and_offset,
visible_communities_only,
DbConn,
DbPool,
ListFn,
Queries,
ReadFn,
},
utils::{fuzzy_search, limit_and_offset, DbConn, DbPool, ListFn, Queries, ReadFn},
CommentSortType,
ListingType,
};
fn queries<'a>() -> Queries<
impl ReadFn<'a, CommentView, (CommentId, Option<PersonId>)>,
impl ReadFn<'a, CommentView, (CommentId, Option<&'a LocalUser>)>,
impl ListFn<'a, CommentView, CommentQuery<'a>>,
> {
let is_creator_banned_from_community = exists(
@ -182,9 +173,12 @@ fn queries<'a>() -> Queries<
};
let read = move |mut conn: DbConn<'a>,
(comment_id, my_person_id): (CommentId, Option<PersonId>)| async move {
let mut query = all_joins(comment::table.find(comment_id).into_boxed(), my_person_id);
query = visible_communities_only(my_person_id, query);
(comment_id, my_local_user): (CommentId, Option<&'a LocalUser>)| async move {
let mut query = all_joins(
comment::table.find(comment_id).into_boxed(),
my_local_user.person_id(),
);
query = my_local_user.visible_communities_only(query);
query.first(&mut conn).await
};
@ -301,7 +295,7 @@ fn queries<'a>() -> Queries<
query = query.filter(not(is_creator_blocked(person_id_join)));
};
query = visible_communities_only(options.local_user.person_id(), query);
query = options.local_user.visible_communities_only(query);
// A Max depth given means its a tree fetch
let (limit, offset) = if let Some(max_depth) = options.max_depth {
@ -366,16 +360,16 @@ fn queries<'a>() -> Queries<
}
impl CommentView {
pub async fn read(
pub async fn read<'a>(
pool: &mut DbPool<'_>,
comment_id: CommentId,
my_person_id: Option<PersonId>,
my_local_user: Option<&'a LocalUser>,
) -> Result<Option<Self>, Error> {
// If a person is given, then my_vote (res.9), if None, should be 0, not null
// Necessary to differentiate between other person's votes
if let Ok(Some(res)) = queries().read(pool, (comment_id, my_person_id)).await {
if let Ok(Some(res)) = queries().read(pool, (comment_id, my_local_user)).await {
let mut new_view = res.clone();
if my_person_id.is_some() && res.my_vote.is_none() {
if my_local_user.is_some() && res.my_vote.is_none() {
new_view.my_vote = Some(0);
}
if res.comment.deleted || res.comment.removed {
@ -490,11 +484,8 @@ mod tests {
let timmy_person_form = PersonInsertForm::test_form(inserted_instance.id, "timmy");
let inserted_timmy_person = Person::create(pool, &timmy_person_form).await?;
let timmy_local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_timmy_person.id)
.admin(Some(true))
.password_encrypted(String::new())
.build();
let timmy_local_user_form = LocalUserInsertForm::test_form_admin(inserted_timmy_person.id);
let inserted_timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?;
let sara_person_form = PersonInsertForm::test_form(inserted_instance.id, "sara");
@ -676,7 +667,7 @@ mod tests {
let read_comment_from_blocked_person = CommentView::read(
pool,
data.inserted_comment_1.id,
Some(data.timmy_local_user_view.person.id),
Some(&data.timmy_local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
@ -1171,7 +1162,7 @@ mod tests {
let authenticated_comment = CommentView::read(
pool,
data.inserted_comment_0.id,
Some(data.timmy_local_user_view.person.id),
Some(&data.timmy_local_user_view.local_user),
)
.await;
assert!(authenticated_comment.is_ok());
@ -1211,7 +1202,7 @@ mod tests {
let comment_view = CommentView::read(
pool,
data.inserted_comment_0.id,
Some(inserted_banned_from_comm_local_user.person_id),
Some(&inserted_banned_from_comm_local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
@ -1232,7 +1223,7 @@ mod tests {
let comment_view = CommentView::read(
pool,
data.inserted_comment_0.id,
Some(data.timmy_local_user_view.person.id),
Some(&data.timmy_local_user_view.local_user),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;

View file

@ -323,10 +323,7 @@ mod tests {
let inserted_timmy = Person::create(pool, &new_person).await.unwrap();
let new_local_user = LocalUserInsertForm::builder()
.person_id(inserted_timmy.id)
.password_encrypted("123".to_string())
.build();
let new_local_user = LocalUserInsertForm::test_form(inserted_timmy.id);
let timmy_local_user = LocalUser::create(pool, &new_local_user, vec![])
.await
.unwrap();

View file

@ -49,7 +49,6 @@ use lemmy_db_schema::{
get_conn,
limit_and_offset,
now,
visible_communities_only,
Commented,
DbConn,
DbPool,
@ -64,7 +63,7 @@ use lemmy_db_schema::{
use tracing::debug;
fn queries<'a>() -> Queries<
impl ReadFn<'a, PostView, (PostId, Option<PersonId>, bool)>,
impl ReadFn<'a, PostView, (PostId, Option<&'a LocalUser>, bool)>,
impl ListFn<'a, PostView, (PostQuery<'a>, &'a Site)>,
> {
let is_creator_banned_from_community = exists(
@ -142,6 +141,7 @@ fn queries<'a>() -> Queries<
.single_value()
};
// TODO maybe this should go to localuser also
let all_joins = move |query: post_aggregates::BoxedQuery<'a, Pg>,
my_person_id: Option<PersonId>| {
let is_local_user_banned_from_community_selection: Box<
@ -250,52 +250,56 @@ fn queries<'a>() -> Queries<
))
};
let read =
move |mut conn: DbConn<'a>,
(post_id, my_person_id, is_mod_or_admin): (PostId, Option<PersonId>, bool)| async move {
// The left join below will return None in this case
let person_id_join = my_person_id.unwrap_or(PersonId(-1));
let read = move |mut conn: DbConn<'a>,
(post_id, my_local_user, is_mod_or_admin): (
PostId,
Option<&'a LocalUser>,
bool,
)| async move {
// The left join below will return None in this case
let my_person_id = my_local_user.person_id();
let person_id_join = my_person_id.unwrap_or(PersonId(-1));
let mut query = all_joins(
post_aggregates::table
.filter(post_aggregates::post_id.eq(post_id))
.into_boxed(),
my_person_id,
);
let mut query = all_joins(
post_aggregates::table
.filter(post_aggregates::post_id.eq(post_id))
.into_boxed(),
my_person_id,
);
// Hide deleted and removed for non-admins or mods
if !is_mod_or_admin {
query = query
.filter(
community::removed
.eq(false)
.or(post::creator_id.eq(person_id_join)),
)
.filter(
post::removed
.eq(false)
.or(post::creator_id.eq(person_id_join)),
)
// users can see their own deleted posts
.filter(
community::deleted
.eq(false)
.or(post::creator_id.eq(person_id_join)),
)
.filter(
post::deleted
.eq(false)
.or(post::creator_id.eq(person_id_join)),
);
}
// Hide deleted and removed for non-admins or mods
if !is_mod_or_admin {
query = query
.filter(
community::removed
.eq(false)
.or(post::creator_id.eq(person_id_join)),
)
.filter(
post::removed
.eq(false)
.or(post::creator_id.eq(person_id_join)),
)
// users can see their own deleted posts
.filter(
community::deleted
.eq(false)
.or(post::creator_id.eq(person_id_join)),
)
.filter(
post::deleted
.eq(false)
.or(post::creator_id.eq(person_id_join)),
);
}
query = visible_communities_only(my_person_id, query);
query = my_local_user.visible_communities_only(query);
Commented::new(query)
.text("PostView::read")
.first(&mut conn)
.await
};
Commented::new(query)
.text("PostView::read")
.first(&mut conn)
.await
};
let list = move |mut conn: DbConn<'a>, (options, site): (PostQuery<'a>, &'a Site)| async move {
// The left join below will return None in this case
@ -392,7 +396,10 @@ fn queries<'a>() -> Queries<
.filter(not(post::removed.or(post::deleted)));
}
if !options.local_user.show_nsfw(site) {
if !options
.show_nsfw
.unwrap_or(options.local_user.show_nsfw(site))
{
query = query
.filter(post::nsfw.eq(false))
.filter(community::nsfw.eq(false));
@ -437,7 +444,7 @@ fn queries<'a>() -> Queries<
}
};
query = visible_communities_only(options.local_user.person_id(), query);
query = options.local_user.visible_communities_only(query);
// Dont filter blocks or missing languages for moderator view type
if let (Some(person_id), false) = (
@ -552,14 +559,14 @@ fn queries<'a>() -> Queries<
}
impl PostView {
pub async fn read(
pub async fn read<'a>(
pool: &mut DbPool<'_>,
post_id: PostId,
my_person_id: Option<PersonId>,
my_local_user: Option<&'a LocalUser>,
is_mod_or_admin: bool,
) -> Result<Option<Self>, Error> {
queries()
.read(pool, (post_id, my_person_id, is_mod_or_admin))
.read(pool, (post_id, my_local_user, is_mod_or_admin))
.await
}
}
@ -617,6 +624,7 @@ pub struct PostQuery<'a> {
pub page_back: Option<bool>,
pub show_hidden: Option<bool>,
pub show_read: Option<bool>,
pub show_nsfw: Option<bool>,
}
impl<'a> PostQuery<'a> {
@ -938,7 +946,7 @@ mod tests {
let post_listing_single_with_person = PostView::read(
pool,
data.inserted_post.id,
Some(data.local_user_view.person.id),
Some(&data.local_user_view.local_user),
false,
)
.await?
@ -1067,7 +1075,7 @@ mod tests {
let post_listing_single_with_person = PostView::read(
pool,
data.inserted_post.id,
Some(data.local_user_view.person.id),
Some(&data.local_user_view.local_user),
false,
)
.await?
@ -1585,6 +1593,48 @@ mod tests {
cleanup(data, pool).await
}
#[tokio::test]
#[serial]
async fn post_listings_hide_nsfw() -> LemmyResult<()> {
let pool = &build_db_pool().await?;
let pool = &mut pool.into();
let data = init_data(pool).await?;
// Mark a post as nsfw
let update_form = PostUpdateForm {
nsfw: Some(true),
..Default::default()
};
Post::update(pool, data.inserted_bot_post.id, &update_form).await?;
// Make sure you don't see the nsfw post in the regular results
let post_listings_hide_nsfw = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(vec![POST], names(&post_listings_hide_nsfw));
// Make sure it does come back with the show_nsfw option
let post_listings_show_nsfw = PostQuery {
sort: Some(SortType::New),
show_nsfw: Some(true),
local_user: Some(&data.local_user_view.local_user),
..Default::default()
}
.list(&data.site, pool)
.await?;
assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_show_nsfw));
// Make sure that nsfw field is true.
assert!(
&post_listings_show_nsfw
.first()
.ok_or(LemmyErrorType::CouldntFindPost)?
.post
.nsfw
);
cleanup(data, pool).await
}
async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> {
let num_deleted = Post::delete(pool, data.inserted_post.id).await?;
Community::delete(pool, data.inserted_community.id).await?;
@ -1755,7 +1805,7 @@ mod tests {
let authenticated_post = PostView::read(
pool,
data.inserted_post.id,
Some(data.local_user_view.person.id),
Some(&data.local_user_view.local_user),
false,
)
.await;
@ -1797,7 +1847,7 @@ mod tests {
let post_view = PostView::read(
pool,
data.inserted_post.id,
Some(inserted_banned_from_comm_local_user.person_id),
Some(&inserted_banned_from_comm_local_user),
false,
)
.await?
@ -1819,7 +1869,7 @@ mod tests {
let post_view = PostView::read(
pool,
data.inserted_post.id,
Some(data.local_user_view.person.id),
Some(&data.local_user_view.local_user),
false,
)
.await?

View file

@ -11,12 +11,18 @@ use diesel::{
use diesel_async::RunQueryDsl;
use lemmy_db_schema::{
aliases,
newtypes::{PersonId, RegistrationApplicationId},
schema::{local_user, person, registration_application},
utils::{get_conn, limit_and_offset, DbConn, DbPool, ListFn, Queries, ReadFn},
};
enum ReadBy {
Id(RegistrationApplicationId),
Person(PersonId),
}
fn queries<'a>() -> Queries<
impl ReadFn<'a, RegistrationApplicationView, i32>,
impl ReadFn<'a, RegistrationApplicationView, ReadBy>,
impl ListFn<'a, RegistrationApplicationView, RegistrationApplicationQuery>,
> {
let all_joins = |query: registration_application::BoxedQuery<'a, Pg>| {
@ -36,14 +42,15 @@ fn queries<'a>() -> Queries<
))
};
let read = move |mut conn: DbConn<'a>, registration_application_id: i32| async move {
all_joins(
registration_application::table
.find(registration_application_id)
.into_boxed(),
)
.first(&mut conn)
.await
let read = move |mut conn: DbConn<'a>, search: ReadBy| async move {
let mut query = all_joins(registration_application::table.into_boxed());
query = match search {
ReadBy::Id(id) => query.filter(registration_application::id.eq(id)),
ReadBy::Person(person_id) => query.filter(person::id.eq(person_id)),
};
query.first(&mut conn).await
};
let list = move |mut conn: DbConn<'a>, options: RegistrationApplicationQuery| async move {
@ -76,11 +83,17 @@ fn queries<'a>() -> Queries<
impl RegistrationApplicationView {
pub async fn read(
pool: &mut DbPool<'_>,
registration_application_id: i32,
id: RegistrationApplicationId,
) -> Result<Option<Self>, Error> {
queries().read(pool, registration_application_id).await
queries().read(pool, ReadBy::Id(id)).await
}
pub async fn read_by_person(
pool: &mut DbPool<'_>,
person_id: PersonId,
) -> Result<Option<Self>, Error> {
queries().read(pool, ReadBy::Person(person_id)).await
}
/// Returns the current unread registration_application count
pub async fn get_unread_count(
pool: &mut DbPool<'_>,
@ -167,11 +180,7 @@ mod tests {
let inserted_timmy_person = Person::create(pool, &timmy_person_form).await.unwrap();
let timmy_local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_timmy_person.id)
.password_encrypted("nada".to_string())
.admin(Some(true))
.build();
let timmy_local_user_form = LocalUserInsertForm::test_form_admin(inserted_timmy_person.id);
let _inserted_timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![])
.await
@ -181,10 +190,7 @@ mod tests {
let inserted_sara_person = Person::create(pool, &sara_person_form).await.unwrap();
let sara_local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_sara_person.id)
.password_encrypted("nada".to_string())
.build();
let sara_local_user_form = LocalUserInsertForm::test_form(inserted_sara_person.id);
let inserted_sara_local_user = LocalUser::create(pool, &sara_local_user_form, vec![])
.await
@ -209,10 +215,7 @@ mod tests {
let inserted_jess_person = Person::create(pool, &jess_person_form).await.unwrap();
let jess_local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_jess_person.id)
.password_encrypted("nada".to_string())
.build();
let jess_local_user_form = LocalUserInsertForm::test_form(inserted_jess_person.id);
let inserted_jess_local_user = LocalUser::create(pool, &jess_local_user_form, vec![])
.await

View file

@ -33,7 +33,6 @@ serde_with = { workspace = true }
ts-rs = { workspace = true, optional = true }
chrono.workspace = true
strum = { workspace = true }
strum_macros = { workspace = true }
[dev-dependencies]
serial_test = { workspace = true }

View file

@ -2,10 +2,11 @@ use crate::structs::CommunityModeratorView;
use diesel::{dsl::exists, result::Error, select, ExpressionMethods, QueryDsl};
use diesel_async::RunQueryDsl;
use lemmy_db_schema::{
impls::local_user::LocalUserOptionHelper,
newtypes::{CommunityId, PersonId},
schema::{community, community_moderator, person},
source::local_user::LocalUser,
utils::{get_conn, DbPool},
CommunityVisibility,
};
impl CommunityModeratorView {
@ -60,20 +61,28 @@ impl CommunityModeratorView {
pub async fn for_person(
pool: &mut DbPool<'_>,
person_id: PersonId,
is_authenticated: bool,
local_user: Option<&LocalUser>,
) -> Result<Vec<Self>, Error> {
let conn = &mut get_conn(pool).await?;
let mut query = community_moderator::table
.inner_join(community::table)
.inner_join(person::table)
.filter(community_moderator::person_id.eq(person_id))
.filter(community::deleted.eq(false))
.filter(community::removed.eq(false))
.select((community::all_columns, person::all_columns))
.into_boxed();
if !is_authenticated {
query = query.filter(community::visibility.eq(CommunityVisibility::Public));
query = local_user.visible_communities_only(query);
// only show deleted communities to creator
if Some(person_id) != local_user.person_id() {
query = query.filter(community::deleted.eq(false));
}
// Show removed communities to admins only
if !local_user.is_admin() {
query = query.filter(community::removed.eq(false))
}
query.load::<CommunityModeratorView>(conn).await
}

View file

@ -22,27 +22,18 @@ use lemmy_db_schema::{
instance_block,
},
source::{community::CommunityFollower, local_user::LocalUser, site::Site},
utils::{
fuzzy_search,
limit_and_offset,
visible_communities_only,
DbConn,
DbPool,
ListFn,
Queries,
ReadFn,
},
utils::{fuzzy_search, limit_and_offset, DbConn, DbPool, ListFn, Queries, ReadFn},
ListingType,
SortType,
};
fn queries<'a>() -> Queries<
impl ReadFn<'a, CommunityView, (CommunityId, Option<PersonId>, bool)>,
impl ReadFn<'a, CommunityView, (CommunityId, Option<&'a LocalUser>, bool)>,
impl ListFn<'a, CommunityView, (CommunityQuery<'a>, &'a Site)>,
> {
let all_joins = |query: community::BoxedQuery<'a, Pg>, my_person_id: Option<PersonId>| {
let all_joins = |query: community::BoxedQuery<'a, Pg>, my_local_user: Option<&'a LocalUser>| {
// The left join below will return None in this case
let person_id_join = my_person_id.unwrap_or(PersonId(-1));
let person_id_join = my_local_user.person_id().unwrap_or(PersonId(-1));
query
.inner_join(community_aggregates::table)
@ -89,14 +80,14 @@ fn queries<'a>() -> Queries<
.and(community::deleted.eq(false));
let read = move |mut conn: DbConn<'a>,
(community_id, my_person_id, is_mod_or_admin): (
(community_id, my_local_user, is_mod_or_admin): (
CommunityId,
Option<PersonId>,
Option<&'a LocalUser>,
bool,
)| async move {
let mut query = all_joins(
community::table.find(community_id).into_boxed(),
my_person_id,
my_local_user,
)
.select(selection);
@ -105,7 +96,7 @@ fn queries<'a>() -> Queries<
query = query.filter(not_removed_or_deleted);
}
query = visible_communities_only(my_person_id, query);
query = my_local_user.visible_communities_only(query);
query.first(&mut conn).await
};
@ -116,11 +107,7 @@ fn queries<'a>() -> Queries<
// The left join below will return None in this case
let person_id_join = options.local_user.person_id().unwrap_or(PersonId(-1));
let mut query = all_joins(
community::table.into_boxed(),
options.local_user.person_id(),
)
.select(selection);
let mut query = all_joins(community::table.into_boxed(), options.local_user).select(selection);
if let Some(search_term) = options.search_term {
let searcher = fuzzy_search(&search_term);
@ -173,7 +160,7 @@ fn queries<'a>() -> Queries<
query = query.filter(community::nsfw.eq(false));
}
query = visible_communities_only(options.local_user.person_id(), query);
query = options.local_user.visible_communities_only(query);
let (limit, offset) = limit_and_offset(options.page, options.limit)?;
query
@ -187,14 +174,14 @@ fn queries<'a>() -> Queries<
}
impl CommunityView {
pub async fn read(
pub async fn read<'a>(
pool: &mut DbPool<'_>,
community_id: CommunityId,
my_person_id: Option<PersonId>,
my_local_user: Option<&'a LocalUser>,
is_mod_or_admin: bool,
) -> Result<Option<Self>, Error> {
queries()
.read(pool, (community_id, my_person_id, is_mod_or_admin))
.read(pool, (community_id, my_local_user, is_mod_or_admin))
.await
}
@ -288,10 +275,7 @@ mod tests {
let inserted_person = Person::create(pool, &new_person).await.unwrap();
let local_user_form = LocalUserInsertForm::builder()
.person_id(inserted_person.id)
.password_encrypted(String::new())
.build();
let local_user_form = LocalUserInsertForm::test_form(inserted_person.id);
let local_user = LocalUser::create(pool, &local_user_form, vec![])
.await
.unwrap();
@ -388,7 +372,7 @@ mod tests {
let authenticated_community = CommunityView::read(
pool,
data.inserted_community.id,
Some(data.local_user.person_id),
Some(&data.local_user),
false,
)
.await;

View file

@ -27,7 +27,7 @@ use lemmy_db_schema::{
SortType,
};
use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString};
use strum::{Display, EnumString};
enum ListMode {
Admins,
@ -196,10 +196,7 @@ mod tests {
..PersonInsertForm::test_form(inserted_instance.id, "alice")
};
let alice = Person::create(pool, &alice_form).await?;
let alice_local_user_form = LocalUserInsertForm::builder()
.person_id(alice.id)
.password_encrypted(String::new())
.build();
let alice_local_user_form = LocalUserInsertForm::test_form(alice.id);
let alice_local_user = LocalUser::create(pool, &alice_local_user_form, vec![]).await?;
let bob_form = PersonInsertForm {
@ -208,10 +205,7 @@ mod tests {
..PersonInsertForm::test_form(inserted_instance.id, "bob")
};
let bob = Person::create(pool, &bob_form).await?;
let bob_local_user_form = LocalUserInsertForm::builder()
.person_id(bob.id)
.password_encrypted(String::new())
.build();
let bob_local_user_form = LocalUserInsertForm::test_form(bob.id);
let bob_local_user = LocalUser::create(pool, &bob_local_user_form, vec![]).await?;
Ok(Data {

View file

@ -34,6 +34,13 @@ tokio = { workspace = true, features = ["full"] }
tracing.workspace = true
moka.workspace = true
tokio-util = "0.7.11"
async-trait.workspace = true
[dev-dependencies]
serial_test = { workspace = true }
url.workspace = true
actix-web.workspace = true
tracing-test = "0.2.5"
uuid.workspace = true
test-context = "0.3.0"
mockall = "0.12.1"

View file

@ -0,0 +1,572 @@
use crate::util::LEMMY_TEST_FAST_FEDERATION;
use anyhow::Result;
use async_trait::async_trait;
use chrono::{DateTime, TimeZone, Utc};
use lemmy_db_schema::{
newtypes::{CommunityId, DbUrl, InstanceId},
source::{activity::SentActivity, site::Site},
utils::{ActualDbPool, DbPool},
};
use lemmy_db_views_actor::structs::CommunityFollowerView;
use once_cell::sync::Lazy;
use reqwest::Url;
use std::collections::{HashMap, HashSet};
/// interval with which new additions to community_followers are queried.
///
/// The first time some user on an instance follows a specific remote community (or, more precisely:
/// the first time a (followed_community_id, follower_inbox_url) tuple appears), this delay limits
/// the maximum time until the follow actually results in activities from that community id being
/// sent to that inbox url. This delay currently needs to not be too small because the DB load is
/// currently fairly high because of the current structure of storing inboxes for every person, not
/// having a separate list of shared_inboxes, and the architecture of having every instance queue be
/// fully separate. (see https://github.com/LemmyNet/lemmy/issues/3958)
static FOLLOW_ADDITIONS_RECHECK_DELAY: Lazy<chrono::TimeDelta> = Lazy::new(|| {
if *LEMMY_TEST_FAST_FEDERATION {
chrono::TimeDelta::try_seconds(1).expect("TimeDelta out of bounds")
} else {
chrono::TimeDelta::try_minutes(2).expect("TimeDelta out of bounds")
}
});
/// The same as FOLLOW_ADDITIONS_RECHECK_DELAY, but triggering when the last person on an instance
/// unfollows a specific remote community. This is expected to happen pretty rarely and updating it
/// in a timely manner is not too important.
static FOLLOW_REMOVALS_RECHECK_DELAY: Lazy<chrono::TimeDelta> =
Lazy::new(|| chrono::TimeDelta::try_hours(1).expect("TimeDelta out of bounds"));
#[async_trait]
pub trait DataSource: Send + Sync {
async fn read_site_from_instance_id(
&self,
instance_id: InstanceId,
) -> Result<Option<Site>, diesel::result::Error>;
async fn get_instance_followed_community_inboxes(
&self,
instance_id: InstanceId,
last_fetch: DateTime<Utc>,
) -> Result<Vec<(CommunityId, DbUrl)>, diesel::result::Error>;
}
pub struct DbDataSource {
pool: ActualDbPool,
}
impl DbDataSource {
pub fn new(pool: ActualDbPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl DataSource for DbDataSource {
async fn read_site_from_instance_id(
&self,
instance_id: InstanceId,
) -> Result<Option<Site>, diesel::result::Error> {
Site::read_from_instance_id(&mut DbPool::Pool(&self.pool), instance_id).await
}
async fn get_instance_followed_community_inboxes(
&self,
instance_id: InstanceId,
last_fetch: DateTime<Utc>,
) -> Result<Vec<(CommunityId, DbUrl)>, diesel::result::Error> {
CommunityFollowerView::get_instance_followed_community_inboxes(
&mut DbPool::Pool(&self.pool),
instance_id,
last_fetch,
)
.await
}
}
pub(crate) struct CommunityInboxCollector<T: DataSource> {
// load site lazily because if an instance is first seen due to being on allowlist,
// the corresponding row in `site` may not exist yet since that is only added once
// `fetch_instance_actor_for_object` is called.
// (this should be unlikely to be relevant outside of the federation tests)
site_loaded: bool,
site: Option<Site>,
followed_communities: HashMap<CommunityId, HashSet<Url>>,
last_full_communities_fetch: DateTime<Utc>,
last_incremental_communities_fetch: DateTime<Utc>,
instance_id: InstanceId,
domain: String,
pub(crate) data_source: T,
}
pub type RealCommunityInboxCollector = CommunityInboxCollector<DbDataSource>;
impl<T: DataSource> CommunityInboxCollector<T> {
pub fn new_real(
pool: ActualDbPool,
instance_id: InstanceId,
domain: String,
) -> RealCommunityInboxCollector {
CommunityInboxCollector::new(DbDataSource::new(pool), instance_id, domain)
}
pub fn new(
data_source: T,
instance_id: InstanceId,
domain: String,
) -> CommunityInboxCollector<T> {
CommunityInboxCollector {
data_source,
site_loaded: false,
site: None,
followed_communities: HashMap::new(),
last_full_communities_fetch: Utc.timestamp_nanos(0),
last_incremental_communities_fetch: Utc.timestamp_nanos(0),
instance_id,
domain,
}
}
/// get inbox urls of sending the given activity to the given instance
/// most often this will return 0 values (if instance doesn't care about the activity)
/// or 1 value (the shared inbox)
/// > 1 values only happens for non-lemmy software
pub async fn get_inbox_urls(&mut self, activity: &SentActivity) -> Result<Vec<Url>> {
let mut inbox_urls: HashSet<Url> = HashSet::new();
if activity.send_all_instances {
if !self.site_loaded {
self.site = self
.data_source
.read_site_from_instance_id(self.instance_id)
.await?;
self.site_loaded = true;
}
if let Some(site) = &self.site {
// Nutomic: Most non-lemmy software wont have a site row. That means it cant handle these
// activities. So handling it like this is fine.
inbox_urls.insert(site.inbox_url.inner().clone());
}
}
if let Some(t) = &activity.send_community_followers_of {
if let Some(urls) = self.followed_communities.get(t) {
inbox_urls.extend(urls.iter().cloned());
}
}
inbox_urls.extend(
activity
.send_inboxes
.iter()
.filter_map(std::option::Option::as_ref)
// a similar filter also happens within the activitypub-federation crate. but that filter
// happens much later - by doing it here, we can ensure that in the happy case, this
// function returns 0 urls which means the system doesn't have to create a tokio
// task for sending at all (since that task has a fair amount of overhead)
.filter(|&u| (u.domain() == Some(&self.domain)))
.map(|u| u.inner().clone()),
);
tracing::trace!(
"get_inbox_urls: {:?}, send_inboxes: {:?}",
inbox_urls,
activity.send_inboxes
);
Ok(inbox_urls.into_iter().collect())
}
pub async fn update_communities(&mut self) -> Result<()> {
if (Utc::now() - self.last_full_communities_fetch) > *FOLLOW_REMOVALS_RECHECK_DELAY {
tracing::debug!("{}: fetching full list of communities", self.domain);
// process removals every hour
(self.followed_communities, self.last_full_communities_fetch) = self
.get_communities(self.instance_id, Utc.timestamp_nanos(0))
.await?;
self.last_incremental_communities_fetch = self.last_full_communities_fetch;
}
if (Utc::now() - self.last_incremental_communities_fetch) > *FOLLOW_ADDITIONS_RECHECK_DELAY {
// process additions every minute
let (news, time) = self
.get_communities(self.instance_id, self.last_incremental_communities_fetch)
.await?;
if !news.is_empty() {
tracing::debug!(
"{}: fetched {} incremental new followed communities",
self.domain,
news.len()
);
}
self.followed_communities.extend(news);
self.last_incremental_communities_fetch = time;
}
Ok(())
}
/// get a list of local communities with the remote inboxes on the given instance that cares about
/// them
async fn get_communities(
&mut self,
instance_id: InstanceId,
last_fetch: DateTime<Utc>,
) -> Result<(HashMap<CommunityId, HashSet<Url>>, DateTime<Utc>)> {
// update to time before fetch to ensure overlap. subtract some time to ensure overlap even if
// published date is not exact
let new_last_fetch = Utc::now() - *FOLLOW_ADDITIONS_RECHECK_DELAY / 2;
let inboxes = self
.data_source
.get_instance_followed_community_inboxes(instance_id, last_fetch)
.await?;
let map: HashMap<CommunityId, HashSet<Url>> =
inboxes.into_iter().fold(HashMap::new(), |mut map, (c, u)| {
map.entry(c).or_default().insert(u.into());
map
});
Ok((map, new_last_fetch))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod tests {
use super::*;
use lemmy_db_schema::{
newtypes::{ActivityId, CommunityId, InstanceId, SiteId},
source::activity::{ActorType, SentActivity},
};
use mockall::{mock, predicate::*};
use serde_json::json;
mock! {
DataSource {}
#[async_trait]
impl DataSource for DataSource {
async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> Result<Option<Site>, diesel::result::Error>;
async fn get_instance_followed_community_inboxes(
&self,
instance_id: InstanceId,
last_fetch: DateTime<Utc>,
) -> Result<Vec<(CommunityId, DbUrl)>, diesel::result::Error>;
}
}
fn setup_collector() -> CommunityInboxCollector<MockDataSource> {
let mock_data_source = MockDataSource::new();
let instance_id = InstanceId(1);
let domain = "example.com".to_string();
CommunityInboxCollector::new(mock_data_source, instance_id, domain)
}
#[tokio::test]
async fn test_get_inbox_urls_empty() {
let mut collector = setup_collector();
let activity = SentActivity {
id: ActivityId(1),
ap_id: Url::parse("https://example.com/activities/1")
.unwrap()
.into(),
data: json!({}),
sensitive: false,
published: Utc::now(),
send_inboxes: vec![],
send_community_followers_of: None,
send_all_instances: false,
actor_type: ActorType::Person,
actor_apub_id: None,
};
let result = collector.get_inbox_urls(&activity).await.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn test_get_inbox_urls_send_all_instances() {
let mut collector = setup_collector();
let site_inbox = Url::parse("https://example.com/inbox").unwrap();
let site = Site {
id: SiteId(1),
name: "Test Site".to_string(),
sidebar: None,
published: Utc::now(),
updated: None,
icon: None,
banner: None,
description: None,
actor_id: Url::parse("https://example.com/site").unwrap().into(),
last_refreshed_at: Utc::now(),
inbox_url: site_inbox.clone().into(),
private_key: None,
public_key: "test_key".to_string(),
instance_id: InstanceId(1),
content_warning: None,
};
collector
.data_source
.expect_read_site_from_instance_id()
.return_once(move |_| Ok(Some(site)));
let activity = SentActivity {
id: ActivityId(1),
ap_id: Url::parse("https://example.com/activities/1")
.unwrap()
.into(),
data: json!({}),
sensitive: false,
published: Utc::now(),
send_inboxes: vec![],
send_community_followers_of: None,
send_all_instances: true,
actor_type: ActorType::Person,
actor_apub_id: None,
};
let result = collector.get_inbox_urls(&activity).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0], site_inbox);
}
#[tokio::test]
async fn test_get_inbox_urls_community_followers() {
let mut collector = setup_collector();
let community_id = CommunityId(1);
let url1 = "https://follower1.example.com/inbox";
let url2 = "https://follower2.example.com/inbox";
collector
.data_source
.expect_get_instance_followed_community_inboxes()
.return_once(move |_, _| {
Ok(vec![
(community_id, Url::parse(url1).unwrap().into()),
(community_id, Url::parse(url2).unwrap().into()),
])
});
collector.update_communities().await.unwrap();
let activity = SentActivity {
id: ActivityId(1),
ap_id: Url::parse("https://example.com/activities/1")
.unwrap()
.into(),
data: json!({}),
sensitive: false,
published: Utc::now(),
send_inboxes: vec![],
send_community_followers_of: Some(community_id),
send_all_instances: false,
actor_type: ActorType::Person,
actor_apub_id: None,
};
let result = collector.get_inbox_urls(&activity).await.unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains(&Url::parse(url1).unwrap()));
assert!(result.contains(&Url::parse(url2).unwrap()));
}
#[tokio::test]
async fn test_get_inbox_urls_send_inboxes() {
let mut collector = setup_collector();
collector.domain = "example.com".to_string();
let inbox_user_1 = Url::parse("https://example.com/user1/inbox").unwrap();
let inbox_user_2 = Url::parse("https://example.com/user2/inbox").unwrap();
let other_domain_inbox = Url::parse("https://other-domain.com/user3/inbox").unwrap();
let activity = SentActivity {
id: ActivityId(1),
ap_id: Url::parse("https://example.com/activities/1")
.unwrap()
.into(),
data: json!({}),
sensitive: false,
published: Utc::now(),
send_inboxes: vec![
Some(inbox_user_1.clone().into()),
Some(inbox_user_2.clone().into()),
Some(other_domain_inbox.clone().into()),
],
send_community_followers_of: None,
send_all_instances: false,
actor_type: ActorType::Person,
actor_apub_id: None,
};
let result = collector.get_inbox_urls(&activity).await.unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains(&inbox_user_1));
assert!(result.contains(&inbox_user_2));
assert!(!result.contains(&other_domain_inbox));
}
#[tokio::test]
async fn test_get_inbox_urls_combined() {
let mut collector = setup_collector();
collector.domain = "example.com".to_string();
let community_id = CommunityId(1);
let site_inbox = Url::parse("https://example.com/site_inbox").unwrap();
let site = Site {
id: SiteId(1),
name: "Test Site".to_string(),
sidebar: None,
published: Utc::now(),
updated: None,
icon: None,
banner: None,
description: None,
actor_id: Url::parse("https://example.com/site").unwrap().into(),
last_refreshed_at: Utc::now(),
inbox_url: site_inbox.clone().into(),
private_key: None,
public_key: "test_key".to_string(),
instance_id: InstanceId(1),
content_warning: None,
};
collector
.data_source
.expect_read_site_from_instance_id()
.return_once(move |_| Ok(Some(site)));
let subdomain_inbox = "https://follower.example.com/inbox";
collector
.data_source
.expect_get_instance_followed_community_inboxes()
.return_once(move |_, _| {
Ok(vec![(
community_id,
Url::parse(subdomain_inbox).unwrap().into(),
)])
});
collector.update_communities().await.unwrap();
let user1_inbox = Url::parse("https://example.com/user1/inbox").unwrap();
let user2_inbox = Url::parse("https://other-domain.com/user2/inbox").unwrap();
let activity = SentActivity {
id: ActivityId(1),
ap_id: Url::parse("https://example.com/activities/1")
.unwrap()
.into(),
data: json!({}),
sensitive: false,
published: Utc::now(),
send_inboxes: vec![
Some(user1_inbox.clone().into()),
Some(user2_inbox.clone().into()),
],
send_community_followers_of: Some(community_id),
send_all_instances: true,
actor_type: ActorType::Person,
actor_apub_id: None,
};
let result = collector.get_inbox_urls(&activity).await.unwrap();
assert_eq!(result.len(), 3);
assert!(result.contains(&site_inbox));
assert!(result.contains(&Url::parse(subdomain_inbox).unwrap()));
assert!(result.contains(&user1_inbox));
assert!(!result.contains(&user2_inbox));
}
#[tokio::test]
async fn test_update_communities() {
let mut collector = setup_collector();
let community_id1 = CommunityId(1);
let community_id2 = CommunityId(2);
let community_id3 = CommunityId(3);
let user1_inbox_str = "https://follower1.example.com/inbox";
let user1_inbox = Url::parse(user1_inbox_str).unwrap();
let user2_inbox_str = "https://follower2.example.com/inbox";
let user2_inbox = Url::parse(user2_inbox_str).unwrap();
let user3_inbox_str = "https://follower3.example.com/inbox";
let user3_inbox = Url::parse(user3_inbox_str).unwrap();
collector
.data_source
.expect_get_instance_followed_community_inboxes()
.times(2)
.returning(move |_, last_fetch| {
if last_fetch == Utc.timestamp_nanos(0) {
Ok(vec![
(community_id1, Url::parse(user1_inbox_str).unwrap().into()),
(community_id2, Url::parse(user2_inbox_str).unwrap().into()),
])
} else {
Ok(vec![(
community_id3,
Url::parse(user3_inbox_str).unwrap().into(),
)])
}
});
// First update
collector.update_communities().await.unwrap();
assert_eq!(collector.followed_communities.len(), 2);
assert!(collector.followed_communities[&community_id1].contains(&user1_inbox));
assert!(collector.followed_communities[&community_id2].contains(&user2_inbox));
// Simulate time passing
collector.last_full_communities_fetch = Utc::now() - chrono::TimeDelta::try_minutes(3).unwrap();
collector.last_incremental_communities_fetch =
Utc::now() - chrono::TimeDelta::try_minutes(3).unwrap();
// Second update (incremental)
collector.update_communities().await.unwrap();
assert_eq!(collector.followed_communities.len(), 3);
assert!(collector.followed_communities[&community_id1].contains(&user1_inbox));
assert!(collector.followed_communities[&community_id3].contains(&user3_inbox));
assert!(collector.followed_communities[&community_id2].contains(&user2_inbox));
}
#[tokio::test]
async fn test_get_inbox_urls_no_duplicates() {
let mut collector = setup_collector();
collector.domain = "example.com".to_string();
let community_id = CommunityId(1);
let site_inbox = Url::parse("https://example.com/site_inbox").unwrap();
let site_inbox_clone = site_inbox.clone();
let site = Site {
id: SiteId(1),
name: "Test Site".to_string(),
sidebar: None,
published: Utc::now(),
updated: None,
icon: None,
banner: None,
description: None,
actor_id: Url::parse("https://example.com/site").unwrap().into(),
last_refreshed_at: Utc::now(),
inbox_url: site_inbox.clone().into(),
private_key: None,
public_key: "test_key".to_string(),
instance_id: InstanceId(1),
content_warning: None,
};
collector
.data_source
.expect_read_site_from_instance_id()
.return_once(move |_| Ok(Some(site)));
collector
.data_source
.expect_get_instance_followed_community_inboxes()
.return_once(move |_, _| Ok(vec![(community_id, site_inbox_clone.into())]));
collector.update_communities().await.unwrap();
let activity = SentActivity {
id: ActivityId(1),
ap_id: Url::parse("https://example.com/activities/1")
.unwrap()
.into(),
data: json!({}),
sensitive: false,
published: Utc::now(),
send_inboxes: vec![Some(site_inbox.into())],
send_community_followers_of: Some(community_id),
send_all_instances: true,
actor_type: ActorType::Person,
actor_apub_id: None,
};
let result = collector.get_inbox_urls(&activity).await.unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains(&Url::parse("https://example.com/site_inbox").unwrap()));
}
}

View file

@ -1,6 +1,9 @@
use crate::{util::CancellableTask, worker::InstanceWorker};
use activitypub_federation::config::FederationConfig;
use lemmy_api_common::context::LemmyContext;
use lemmy_api_common::{
context::LemmyContext,
lemmy_utils::settings::structs::FederationWorkerConfig,
};
use lemmy_db_schema::{newtypes::InstanceId, source::instance::Instance};
use lemmy_utils::error::LemmyResult;
use stats::receive_print_stats;
@ -14,6 +17,8 @@ use tokio_util::sync::CancellationToken;
use tracing::info;
use util::FederationQueueStateWithDomain;
mod inboxes;
mod send;
mod stats;
mod util;
mod worker;
@ -38,10 +43,15 @@ pub struct SendManager {
context: FederationConfig<LemmyContext>,
stats_sender: UnboundedSender<FederationQueueStateWithDomain>,
exit_print: JoinHandle<()>,
federation_worker_config: FederationWorkerConfig,
}
impl SendManager {
fn new(opts: Opts, context: FederationConfig<LemmyContext>) -> Self {
fn new(
opts: Opts,
context: FederationConfig<LemmyContext>,
federation_worker_config: FederationWorkerConfig,
) -> Self {
assert!(opts.process_count > 0);
assert!(opts.process_index > 0);
assert!(opts.process_index <= opts.process_count);
@ -56,14 +66,20 @@ impl SendManager {
stats_receiver,
)),
context,
federation_worker_config,
}
}
pub fn run(opts: Opts, context: FederationConfig<LemmyContext>) -> CancellableTask {
pub fn run(
opts: Opts,
context: FederationConfig<LemmyContext>,
config: FederationWorkerConfig,
) -> CancellableTask {
CancellableTask::spawn(WORKER_EXIT_TIMEOUT, move |cancel| {
let opts = opts.clone();
let config = config.clone();
let context = context.clone();
let mut manager = Self::new(opts, context);
let mut manager = Self::new(opts, context, config);
async move {
let result = manager.do_loop(cancel).await;
// the loop function will only return if there is (a) an internal error (e.g. db connection
@ -120,22 +136,21 @@ impl SendManager {
// create new worker
let context = self.context.clone();
let stats_sender = self.stats_sender.clone();
let federation_worker_config = self.federation_worker_config.clone();
self.workers.insert(
instance.id,
CancellableTask::spawn(WORKER_EXIT_TIMEOUT, move |stop| {
// if the instance worker ends unexpectedly due to internal/db errors, this lambda is rerun by cancellabletask.
// if the instance worker ends unexpectedly due to internal/db errors, this lambda is
// rerun by cancellabletask.
let instance = instance.clone();
let req_data = context.to_request_data();
let stats_sender = stats_sender.clone();
async move {
InstanceWorker::init_and_loop(
instance,
req_data,
stop,
stats_sender,
)
.await
}
InstanceWorker::init_and_loop(
instance,
context.clone(),
federation_worker_config.clone(),
stop,
stats_sender.clone(),
)
}),
);
} else if !should_federate {
@ -214,7 +229,14 @@ mod test {
.app_data(context.clone())
.build()
.await?;
let concurrent_sends_per_instance = std::env::var("LEMMY_TEST_FEDERATION_CONCURRENT_SENDS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let federation_worker_config = FederationWorkerConfig {
concurrent_sends_per_instance,
};
let pool = &mut context.pool();
let instances = vec![
Instance::read_or_create(pool, "alpha.com".to_string()).await?,
@ -222,7 +244,7 @@ mod test {
Instance::read_or_create(pool, "gamma.com".to_string()).await?,
];
let send_manager = SendManager::new(opts, federation_config);
let send_manager = SendManager::new(opts, federation_config, federation_worker_config);
Ok(Self {
send_manager,
context,

148
crates/federate/src/send.rs Normal file
View file

@ -0,0 +1,148 @@
use crate::util::get_actor_cached;
use activitypub_federation::{
activity_sending::SendActivityTask,
config::Data,
protocol::context::WithContext,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use lemmy_api_common::{context::LemmyContext, federate_retry_sleep_duration};
use lemmy_apub::{activity_lists::SharedInboxActivities, FEDERATION_CONTEXT};
use lemmy_db_schema::{newtypes::ActivityId, source::activity::SentActivity};
use reqwest::Url;
use std::ops::Deref;
use tokio::{sync::mpsc::UnboundedSender, time::sleep};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Eq)]
pub(crate) struct SendSuccessInfo {
pub activity_id: ActivityId,
pub published: Option<DateTime<Utc>>,
// true if the activity was skipped because the target instance is not interested in this
// activity
pub was_skipped: bool,
}
impl PartialEq for SendSuccessInfo {
fn eq(&self, other: &Self) -> bool {
self.activity_id == other.activity_id
}
}
/// order backwards because the binary heap is a max heap, and we need the smallest element to be on
/// top
impl PartialOrd for SendSuccessInfo {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SendSuccessInfo {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.activity_id.cmp(&self.activity_id)
}
}
/// Represents the result of sending an activity.
///
/// This enum is used to communicate the outcome of a send operation from a send task
/// to the main instance worker. It's designed to maintain a clean separation between
/// the send task and the main thread, allowing the send.rs file to be self-contained
/// and easier to understand.
///
/// The use of a channel for communication (rather than shared atomic variables) was chosen
/// because:
/// 1. It keeps the send task cleanly separated with no direct interaction with the main thread.
/// 2. The failure event needs to be transferred to the main task for database updates anyway.
/// 3. The main fail_count should only be updated under certain conditions, which are best handled
/// in the main task.
/// 4. It maintains consistency in how data is communicated (all via channels rather than a mix of
/// channels and atomics).
/// 5. It simplifies concurrency management and makes the flow of data more predictable.
pub(crate) enum SendActivityResult {
Success(SendSuccessInfo),
Failure { fail_count: i32 },
}
/// Represents a task for retrying to send an activity.
///
/// This struct encapsulates all the necessary information and resources for attempting
/// to send an activity to multiple inbox URLs, with built-in retry logic.
pub(crate) struct SendRetryTask<'a> {
pub activity: &'a SentActivity,
pub object: &'a SharedInboxActivities,
/// Must not be empty at this point
pub inbox_urls: Vec<Url>,
/// Channel to report results back to the main instance worker
pub report: &'a mut UnboundedSender<SendActivityResult>,
/// The first request will be sent immediately, but subsequent requests will be delayed
/// according to the number of previous fails + 1
///
/// This is a read-only immutable variable that is passed only one way, from the main
/// thread to each send task. It allows the task to determine how long to sleep initially
/// if the request fails.
pub initial_fail_count: i32,
/// For logging purposes
pub domain: String,
pub context: Data<LemmyContext>,
pub stop: CancellationToken,
}
impl<'a> SendRetryTask<'a> {
// this function will return successfully when (a) send succeeded or (b) worker cancelled
// and will return an error if an internal error occurred (send errors cause an infinite loop)
pub async fn send_retry_loop(self) -> Result<()> {
let SendRetryTask {
activity,
object,
inbox_urls,
report,
initial_fail_count,
domain,
context,
stop,
} = self;
debug_assert!(!inbox_urls.is_empty());
let pool = &mut context.pool();
let Some(actor_apub_id) = &activity.actor_apub_id else {
return Err(anyhow::anyhow!("activity is from before lemmy 0.19"));
};
let actor = get_actor_cached(pool, activity.actor_type, actor_apub_id)
.await
.context("failed getting actor instance (was it marked deleted / removed?)")?;
let object = WithContext::new(object.clone(), FEDERATION_CONTEXT.deref().clone());
let requests = SendActivityTask::prepare(&object, actor.as_ref(), inbox_urls, &context).await?;
for task in requests {
// usually only one due to shared inbox
tracing::debug!("sending out {}", task);
let mut fail_count = initial_fail_count;
while let Err(e) = task.sign_and_send(&context).await {
fail_count += 1;
report.send(SendActivityResult::Failure {
fail_count,
// activity_id: activity.id,
})?;
let retry_delay = federate_retry_sleep_duration(fail_count);
tracing::info!(
"{}: retrying {:?} attempt {} with delay {retry_delay:.2?}. ({e})",
domain,
activity.id,
fail_count
);
tokio::select! {
() = sleep(retry_delay) => {},
() = stop.cancelled() => {
// cancel sending without reporting any result.
// the InstanceWorker needs to be careful to not hang on receive of that
// channel when cancelled (see handle_send_results)
return Ok(());
}
}
}
}
report.send(SendActivityResult::Success(SendSuccessInfo {
activity_id: activity.id,
published: Some(activity.published),
was_skipped: false,
}))?;
Ok(())
}
}

View file

@ -1,7 +1,6 @@
use anyhow::{anyhow, Context, Result};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use lemmy_api_common::lemmy_utils::CACHE_DURATION_FEDERATION;
use lemmy_apub::{
activity_lists::SharedInboxActivities,
fetcher::{site_or_community_or_user::SiteOrCommunityOrUser, user_or_community::UserOrCommunity},
@ -28,19 +27,28 @@ use tokio_util::sync::CancellationToken;
/// Decrease the delays of the federation queue.
/// Should only be used for federation tests since it significantly increases CPU and DB load of the
/// federation queue.
/// federation queue. This is intentionally a separate flag from other flags like debug_assertions,
/// since this is a invasive change we only need rarely.
pub(crate) static LEMMY_TEST_FAST_FEDERATION: Lazy<bool> = Lazy::new(|| {
std::env::var("LEMMY_TEST_FAST_FEDERATION")
.map(|s| !s.is_empty())
.unwrap_or(false)
});
/// Recheck for new federation work every n seconds.
/// Recheck for new federation work every n seconds within each InstanceWorker.
///
/// When the queue is processed faster than new activities are added and it reaches the current time
/// with an empty batch, this is the delay the queue waits before it checks if new activities have
/// been added to the sent_activities table. This delay is only applied if no federated activity
/// happens during sending activities of the last batch.
/// happens during sending activities of the last batch, which means on high-activity instances it
/// may never be used. This means that it does not affect the maximum throughput of the queue.
///
///
/// This is thus the interval with which tokio wakes up each of the
/// InstanceWorkers to check for new work, if the queue previously was empty.
/// If the delay is too short, the workers (one per federated instance) will wake up too
/// often and consume a lot of CPU. If the delay is long, then activities on low-traffic instances
/// will on average take delay/2 seconds to federate.
pub(crate) static WORK_FINISHED_RECHECK_DELAY: Lazy<Duration> = Lazy::new(|| {
if *LEMMY_TEST_FAST_FEDERATION {
Duration::from_millis(100)
@ -49,6 +57,21 @@ pub(crate) static WORK_FINISHED_RECHECK_DELAY: Lazy<Duration> = Lazy::new(|| {
}
});
/// Cache the latest activity id for a certain duration.
///
/// This cache is common to all the instance workers and prevents there from being more than one
/// call per N seconds between each DB query to find max(activity_id).
pub(crate) static CACHE_DURATION_LATEST_ID: Lazy<Duration> = Lazy::new(|| {
if *LEMMY_TEST_FAST_FEDERATION {
// in test mode, we use the same cache duration as the recheck delay so when recheck happens
// data is fresh, accelerating the time the tests take.
*WORK_FINISHED_RECHECK_DELAY
} else {
// in normal mode, we limit the query to one per second
Duration::from_secs(1)
}
});
/// A task that will be run in an infinite loop, unless it is cancelled.
/// If the task exits without being cancelled, an error will be logged and the task will be
/// restarted.
@ -174,7 +197,7 @@ pub(crate) async fn get_activity_cached(
pub(crate) async fn get_latest_activity_id(pool: &mut DbPool<'_>) -> Result<ActivityId> {
static CACHE: Lazy<Cache<(), ActivityId>> = Lazy::new(|| {
Cache::builder()
.time_to_live(CACHE_DURATION_FEDERATION)
.time_to_live(*CACHE_DURATION_LATEST_ID)
.build()
});
CACHE

File diff suppressed because it is too large Load diff

View file

@ -41,12 +41,66 @@ pub fn config(
.service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete)));
}
#[derive(Deserialize)]
trait ProcessUrl {
/// If thumbnail or format is given, this uses the pictrs process endpoint.
/// Otherwise, it uses the normal pictrs url (IE image/original).
fn process_url(&self, image_url: &str, pictrs_url: &Url) -> String;
}
#[derive(Deserialize, Clone)]
struct PictrsGetParams {
format: Option<String>,
thumbnail: Option<i32>,
}
impl ProcessUrl for PictrsGetParams {
fn process_url(&self, src: &str, pictrs_url: &Url) -> String {
if self.format.is_none() && self.thumbnail.is_none() {
format!("{}image/original/{}", pictrs_url, src)
} else {
// Take file type from name, or jpg if nothing is given
let format = self
.clone()
.format
.unwrap_or_else(|| src.split('.').last().unwrap_or("jpg").to_string());
let mut url = format!("{}image/process.{}?src={}", pictrs_url, format, src);
if let Some(size) = self.thumbnail {
url = format!("{url}&thumbnail={size}",);
}
url
}
}
}
#[derive(Deserialize, Clone)]
pub struct ImageProxyParams {
url: String,
format: Option<String>,
thumbnail: Option<i32>,
}
impl ProcessUrl for ImageProxyParams {
fn process_url(&self, proxy_url: &str, pictrs_url: &Url) -> String {
if self.format.is_none() && self.thumbnail.is_none() {
format!("{}image/original?proxy={}", pictrs_url, proxy_url)
} else {
// Take file type from name, or jpg if nothing is given
let format = self
.clone()
.format
.unwrap_or_else(|| proxy_url.split('.').last().unwrap_or("jpg").to_string());
let mut url = format!("{}image/process.{}?proxy={}", pictrs_url, format, proxy_url);
if let Some(size) = self.thumbnail {
url = format!("{url}&thumbnail={size}",);
}
url
}
}
}
fn adapt_request(
request: &HttpRequest,
client: &ClientWithMiddleware,
@ -133,23 +187,10 @@ async fn full_res(
// If there are no query params, the URL is original
let pictrs_config = context.settings().pictrs_config()?;
let url = if params.format.is_none() && params.thumbnail.is_none() {
format!("{}image/original/{}", pictrs_config.url, name,)
} else {
// Take file type from name, or jpg if nothing is given
let format = params
.format
.unwrap_or_else(|| name.split('.').last().unwrap_or("jpg").to_string());
let mut url = format!("{}image/process.{}?src={}", pictrs_config.url, format, name,);
let processed_url = params.process_url(name, &pictrs_config.url);
if let Some(size) = params.thumbnail {
url = format!("{url}&thumbnail={size}",);
}
url
};
image(url, req, &client).await
image(processed_url, req, &client).await
}
async fn image(
@ -208,11 +249,6 @@ async fn delete(
Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream())))
}
#[derive(Deserialize)]
pub struct ImageProxyParams {
url: String,
}
pub async fn image_proxy(
Query(params): Query<ImageProxyParams>,
req: HttpRequest,
@ -226,9 +262,10 @@ pub async fn image_proxy(
RemoteImage::validate(&mut context.pool(), url.clone().into()).await?;
let pictrs_config = context.settings().pictrs_config()?;
let url = format!("{}image/original?proxy={}", pictrs_config.url, &params.url);
image(url, req, &client).await
let processed_url = params.process_url(&params.url, &pictrs_config.url);
image(processed_url, req, &client).await
}
fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static

View file

@ -44,7 +44,6 @@ full = [
"dep:enum-map",
"dep:futures",
"dep:tokio",
"dep:openssl",
"dep:html2text",
"dep:lettre",
"dep:uuid",
@ -65,7 +64,6 @@ actix-web = { workspace = true, optional = true }
anyhow = { workspace = true, optional = true }
reqwest-middleware = { workspace = true, optional = true }
strum = { workspace = true }
strum_macros = { workspace = true }
futures = { workspace = true, optional = true }
diesel = { workspace = true, features = ["chrono"], optional = true }
http = { workspace = true, optional = true }
@ -74,13 +72,14 @@ uuid = { workspace = true, features = ["serde", "v4"], optional = true }
rosetta-i18n = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
urlencoding = { workspace = true, optional = true }
openssl = { version = "0.10.64", optional = true }
html2text = { version = "0.12.5", optional = true }
deser-hjson = { version = "2.2.4", optional = true }
smart-default = { version = "0.7.1", optional = true }
lettre = { version = "0.11.7", features = [
lettre = { version = "0.11.7", default-features = false, features = [
"builder",
"tokio1",
"tokio1-native-tls",
"tokio1-rustls-tls",
"smtp-transport",
], optional = true }
markdown-it = { version = "0.6.0", optional = true }
ts-rs = { workspace = true, optional = true }

View file

@ -1,26 +0,0 @@
use openssl::{pkey::PKey, rsa::Rsa};
use std::io::{Error, ErrorKind};
pub struct Keypair {
pub private_key: String,
pub public_key: String,
}
/// Generate the asymmetric keypair for ActivityPub HTTP signatures.
pub fn generate_actor_keypair() -> Result<Keypair, Error> {
let rsa = Rsa::generate(2048)?;
let pkey = PKey::from_rsa(rsa)?;
let public_key = pkey.public_key_to_pem()?;
let private_key = pkey.private_key_to_pem_pkcs8()?;
let key_to_string = |key| match String::from_utf8(key) {
Ok(s) => Ok(s),
Err(e) => Err(Error::new(
ErrorKind::Other,
format!("Failed converting key to string: {e}"),
)),
};
Ok(Keypair {
private_key: key_to_string(private_key)?,
public_key: key_to_string(public_key)?,
})
}

View file

@ -1,7 +1,7 @@
use cfg_if::cfg_if;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use strum_macros::{Display, EnumIter};
use strum::{Display, EnumIter};
#[derive(Display, Debug, Serialize, Deserialize, Clone, PartialEq, Eq, EnumIter, Hash)]
#[cfg_attr(feature = "full", derive(ts_rs::TS))]
@ -38,6 +38,8 @@ pub enum LemmyErrorType {
NotTopAdmin,
NotTopMod,
NotLoggedIn,
NotHigherMod,
NotHigherAdmin,
SiteBan,
Deleted,
BannedFromCommunity,

View file

@ -2,7 +2,6 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "full")] {
pub mod apub;
pub mod cache_header;
pub mod email;
pub mod rate_limit;

View file

@ -6,7 +6,7 @@ use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
time::Instant,
};
use strum_macros::AsRefStr;
use strum::{AsRefStr, Display};
use tracing::debug;
static START_TIME: Lazy<Instant> = Lazy::new(Instant::now);
@ -66,7 +66,7 @@ impl Bucket {
}
}
#[derive(Debug, enum_map::Enum, Copy, Clone, AsRefStr)]
#[derive(Debug, enum_map::Enum, Copy, Clone, Display, AsRefStr)]
pub enum ActionType {
Message,
Register,

View file

@ -43,12 +43,8 @@ pub struct Settings {
#[default(None)]
#[doku(skip)]
pub opentelemetry_url: Option<Url>,
/// The number of activitypub federation workers that can be in-flight concurrently
#[default(0)]
pub worker_count: usize,
/// The number of activitypub federation retry workers that can be in-flight concurrently
#[default(0)]
pub retry_count: usize,
#[default(Default::default())]
pub federation: FederationWorkerConfig,
// Prometheus configuration.
#[default(None)]
#[doku(example = "Some(Default::default())")]
@ -237,3 +233,14 @@ pub struct PrometheusConfig {
#[doku(example = "10002")]
pub port: i32,
}
#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]
#[serde(default)]
// named federation"worker"config to disambiguate from the activitypub library configuration
pub struct FederationWorkerConfig {
/// Limit to the number of concurrent outgoing federation requests per target instance.
/// Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities
/// per second) and if a receiving instance is not keeping up.
#[default(1)]
pub concurrent_sends_per_instance: i8,
}

View file

@ -97,7 +97,7 @@ services:
logging: *default-logging
postgres:
image: postgres:16-alpine
image: pgautoupgrade/pgautoupgrade:16-alpine
# this needs to match the database host in lemmy.hson
# Tune your settings via
# https://pgtune.leopard.in.ua/#/

View file

@ -20,7 +20,7 @@ x-lemmy-default: &lemmy-default
restart: always
x-postgres-default: &postgres-default
image: postgres:16-alpine
image: pgautoupgrade/pgautoupgrade:16-alpine
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=password

View file

@ -1,48 +1,8 @@
#!/bin/sh
set -e
echo "Do not stop in the middle of this upgrade, wait until you see the message: Upgrade complete."
echo "Stopping lemmy and all services..."
sudo docker-compose stop
echo "Make sure postgres is started..."
sudo docker-compose up -d postgres
sleep 20s
echo "Exporting the Database to 12_15.dump.sql ..."
sudo docker-compose exec -T postgres pg_dumpall -c -U lemmy > 12_15_dump.sql
echo "Done."
echo "Stopping postgres..."
sudo docker-compose stop postgres
sleep 20s
echo "Removing the old postgres folder"
sudo rm -rf volumes/postgres
echo "Updating docker-compose to use postgres version 15."
sed -i "s/image: postgres:.*/image: postgres:15-alpine/" ./docker-compose.yml
echo "Starting up new postgres..."
sudo docker-compose up -d postgres
sleep 20s
echo "Importing the database...."
cat 12_15_dump.sql | sudo docker-compose exec -T postgres psql -U lemmy
echo "Done."
POSTGRES_PASSWORD=$(grep "POSTGRES_PASSWORD" ./docker-compose.yml | cut -d"=" -f2)
echo "Fixing a weird password issue with postgres 15"
sudo docker-compose exec -T postgres psql -U lemmy -c "alter user lemmy with password '$POSTGRES_PASSWORD'"
sudo docker-compose restart postgres
echo "Setting correct perms for pictrs folder"
sudo chown -R 991:991 volumes/pictrs
echo "Updating docker-compose to use postgres version 16."
sudo sed -i "s/image: .*postgres:.*/image: pgautoupgrade\/pgautoupgrade:16-alpine/" ./docker-compose.yml
echo "Starting up lemmy..."
sudo docker-compose up -d
echo "A copy of your old database is at 12_15.dump.sql . You can delete this file if the upgrade went smoothly."
echo "Upgrade complete."

View file

@ -1,42 +1,8 @@
#!/bin/sh
set -e
echo "Do not stop in the middle of this upgrade, wait until you see the message: Upgrade complete."
echo "Stopping lemmy and all services..."
sudo docker compose stop
echo "Make sure postgres is started..."
sudo docker compose up -d postgres
echo "Waiting..."
sleep 20s
echo "Exporting the Database to 15_16.dump.sql ..."
sudo docker compose exec -T postgres pg_dumpall -c -U lemmy | sudo tee 15_16_dump.sql > /dev/null
echo "Done."
echo "Stopping postgres..."
sudo docker compose stop postgres
echo "Waiting..."
sleep 20s
echo "Removing the old postgres folder"
sudo rm -rf volumes/postgres
echo "Updating docker compose to use postgres version 16."
sudo sed -i "s/image: .*postgres:.*/image: docker.io\/postgres:16-alpine/" ./docker-compose.yml
echo "Starting up new postgres..."
sudo docker compose up -d postgres
echo "Waiting..."
sleep 20s
echo "Importing the database...."
sudo cat 15_16_dump.sql | sudo docker compose exec -T postgres psql -U lemmy
echo "Done."
echo "Updating docker-compose to use postgres version 16."
sudo sed -i "s/image: .*postgres:.*/image: pgautoupgrade\/pgautoupgrade:16-alpine/" ./docker-compose.yml
echo "Starting up lemmy..."
sudo docker compose up -d
echo "A copy of your old database is at 15_16.dump.sql . You can delete this file if the upgrade went smoothly."
echo "Upgrade complete."
sudo docker-compose up -d

View file

@ -14,6 +14,7 @@ source scripts/start_dev_db.sh
# so to load the config we need to traverse to the repo root
export LEMMY_CONFIG_LOCATION=../../config/config.hjson
export RUST_BACKTRACE=1
export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min
if [ -n "$PACKAGE" ];
then

View file

@ -82,6 +82,7 @@ use lemmy_api::{
},
registration_applications::{
approve::approve_registration_application,
get::get_registration_application,
list::list_registration_applications,
unread_count::get_unread_registration_application_count,
},
@ -361,6 +362,10 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
"/registration_application/approve",
web::put().to(approve_registration_application),
)
.route(
"/registration_application",
web::get().to(get_registration_application),
)
.route("/list_all_media", web::get().to(list_all_media))
.service(
web::scope("/purge")

View file

@ -468,12 +468,11 @@ async fn initialize_local_site_2022_10_10(
};
let person_inserted = Person::create(pool, &person_form).await?;
let local_user_form = LocalUserInsertForm::builder()
.person_id(person_inserted.id)
.password_encrypted(setup.admin_password.clone())
.email(setup.admin_email.clone())
.admin(Some(true))
.build();
let local_user_form = LocalUserInsertForm {
email: setup.admin_email.clone(),
admin: Some(true),
..LocalUserInsertForm::new(person_inserted.id, setup.admin_password.clone())
};
LocalUser::create(pool, &local_user_form, vec![]).await?;
};

View file

@ -276,6 +276,7 @@ pub async fn start_lemmy_server(args: CmdArgs) -> LemmyResult<()> {
process_count: args.federate_process_count,
},
cfg,
SETTINGS.federation.clone(),
)
});
let mut interrupt = tokio::signal::unix::signal(SignalKind::interrupt())?;

View file

@ -2,11 +2,17 @@ use clap::Parser;
use lemmy_server::{init_logging, start_lemmy_server, CmdArgs};
use lemmy_utils::{error::LemmyResult, settings::SETTINGS};
pub extern crate rustls;
#[tokio::main]
pub async fn main() -> LemmyResult<()> {
init_logging(&SETTINGS.opentelemetry_url)?;
let args = CmdArgs::parse();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
#[cfg(not(feature = "embed-pictrs"))]
start_lemmy_server(args).await?;
#[cfg(feature = "embed-pictrs")]

View file

@ -559,9 +559,9 @@ mod tests {
#[tokio::test]
#[serial]
async fn test_nodeinfo_voyager_lemmy_ml() -> LemmyResult<()> {
async fn test_nodeinfo_lemmy_ml() -> LemmyResult<()> {
let client = ClientBuilder::new(client_builder(&Settings::default()).build()?).build();
let form = build_update_instance_form("voyager.lemmy.ml", &client)
let form = build_update_instance_form("lemmy.ml", &client)
.await
.ok_or(LemmyErrorType::CouldntFindObject)?;
assert_eq!(

Some files were not shown because too many files have changed in this diff Show more