Finish adding post tags (#5869)

* add incoming tags federation

* submit activity update on tags change

* community tag federation test

* tag federation tests

* lint

* sql

* remove logs

* add index name, some comments

* fix comments

* clippy

* rename tags for posts

* fix tests

* wip

* cleanup

* simplify Tag::update_many

* separate name, display_name

* update apub examples

* objects have no displayname so remove it again

* add http endpoint

* clippy

* api tests

* federated api test

* simplify comments

* remove old files

* remote mod

* reorder

* change join

* update tags api

* add missing checks for multi-comm

* allow remote mods to edit tags

* helper function

* max tags count

* add description, background_color

* shear

* add mod update post endpoint

* move db read

* handle conflict

* federate as `CommunityPostTag`

* clippy

* remove background color, add display name

* add comments

* working federation

* test fix

* more fix

* fix api tests

---------

Co-authored-by: phiresky <phireskyde+git@gmail.com>
This commit is contained in:
Nutomic 2025-07-24 11:57:01 +00:00 committed by GitHub
parent 04cbabf6f7
commit 7dfd5ef9e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 938 additions and 562 deletions

1
Cargo.lock generated
View file

@ -3357,7 +3357,6 @@ dependencies = [
"lemmy_utils",
"moka",
"pretty_assertions",
"regex",
"rustls 0.23.27",
"serde",
"serde_json",

View file

@ -31,7 +31,7 @@
"eslint-plugin-prettier": "^5.4.0",
"jest": "^29.5.0",
"joi": "^17.13.3",
"lemmy-js-client": "1.0.0-post-notifications.5",
"lemmy-js-client": "1.0.0-post-tags.3",
"prettier": "^3.5.3",
"ts-jest": "^29.3.2",
"tsoa": "^6.6.0",

View file

@ -36,8 +36,8 @@ importers:
specifier: ^17.13.3
version: 17.13.3
lemmy-js-client:
specifier: 1.0.0-post-notifications.5
version: 1.0.0-post-notifications.5
specifier: 1.0.0-post-tags.3
version: 1.0.0-post-tags.3
prettier:
specifier: ^3.5.3
version: 3.5.3
@ -1594,8 +1594,8 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
lemmy-js-client@1.0.0-post-notifications.5:
resolution: {integrity: sha512-2P0KPCordLRfuGTcgsU3pHSFJlVN5t91e04yhpUf5fZT7iTdlEctFQFtURsvfYPNYK/sdvsucqYbnpbbHJUCTA==}
lemmy-js-client@1.0.0-post-tags.3:
resolution: {integrity: sha512-vUZfQ4420gaGODdHKw8VJhJQsUBVaAcSa9f/Z9x0X98GRnkWDris2YrsjsLtoZ0YZ1Pe5vfYQSjSUH/1lLpfkg==}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
@ -4404,7 +4404,7 @@ snapshots:
kleur@3.0.3: {}
lemmy-js-client@1.0.0-post-notifications.5:
lemmy-js-client@1.0.0-post-tags.3:
dependencies:
'@tsoa/runtime': 6.6.0
transitivePeerDependencies:

View file

@ -1,7 +1,6 @@
jest.setTimeout(120000);
import { AddModToCommunity } from "lemmy-js-client/dist/types/AddModToCommunity";
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
import {
alpha,
beta,
@ -35,12 +34,14 @@ import {
resolveBetaCommunity,
reportCommunity,
randomString,
assertCommunityFederation,
listReports,
} from "./shared";
import { AdminAllowInstanceParams } from "lemmy-js-client/dist/types/AdminAllowInstanceParams";
import {
CommunityReport,
CommunityReportView,
CommunityView,
EditCommunity,
FollowMultiCommunity,
GetPosts,
@ -54,26 +55,6 @@ import {
beforeAll(setupLogins);
afterAll(unfollows);
function assertCommunityFederation(
communityOne?: CommunityView,
communityTwo?: CommunityView,
) {
expect(communityOne?.community.ap_id).toBe(communityTwo?.community.ap_id);
expect(communityOne?.community.name).toBe(communityTwo?.community.name);
expect(communityOne?.community.title).toBe(communityTwo?.community.title);
expect(communityOne?.community.description).toBe(
communityTwo?.community.description,
);
expect(communityOne?.community.icon).toBe(communityTwo?.community.icon);
expect(communityOne?.community.banner).toBe(communityTwo?.community.banner);
expect(communityOne?.community.published_at).toBe(
communityTwo?.community.published_at,
);
expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw);
expect(communityOne?.community.removed).toBe(communityTwo?.community.removed);
expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted);
}
test("Create community", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();

View file

@ -1045,3 +1045,23 @@ export async function waitUntil<T>(
`Failed "${fetcher}": "${checker}" did not return true after ${retries} retries (delayed ${delaySeconds}s each)`,
);
}
export function assertCommunityFederation(
communityOne?: CommunityView,
communityTwo?: CommunityView,
) {
expect(communityOne?.community.ap_id).toBe(communityTwo?.community.ap_id);
expect(communityOne?.community.name).toBe(communityTwo?.community.name);
expect(communityOne?.community.title).toBe(communityTwo?.community.title);
expect(communityOne?.community.description).toBe(
communityTwo?.community.description,
);
expect(communityOne?.community.icon).toBe(communityTwo?.community.icon);
expect(communityOne?.community.banner).toBe(communityTwo?.community.banner);
expect(communityOne?.community.published_at).toBe(
communityTwo?.community.published_at,
);
expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw);
expect(communityOne?.community.removed).toBe(communityTwo?.community.removed);
expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted);
}

View file

@ -2,54 +2,78 @@ jest.setTimeout(120000);
import {
alpha,
beta,
setupLogins,
createCommunity,
unfollows,
randomString,
createPost,
followCommunity,
resolveCommunity,
waitUntil,
assertCommunityFederation,
waitForPost,
gamma,
resolvePerson,
getCommunity,
} from "./shared";
import { CreateCommunityTag } from "lemmy-js-client/dist/types/CreateCommunityTag";
import { UpdateCommunityTag } from "lemmy-js-client/dist/types/UpdateCommunityTag";
import { DeleteCommunityTag } from "lemmy-js-client/dist/types/DeleteCommunityTag";
import { EditPost } from "lemmy-js-client";
import { AddModToCommunity, EditPost } from "lemmy-js-client";
beforeAll(setupLogins);
afterAll(unfollows);
test("Create, update, delete community tag", async () => {
test("Create, and delete a community tag", async () => {
// Create a community first
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
const communityRes = await createCommunity(alpha);
let alphaCommunity = communityRes.community_view;
let betaCommunity = (await resolveCommunity(
beta,
alphaCommunity.community.ap_id,
))!;
await followCommunity(beta, true, betaCommunity.community.id);
await waitUntil(
() => resolveCommunity(beta, alphaCommunity.community.ap_id),
g => g?.community_actions!.follow_state == "Accepted",
);
const communityId = alphaCommunity.community.id;
// Create a tag
const tagName = randomString(10);
let createForm: CreateCommunityTag = {
display_name: tagName,
name: tagName,
community_id: communityId,
};
let createRes = await alpha.createCommunityTag(createForm);
expect(createRes.id).toBeDefined();
expect(createRes.display_name).toBe(tagName);
expect(createRes.name).toBe(tagName);
expect(createRes.community_id).toBe(communityId);
// Update the tag
const newTagName = randomString(10);
let updateForm: UpdateCommunityTag = {
tag_id: createRes.id,
display_name: newTagName,
};
let updateRes = await alpha.updateCommunityTag(updateForm);
expect(updateRes.id).toBe(createRes.id);
expect(updateRes.display_name).toBe(newTagName);
expect(updateRes.community_id).toBe(communityId);
alphaCommunity = (await alpha.getCommunity({ id: communityId }))
.community_view;
expect(alphaCommunity.post_tags.length).toBe(1);
// verify tag federated
betaCommunity = (await waitUntil(
() => resolveCommunity(beta, alphaCommunity.community.ap_id),
g => g!.post_tags.length === 1,
))!;
assertCommunityFederation(alphaCommunity, betaCommunity);
// List tags
let listRes = await alpha.getCommunity({ id: communityId });
expect(listRes.community_view.post_tags.length).toBe(1);
expect(
listRes.community_view.post_tags.find(t => t.id === createRes.id)
?.display_name,
).toBe(newTagName);
alphaCommunity = (await alpha.getCommunity({ id: communityId }))
.community_view;
expect(alphaCommunity.post_tags.length).toBe(1);
expect(alphaCommunity.post_tags.find(t => t.id === createRes.id)?.name).toBe(
tagName,
);
// Verify tag update federated
betaCommunity = (await waitUntil(
() => resolveCommunity(beta, alphaCommunity.community.ap_id),
g => g!.post_tags.find(t => t.ap_id === createRes.ap_id)?.name === tagName,
))!;
assertCommunityFederation(alphaCommunity, betaCommunity);
// Delete the tag
let deleteForm: DeleteCommunityTag = {
@ -59,91 +83,118 @@ test("Create, update, delete community tag", async () => {
expect(deleteRes.id).toBe(createRes.id);
// Verify tag is deleted
listRes = await alpha.getCommunity({ id: communityId });
alphaCommunity = (await alpha.getCommunity({ id: communityId }))
.community_view;
expect(
listRes.community_view.post_tags.find(t => t.id === createRes.id),
alphaCommunity.post_tags.find(t => t.id === createRes.id),
).toBeUndefined();
expect(listRes.community_view.post_tags.length).toBe(0);
expect(alphaCommunity.post_tags.length).toBe(0);
// Verify tag deletion federated
betaCommunity = (await waitUntil(
() => resolveCommunity(beta, alphaCommunity.community.ap_id),
g => g!.post_tags.length === 0,
))!;
assertCommunityFederation(alphaCommunity, betaCommunity);
});
test("Update post tags", async () => {
test("Create and update post tags", async () => {
// Create a community
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
let alphaCommunity = communityRes.community_view;
// Create two tags
const tag1Name = randomString(10);
let createForm1: CreateCommunityTag = {
display_name: tag1Name,
community_id: communityId,
// add gamma as remote mod
let gammaOnAlpha = await resolvePerson(alpha, "lemmy_gamma@lemmy-gamma:8561");
let form: AddModToCommunity = {
community_id: communityRes.community_view.community.id,
person_id: gammaOnAlpha?.person.id as number,
added: true,
};
let tag1Res = await alpha.createCommunityTag(createForm1);
alpha.addModToCommunity(form);
let gammaCommunity = await resolveCommunity(
gamma,
alphaCommunity.community.ap_id,
);
// Remote mod gamma create two tags
const tag1Name = "news";
let createForm1: CreateCommunityTag = {
name: tag1Name,
community_id: gammaCommunity!.community.id,
};
let tag1Res = await gamma.createCommunityTag(createForm1);
expect(tag1Res.id).toBeDefined();
const tag2Name = randomString(10);
const tag2Name = "meme";
let createForm2: CreateCommunityTag = {
display_name: tag2Name,
community_id: communityId,
name: tag2Name,
community_id: gammaCommunity!.community.id,
};
let tag2Res = await alpha.createCommunityTag(createForm2);
let tag2Res = await gamma.createCommunityTag(createForm2);
expect(tag2Res.id).toBeDefined();
// Create a post
let postRes = await alpha.createPost({
await waitUntil(
() => getCommunity(alpha, communityRes.community_view.community.id),
c => c.community_view.post_tags.length > 0,
);
let betaCommunity = await resolveCommunity(
beta,
alphaCommunity.community.ap_id,
);
// follow from beta
await followCommunity(beta, true, betaCommunity!.community.id);
await waitUntil(
() => resolveCommunity(beta, alphaCommunity.community.ap_id),
g => g!.community_actions?.follow_state == "Accepted",
);
// Create a post with tags
let postRes = await beta.createPost({
name: randomString(10),
community_id: communityId,
community_id: betaCommunity!.community.id,
tags: [betaCommunity!.post_tags[0].id, betaCommunity!.post_tags[1].id],
});
expect(postRes.post_view.post.id).toBeDefined();
// Update post tags
let updateForm: EditPost = {
post_id: postRes.post_view.post.id,
tags: [tag1Res.id, tag2Res.id],
};
let updateRes = await alpha.editPost(updateForm);
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
expect(updateRes.post_view.tags?.length).toBe(2);
expect(updateRes.post_view.tags?.map(t => t.id).sort()).toEqual(
[tag1Res.id, tag2Res.id].sort(),
expect(postRes.post_view.post.id).toBe(postRes.post_view.post.id);
expect(postRes.post_view.tags?.length).toBe(2);
expect(postRes.post_view.tags?.map(t => t.id).sort()).toEqual(
[betaCommunity!.post_tags[0].id, betaCommunity!.post_tags[1].id].sort(),
);
// Update post to remove one tag
updateForm.tags = [tag1Res.id];
updateRes = await alpha.editPost(updateForm);
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
expect(updateRes.post_view.tags?.length).toBe(1);
expect(updateRes.post_view.tags?.[0].id).toBe(tag1Res.id);
});
test("Post author can update post tags", async () => {
// Create a community
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
// Create a tag
const tagName = randomString(10);
let createForm: CreateCommunityTag = {
display_name: tagName,
community_id: communityId,
};
let tagRes = await alpha.createCommunityTag(createForm);
expect(tagRes.id).toBeDefined();
let postRes = await createPost(
// wait post tags federated
let alphaPost = await waitForPost(
alpha,
communityId,
"https://example.com/",
"post with tags",
postRes.post_view.post,
p => (p?.tags.length ?? 0) > 0,
);
expect(alphaPost?.tags.length).toBe(2);
expect(alphaPost?.tags.map(t => t.ap_id).sort()).toEqual(
[tag1Res.ap_id, tag2Res.ap_id].sort(),
);
expect(postRes.post_view.post.id).toBeDefined();
// Alpha should be able to update tags on their own post
let updateForm: EditPost = {
post_id: postRes.post_view.post.id,
tags: [tagRes.id],
};
let updateRes = await alpha.editPost(updateForm);
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
// Mod on alpha updates post to remove one tag
communityRes = await getCommunity(
alpha,
communityRes.community_view.community.id,
);
alphaCommunity = communityRes.community_view;
let updateRes = await alpha.modEditPost({
post_id: alphaPost.post.id,
tags: [alphaCommunity!.post_tags[0].id],
});
expect(updateRes.post_view.post.ap_id).toBe(postRes.post_view.post.ap_id);
expect(updateRes.post_view.tags?.length).toBe(1);
expect(updateRes.post_view.tags?.[0].id).toBe(tagRes.id);
expect(updateRes.post_view.tags?.[0].id).toBe(
alphaCommunity!.post_tags[0].id,
);
// wait post tags federated
let betaPost = await waitForPost(beta, postRes.post_view.post, p => {
return (p?.tags.length ?? 0) === 1;
});
expect(betaPost?.tags.map(t => t.ap_id)).toEqual([tag1Res.ap_id]);
});

View file

@ -1,38 +1,76 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use chrono::Utc;
use lemmy_api_utils::{context::LemmyContext, utils::check_community_mod_action};
use lemmy_api_utils::{
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{check_community_mod_action, slur_regex},
};
use lemmy_db_schema::{
source::{
community::Community,
tag::{Tag, TagInsertForm, TagUpdateForm},
},
traits::Crud,
utils::diesel_string_update,
};
use lemmy_db_views_community::{
api::{CreateCommunityTag, DeleteCommunityTag, UpdateCommunityTag},
CommunityView,
};
use lemmy_db_views_community::api::{CreateCommunityTag, DeleteCommunityTag, UpdateCommunityTag};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_utils::{error::LemmyResult, utils::validation::tag_name_length_check};
use lemmy_db_views_site::SiteView;
use lemmy_utils::{
error::LemmyResult,
utils::{
slurs::check_slurs,
validation::{check_api_elements_count, description_length_check, is_valid_actor_name},
},
};
use url::Url;
pub async fn create_community_tag(
data: Json<CreateCommunityTag>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Tag>> {
let community = Community::read(&mut context.pool(), data.community_id).await?;
// reuse this existing function for validation
let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_site = site_view.local_site;
is_valid_actor_name(&data.name, local_site.actor_name_max_length)?;
let community_view =
CommunityView::read(&mut context.pool(), data.community_id, None, false).await?;
let community = community_view.community;
tag_name_length_check(&data.display_name)?;
// Verify that only mods can create tags
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
check_api_elements_count(community_view.post_tags.0.len())?;
if let Some(desc) = &data.description {
description_length_check(desc)?;
check_slurs(desc, &slur_regex(&context).await?)?;
}
let ap_id = Url::parse(&format!("{}/tag/{}", community.ap_id, &data.name))?;
// Create the tag
let tag_form = TagInsertForm {
name: data.name.clone(),
display_name: data.display_name.clone(),
description: data.description.clone(),
community_id: data.community_id,
ap_id: community.build_tag_ap_id(&data.display_name)?,
ap_id: ap_id.into(),
deleted: Some(false),
};
let tag = Tag::create(&mut context.pool(), &tag_form).await?;
ActivityChannel::submit_activity(
SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),
&context,
)?;
Ok(Json(tag))
}
@ -47,16 +85,20 @@ pub async fn update_community_tag(
// Verify that only mods can update tags
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
tag_name_length_check(&data.display_name)?;
if let Some(desc) = &data.description {
description_length_check(desc)?;
check_slurs(desc, &slur_regex(&context).await?)?;
}
// Update the tag
let tag_form = TagUpdateForm {
display_name: Some(data.display_name.clone()),
display_name: diesel_string_update(data.display_name.as_deref()),
description: diesel_string_update(data.description.as_deref()),
updated_at: Some(Some(Utc::now())),
..Default::default()
};
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
Ok(Json(tag))
}
@ -80,5 +122,10 @@ pub async fn delete_community_tag(
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
ActivityChannel::submit_activity(
SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),
&context,
)?;
Ok(Json(tag))
}

View file

@ -6,5 +6,6 @@ pub mod list_post_likes;
pub mod lock;
pub mod mark_many_read;
pub mod mark_read;
pub mod mod_update;
pub mod save;
pub mod update_notifications;

View file

@ -0,0 +1,71 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use chrono::Utc;
use lemmy_api_utils::{
build_response::build_post_response,
context::LemmyContext,
plugins::{plugin_hook_after, plugin_hook_before},
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_community_user_action,
check_is_mod_or_admin,
check_nsfw_allowed,
update_post_tags,
},
};
use lemmy_db_schema::{
source::post::{Post, PostUpdateForm},
traits::Crud,
};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_post::{
api::{ModEditPost, PostResponse},
PostView,
};
use lemmy_db_views_site::SiteView;
use lemmy_utils::error::LemmyResult;
use std::ops::Deref;
pub async fn mod_update_post(
data: Json<ModEditPost>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
let local_instance_id = local_user_view.person.instance_id;
check_nsfw_allowed(data.nsfw, Some(&local_site))?;
let post_id = data.post_id;
let orig_post =
PostView::read(&mut context.pool(), post_id, None, local_instance_id, false).await?;
let community = orig_post.community;
check_community_user_action(&local_user_view, &community, &mut context.pool()).await?;
check_is_mod_or_admin(
&mut context.pool(),
local_user_view.person.id,
community.id,
local_instance_id,
)
.await?;
let mut post_form = PostUpdateForm {
nsfw: data.nsfw,
updated_at: Some(Some(Utc::now())),
..Default::default()
};
post_form = plugin_hook_before("before_update_local_post", post_form).await?;
let post_id = data.post_id;
let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?;
plugin_hook_after("after_update_local_post", &post_form)?;
if let Some(tags) = &data.tags {
update_post_tags(&updated_post, tags, &context).await?;
}
ActivityChannel::submit_activity(SendActivityData::UpdatePost(updated_post.clone()), &context)?;
build_post_response(context.deref(), community.id, local_user_view, post_id).await
}

View file

@ -45,7 +45,6 @@ pub mod actions {
PurgeCommunity,
RemoveCommunity,
TransferCommunity,
UpdateCommunityTag,
};
pub use lemmy_db_views_community_follower::{
api::{

View file

@ -35,11 +35,7 @@ use lemmy_utils::{
error::{LemmyErrorType, LemmyResult},
utils::{
slurs::check_slurs,
validation::{
is_valid_actor_name,
is_valid_body_field,
site_or_community_description_length_check,
},
validation::{description_length_check, is_valid_actor_name, is_valid_body_field},
},
};
@ -69,7 +65,7 @@ pub async fn create_community(
let description = data.description.clone();
if let Some(desc) = &description {
site_or_community_description_length_check(desc)?;
description_length_check(desc)?;
check_slurs(desc, &slur_regex)?;
}

View file

@ -9,7 +9,6 @@ use lemmy_api_utils::{
plugins::{plugin_hook_after, plugin_hook_before},
request::generate_post_link_metadata,
send_activity::SendActivityData,
tags::update_post_tags,
utils::{
check_community_user_action,
check_nsfw_allowed,
@ -18,6 +17,7 @@ use lemmy_api_utils::{
process_markdown_opt,
send_webmention,
slur_regex,
update_post_tags,
},
};
use lemmy_db_schema::{
@ -140,14 +140,7 @@ pub async fn create_post(
plugin_hook_after("after_create_local_post", &inserted_post)?;
if let Some(tags) = &data.tags {
update_post_tags(
&context,
&inserted_post,
&community_view,
tags,
&local_user_view,
)
.await?;
update_post_tags(&inserted_post, tags, &context).await?;
}
let community_id = community.id;

View file

@ -9,7 +9,6 @@ use lemmy_api_utils::{
plugins::{plugin_hook_after, plugin_hook_before},
request::generate_post_link_metadata,
send_activity::SendActivityData,
tags::update_post_tags,
utils::{
check_community_user_action,
check_nsfw_allowed,
@ -17,6 +16,7 @@ use lemmy_api_utils::{
process_markdown_opt,
send_webmention,
slur_regex,
update_post_tags,
},
};
use lemmy_db_schema::{
@ -28,7 +28,6 @@ use lemmy_db_schema::{
traits::Crud,
utils::{diesel_string_update, diesel_url_update},
};
use lemmy_db_views_community::CommunityView;
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_post::{
api::{EditPost, PostResponse},
@ -103,20 +102,6 @@ pub async fn update_post(
check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?;
if let Some(tags) = &data.tags {
// post view does not include communityview.post_tags
let community_view =
CommunityView::read(&mut context.pool(), orig_post.community.id, None, false).await?;
update_post_tags(
&context,
&orig_post.post,
&community_view,
tags,
&local_user_view,
)
.await?;
}
// Verify that only the creator can edit
if !Post::is_post_creator(local_user_view.person.id, orig_post.post.creator_id) {
Err(LemmyErrorType::NoPostEditAllowed)?
@ -162,6 +147,10 @@ pub async fn update_post(
let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?;
plugin_hook_after("after_update_local_post", &post_form)?;
if let Some(tags) = &data.tags {
update_post_tags(&orig_post.post, tags, &context).await?;
}
NotifyData::new(
&updated_post,
None,

View file

@ -35,9 +35,9 @@ use lemmy_utils::{
slurs::check_slurs,
validation::{
build_and_check_regex,
description_length_check,
is_valid_body_field,
site_name_length_check,
site_or_community_description_length_check,
},
},
};
@ -163,7 +163,7 @@ fn validate_create_payload(local_site: &LocalSite, create_site: &CreateSite) ->
check_slurs(&create_site.name, &slur_regex)?;
if let Some(desc) = &create_site.description {
site_or_community_description_length_check(desc)?;
description_length_check(desc)?;
check_slurs(desc, &slur_regex)?;
}

View file

@ -38,9 +38,9 @@ use lemmy_utils::{
validation::{
build_and_check_regex,
check_urls_are_valid,
description_length_check,
is_valid_body_field,
site_name_length_check,
site_or_community_description_length_check,
},
},
};
@ -205,7 +205,7 @@ fn validate_update_payload(local_site: &LocalSite, edit_site: &EditSite) -> Lemm
}
if let Some(desc) = &edit_site.description {
site_or_community_description_length_check(desc)?;
description_length_check(desc)?;
check_slurs_opt(&edit_site.description, &slur_regex)?;
}

View file

@ -5,5 +5,4 @@ pub mod notify;
pub mod plugins;
pub mod request;
pub mod send_activity;
pub mod tags;
pub mod utils;

View file

@ -1,49 +0,0 @@
use crate::{context::LemmyContext, utils::check_community_mod_action};
use lemmy_db_schema::{
newtypes::TagId,
source::{
post::Post,
post_tag::{PostTag, PostTagForm},
},
};
use lemmy_db_views_community::CommunityView;
use lemmy_db_views_local_user::LocalUserView;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
use std::collections::HashSet;
pub async fn update_post_tags(
context: &LemmyContext,
post: &Post,
community: &CommunityView,
tags: &[TagId],
local_user_view: &LocalUserView,
) -> LemmyResult<()> {
let is_author = Post::is_post_creator(local_user_view.person.id, post.creator_id);
if !is_author {
// Check if user is either the post author or a community mod
check_community_mod_action(
local_user_view,
&community.community,
false,
&mut context.pool(),
)
.await?;
}
// validate tags
let valid_tags: HashSet<TagId> = community.post_tags.0.iter().map(|t| t.id).collect();
if !valid_tags.is_superset(&tags.iter().copied().collect()) {
return Err(LemmyErrorType::TagNotInCommunity.into());
}
let insert_tags = tags
.iter()
.map(|tag_id| PostTagForm {
post_id: post.id,
tag_id: *tag_id,
})
.collect::<Vec<PostTagForm>>();
PostTag::set(&mut context.pool(), &insert_tags).await?;
Ok(())
}

View file

@ -8,7 +8,7 @@ use actix_web_httpauth::headers::authorization::{Authorization, Bearer};
use chrono::{DateTime, Days, Local, TimeZone, Utc};
use enum_map::{enum_map, EnumMap};
use lemmy_db_schema::{
newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId, PostOrCommentId},
newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId, PostOrCommentId, TagId},
source::{
comment::{Comment, CommentActions},
community::{Community, CommunityActions, CommunityUpdateForm},
@ -29,6 +29,7 @@ use lemmy_db_schema::{
private_message::PrivateMessage,
registration_application::RegistrationApplication,
site::Site,
tag::{PostTag, Tag},
},
traits::{Crud, Likeable},
utils::DbPool,
@ -60,7 +61,7 @@ use lemmy_utils::{
};
use moka::future::Cache;
use regex::{escape, Regex, RegexSet};
use std::sync::LazyLock;
use std::{collections::HashSet, sync::LazyLock};
use tracing::Instrument;
use url::{ParseError, Url};
use urlencoding::encode;
@ -485,6 +486,7 @@ pub async fn get_url_blocklist(context: &LemmyContext) -> LemmyResult<RegexSet>
)
}
// `local_site` is optional so that tests work easily
pub fn check_nsfw_allowed(nsfw: Option<bool>, local_site: Option<&LocalSite>) -> LemmyResult<()> {
let is_nsfw = nsfw.unwrap_or_default();
let nsfw_disallowed = local_site.is_some_and(|s| s.disallow_nsfw_content);
@ -1004,6 +1006,24 @@ pub fn check_comment_depth(comment: &Comment) -> LemmyResult<()> {
}
}
pub async fn update_post_tags(
post: &Post,
tag_ids: &[TagId],
context: &LemmyContext,
) -> LemmyResult<()> {
// validate tags
let community_tags = Tag::read_for_community(&mut context.pool(), post.community_id)
.await?
.into_iter()
.map(|t| t.id)
.collect::<HashSet<_>>();
if !community_tags.is_superset(&tag_ids.iter().copied().collect()) {
return Err(LemmyErrorType::TagNotInCommunity.into());
}
PostTag::update(&mut context.pool(), post, tag_ids).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -43,6 +43,13 @@
"name": "Deutsch"
}
],
"tag": [
{
"type": "CommunityPostTag",
"id": "https://enterprise.lemmy.ml/c/tenforward/tag/news",
"name": "news"
}
],
"published": "2019-06-02T16:43:50.799554Z",
"updated": "2021-03-10T17:18:10.498868Z"
}

View file

@ -28,5 +28,12 @@
"identifier": "fr",
"name": "Français"
},
"tag": [
{
"type": "CommunityPostTag",
"id": "https://enterprise.lemmy.ml/c/tenforward/tag/news",
"name": "news"
}
],
"published": "2021-02-26T12:35:34.292626Z"
}

View file

@ -5,7 +5,6 @@ use crate::{
community::send_activity_in_community,
generate_activity_id,
send_lemmy_activity,
verify_mod_action,
},
activity_lists::AnnouncableActivities,
protocol::activities::block::block_user::BlockUser,
@ -22,7 +21,12 @@ use lemmy_api_utils::{
};
use lemmy_apub_objects::{
objects::person::ApubPerson,
utils::functions::{verify_is_public, verify_person_in_community, verify_visibility},
utils::functions::{
verify_is_public,
verify_mod_action,
verify_person_in_community,
verify_visibility,
},
};
use lemmy_db_schema::{
source::{

View file

@ -1,5 +1,5 @@
use crate::{
activities::{community::send_activity_in_community, generate_activity_id, verify_mod_action},
activities::{community::send_activity_in_community, generate_activity_id},
activity_lists::AnnouncableActivities,
protocol::activities::community::{
collection_add::CollectionAdd,
@ -19,7 +19,7 @@ use lemmy_api_utils::{
use lemmy_apub_objects::{
objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},
utils::{
functions::{generate_to, verify_person_in_community, verify_visibility},
functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility},
protocol::InCommunity,
},
};

View file

@ -1,5 +1,5 @@
use crate::{
activities::{community::send_activity_in_community, generate_activity_id, verify_mod_action},
activities::{community::send_activity_in_community, generate_activity_id},
activity_lists::AnnouncableActivities,
protocol::activities::community::collection_remove::CollectionRemove,
};
@ -16,7 +16,7 @@ use lemmy_api_utils::{
use lemmy_apub_objects::{
objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},
utils::{
functions::{generate_to, verify_person_in_community, verify_visibility},
functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility},
protocol::InCommunity,
},
};

View file

@ -3,7 +3,6 @@ use crate::{
check_community_deleted_or_removed,
community::send_activity_in_community,
generate_activity_id,
verify_mod_action,
},
activity_lists::AnnouncableActivities,
protocol::activities::community::lock_page::{LockPage, LockType, UndoLockPage},
@ -18,7 +17,7 @@ use lemmy_api_utils::context::LemmyContext;
use lemmy_apub_objects::{
objects::community::ApubCommunity,
utils::{
functions::{generate_to, verify_person_in_community, verify_visibility},
functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility},
protocol::InCommunity,
},
};

View file

@ -1,4 +1,3 @@
use super::verify_mod_action;
use crate::{
activities::send_lemmy_activity,
activity_lists::AnnouncableActivities,
@ -7,12 +6,15 @@ use crate::{
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Actor};
use either::Either;
use lemmy_api_utils::{context::LemmyContext, utils::is_admin};
use lemmy_apub_objects::objects::{
community::ApubCommunity,
instance::ApubSite,
person::ApubPerson,
PostOrComment,
ReportableObjects,
use lemmy_apub_objects::{
objects::{
community::ApubCommunity,
instance::ApubSite,
person::ApubPerson,
PostOrComment,
ReportableObjects,
},
utils::functions::verify_mod_action,
};
use lemmy_db_schema::{
source::{

View file

@ -1,10 +1,5 @@
use crate::{
activities::{
community::send_activity_in_community,
generate_activity_id,
send_lemmy_activity,
verify_mod_action,
},
activities::{community::send_activity_in_community, generate_activity_id, send_lemmy_activity},
activity_lists::AnnouncableActivities,
protocol::activities::community::update::Update,
};
@ -18,7 +13,7 @@ use lemmy_api_utils::context::LemmyContext;
use lemmy_apub_objects::{
objects::{community::ApubCommunity, multi_community::ApubMultiCommunity, person::ApubPerson},
utils::{
functions::{generate_to, verify_person_in_community, verify_visibility},
functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility},
protocol::InCommunity,
},
};

View file

@ -9,14 +9,19 @@ use crate::{
};
use activitypub_federation::{
config::Data,
protocol::verification::{verify_domains_match, verify_urls_match},
protocol::verification::{verify_domains_match, verify_is_remote_object, verify_urls_match},
traits::{Activity, Object},
};
use chrono::Utc;
use lemmy_api_utils::{context::LemmyContext, notify::NotifyData};
use lemmy_apub_objects::{
objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},
objects::{
community::ApubCommunity,
person::ApubPerson,
post::{post_nsfw, update_apub_post_tags, ApubPost},
},
utils::{
functions::{generate_to, verify_person_in_community, verify_visibility},
functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility},
protocol::InCommunity,
},
};
@ -26,7 +31,7 @@ use lemmy_db_schema::{
activity::ActivitySendTargets,
community::Community,
person::Person,
post::{Post, PostActions, PostLikeForm},
post::{Post, PostActions, PostLikeForm, PostUpdateForm},
},
traits::{Crud, Likeable},
};
@ -100,12 +105,42 @@ impl Activity for CreateOrUpdatePage {
verify_person_in_community(&self.actor, &community, context).await?;
check_community_deleted_or_removed(&community)?;
verify_domains_match(self.actor.inner(), self.object.id.inner())?;
verify_urls_match(self.actor.inner(), self.object.creator()?.inner())?;
ApubPost::verify(&self.object, self.actor.inner(), context).await?;
Ok(())
}
async fn receive(self, context: &Data<LemmyContext>) -> LemmyResult<()> {
if verify_urls_match(self.actor.inner(), self.object.creator()?.inner()).is_err()
&& verify_is_remote_object(&self.object.id, context).is_err()
{
if let Ok(post) = self.object.id.dereference_local(context).await {
post.set_not_pending(&mut context.pool()).await?;
}
// allow mods to edit the post
if let Ok(Some(post)) =
Post::read_from_apub_id(&mut context.pool(), self.object.id.clone().into_inner()).await
{
let community = Community::read(&mut context.pool(), post.community_id).await?;
if verify_mod_action(&self.actor, &community, context)
.await
.is_ok()
{
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
let form = PostUpdateForm {
updated_at: Some(Some(Utc::now())),
nsfw: post_nsfw(&self.object, &community, Some(&local_site), context).await?,
..Default::default()
};
Post::update(&mut context.pool(), post.id, &form).await?;
update_apub_post_tags(&self.object, &post, context).await?;
}
}
return Ok(());
}
verify_urls_match(self.actor.inner(), self.object.creator()?.inner())?;
let site_view = SiteView::read_local(&mut context.pool()).await?;
let post = ApubPost::from_json(self.object, context).await?;

View file

@ -1,10 +1,5 @@
use crate::{
activities::{
community::send_activity_in_community,
send_lemmy_activity,
verify_mod_action,
verify_person,
},
activities::{community::send_activity_in_community, send_lemmy_activity, verify_person},
activity_lists::AnnouncableActivities,
protocol::activities::deletion::{delete::Delete, undo_delete::UndoDelete},
};
@ -25,7 +20,13 @@ use lemmy_apub_objects::{
private_message::ApubPrivateMessage,
},
utils::{
functions::{generate_to, verify_is_public, verify_person_in_community, verify_visibility},
functions::{
generate_to,
verify_is_public,
verify_mod_action,
verify_person_in_community,
verify_visibility,
},
protocol::InCommunity,
},
};

View file

@ -33,7 +33,6 @@ use following::send_accept_or_reject_follow;
use lemmy_api_utils::{
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::check_is_mod_or_admin,
};
use lemmy_apub_objects::{objects::person::ApubPerson, utils::functions::GetActorType};
use lemmy_db_schema::{
@ -44,7 +43,6 @@ use lemmy_db_schema::{
},
traits::Crud,
};
use lemmy_db_views_site::SiteView;
use lemmy_utils::error::{FederationError, LemmyError, LemmyResult};
use serde::Serialize;
use tracing::info;
@ -69,36 +67,6 @@ async fn verify_person(
Ok(())
}
/// Verify that mod action in community was performed by a moderator.
///
/// * `mod_id` - Activitypub ID of the mod or admin who performed the action
/// * `object_id` - Activitypub ID of the actor or object that is being moderated
/// * `community` - The community inside which moderation is happening
pub(crate) async fn verify_mod_action(
mod_id: &ObjectId<ApubPerson>,
community: &Community,
context: &Data<LemmyContext>,
) -> LemmyResult<()> {
// mod action comes from the same instance as the community, so it was presumably done
// by an instance admin.
// TODO: federate instance admin status and check it here
if mod_id.inner().domain() == community.ap_id.domain() {
return Ok(());
}
let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_instance_id = site_view.site.instance_id;
let mod_ = mod_id.dereference(context).await?;
check_is_mod_or_admin(
&mut context.pool(),
mod_.id,
community.id,
local_instance_id,
)
.await
}
pub(crate) fn check_community_deleted_or_removed(community: &Community) -> LemmyResult<()> {
if community.deleted || community.removed {
Err(FederationError::CannotCreatePostOrCommentInDeletedOrRemovedCommunity)?

View file

@ -21,14 +21,17 @@ use actix_web::{
HttpResponse,
};
use lemmy_api_utils::context::LemmyContext;
use lemmy_apub_objects::objects::{
community::ApubCommunity,
multi_community::ApubMultiCommunity,
multi_community_collection::ApubFeedCollection,
SiteOrMultiOrCommunityOrUser,
use lemmy_apub_objects::{
objects::{
community::ApubCommunity,
multi_community::ApubMultiCommunity,
multi_community_collection::ApubFeedCollection,
SiteOrMultiOrCommunityOrUser,
},
protocol::tags::CommunityTag,
};
use lemmy_db_schema::{
source::{community::Community, multi_community::MultiCommunity},
source::{community::Community, multi_community::MultiCommunity, tag::Tag},
traits::ApubActor,
};
use lemmy_db_schema_file::enums::CommunityVisibility;
@ -193,6 +196,35 @@ pub(crate) async fn get_apub_person_multi_community_follows(
Ok(create_http_response(collection, &FEDERATION_CONTEXT)?)
}
#[derive(Deserialize, Clone)]
pub(crate) struct CommunityTagPath {
community_name: String,
tag_name: String,
}
/// Return the ActivityPub json representation of a local community over HTTP.
pub(crate) async fn get_apub_community_tag_http(
info: Path<CommunityTagPath>,
context: Data<LemmyContext>,
) -> LemmyResult<HttpResponse> {
let community: ApubCommunity =
Community::read_from_name(&mut context.pool(), &info.community_name, true)
.await?
.ok_or(LemmyErrorType::NotFound)?
.into();
check_community_fetchable(&community)?;
let tag = Tag::read_for_community(&mut context.pool(), community.id)
.await?
.into_iter()
.map(CommunityTag::to_json)
.find(|t| t.name == info.tag_name)
.ok_or(LemmyErrorType::NotFound)?;
Ok(create_http_response(tag, &FEDERATION_CONTEXT)?)
}
#[cfg(test)]
pub(crate) mod tests {

View file

@ -6,6 +6,7 @@ use crate::http::{
get_apub_community_http,
get_apub_community_moderators,
get_apub_community_outbox,
get_apub_community_tag_http,
get_apub_person_multi_community,
get_apub_person_multi_community_follows,
},
@ -45,6 +46,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
"/c/{community_name}/moderators",
web::get().to(get_apub_community_moderators),
)
.route(
"/c/{community_name}/tag/{tag_name}",
web::get().to(get_apub_community_tag_http),
)
.route("/u/{user_name}", web::get().to(get_apub_person_http))
.route(
"/u/{user_name}/outbox",

View file

@ -1,6 +1,6 @@
use crate::{
objects::instance::fetch_instance_actor_for_object,
protocol::group::Group,
protocol::{group::Group, tags::CommunityTag},
utils::{
functions::{
check_apub_id_valid_with_strictness,
@ -37,6 +37,7 @@ use lemmy_db_schema::{
source::{
actor_language::CommunityLanguage,
community::{Community, CommunityInsertForm, CommunityUpdateForm},
tag::Tag,
},
traits::{ApubActor, Crud},
};
@ -117,7 +118,7 @@ impl Object for ApubCommunity {
let community_id = self.id;
let langs = CommunityLanguage::read(&mut data.pool(), community_id).await?;
let language = LanguageTag::new_multiple(langs, &mut data.pool()).await?;
let post_tags = Tag::read_for_community(&mut data.pool(), community_id).await?;
let group = Group {
kind: GroupType::Group,
id: self.id().clone().into(),
@ -145,6 +146,7 @@ impl Object for ApubCommunity {
)),
manually_approves_followers: Some(self.visibility == CommunityVisibility::Private),
discoverable: Some(self.visibility != CommunityVisibility::Unlisted),
tag: post_tags.into_iter().map(CommunityTag::to_json).collect(),
};
Ok(group)
}
@ -157,6 +159,9 @@ impl Object for ApubCommunity {
check_apub_id_valid_with_strictness(group.id.inner(), true, context).await?;
verify_domains_match(expected_domain, group.id.inner())?;
// Doesnt call verify_is_remote_object() because the community might be edited by a
// remote mod. This is safe as we validate `expected_domain`.
let slur_regex = slur_regex(context).await?;
check_slurs(&group.preferred_username, &slur_regex)?;
@ -193,7 +198,8 @@ impl Object for ApubCommunity {
deleted: Some(false),
nsfw: Some(group.sensitive.unwrap_or(false)),
ap_id: Some(group.id.clone().into()),
local: Some(false),
// May be a local community which is updated by remote mod.
local: Some(group.id.is_local(context)),
last_refreshed_at: Some(Utc::now()),
icon,
banner,
@ -234,6 +240,14 @@ impl Object for ApubCommunity {
let community = Community::insert_apub(&mut context.pool(), timestamp, &form).await?;
CommunityLanguage::update(&mut context.pool(), languages, community.id).await?;
let new_tags = group
.tag
.iter()
.map(|t| t.to_insert_form(community.id))
.collect();
let existing_tags = Tag::read_for_community(&mut context.pool(), community.id).await?;
Tag::update_many(&mut context.pool(), new_tags, existing_tags).await?;
let community: ApubCommunity = community.into();
// These collections are not necessary for Lemmy to work, so ignore errors. Reset request count

View file

@ -1,11 +1,15 @@
use crate::{objects::ApubSite, protocol::multi_community::Feed, utils::functions::GetActorType};
use crate::{
objects::ApubSite,
protocol::multi_community::Feed,
utils::functions::{check_apub_id_valid_with_strictness, GetActorType},
};
use activitypub_federation::{
config::Data,
protocol::verification::verify_domains_match,
protocol::verification::{verify_domains_match, verify_is_remote_object},
traits::{Actor, Object},
};
use chrono::{DateTime, Utc};
use lemmy_api_utils::context::LemmyContext;
use lemmy_api_utils::{context::LemmyContext, utils::slur_regex};
use lemmy_db_schema::{
sensitive::SensitiveString,
source::{
@ -16,7 +20,10 @@ use lemmy_db_schema::{
};
use lemmy_db_schema_file::enums::ActorType;
use lemmy_db_views_site::SiteView;
use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult};
use lemmy_utils::{
error::{LemmyError, LemmyErrorType, LemmyResult},
utils::slurs::{check_slurs, check_slurs_opt},
};
use std::ops::Deref;
use url::Url;
@ -91,9 +98,16 @@ impl Object for ApubMultiCommunity {
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_context: &Data<LemmyContext>,
context: &Data<LemmyContext>,
) -> LemmyResult<()> {
check_apub_id_valid_with_strictness(json.id.inner(), true, context).await?;
verify_domains_match(expected_domain, json.id.inner())?;
verify_is_remote_object(&json.id, context)?;
let slur_regex = slur_regex(context).await?;
check_slurs(&json.name, &slur_regex)?;
check_slurs_opt(&json.summary, &slur_regex)?;
Ok(())
}

View file

@ -1,10 +1,14 @@
use crate::{
protocol::page::{
Attachment,
Hashtag,
HashtagType::{self},
Page,
PageType,
protocol::{
page::{
Attachment,
Hashtag,
HashtagOrLemmyTag,
HashtagType::{self},
Page,
PageType,
},
tags::CommunityTag,
},
utils::{
functions::{
@ -20,10 +24,7 @@ use crate::{
};
use activitypub_federation::{
config::Data,
protocol::{
values::MediaTypeMarkdownOrHtml,
verification::{verify_domains_match, verify_is_remote_object},
},
protocol::{values::MediaTypeMarkdownOrHtml, verification::verify_domains_match},
traits::Object,
};
use anyhow::anyhow;
@ -33,13 +34,21 @@ use lemmy_api_utils::{
context::LemmyContext,
plugins::{plugin_hook_after, plugin_hook_before},
request::generate_post_link_metadata,
utils::{check_nsfw_allowed, get_url_blocklist, process_markdown_opt, slur_regex},
utils::{
check_nsfw_allowed,
get_url_blocklist,
process_markdown_opt,
slur_regex,
update_post_tags,
},
};
use lemmy_db_schema::{
source::{
community::Community,
local_site::LocalSite,
person::Person,
post::{Post, PostInsertForm, PostUpdateForm},
tag::Tag,
},
traits::Crud,
};
@ -54,7 +63,7 @@ use lemmy_utils::{
validation::{is_url_blocked, is_valid_url},
},
};
use std::ops::Deref;
use std::{collections::HashSet, ops::Deref};
use stringreader::StringReader;
use url::Url;
@ -133,11 +142,21 @@ impl Object for ApubPost {
})
.into_iter()
.collect();
// Add tags defined by community and applied to this post
let mut tags: Vec<HashtagOrLemmyTag> = Tag::read_for_post(&mut context.pool(), self.id)
.await?
.into_iter()
.map(|tag| HashtagOrLemmyTag::CommunityTag(CommunityTag::to_json(tag)))
.collect();
// Add automatic hashtag based on community name
let hashtag = Hashtag {
href: self.ap_id.clone().into(),
name: format!("#{}", &community.name),
kind: HashtagType::Hashtag,
};
tags.push(HashtagOrLemmyTag::Hashtag(hashtag));
let page = Page {
kind: PageType::Page,
@ -156,7 +175,7 @@ impl Object for ApubPost {
published: Some(self.published_at),
updated: self.updated_at,
in_reply_to: None,
tag: vec![hashtag],
tag: tags,
};
Ok(page)
}
@ -167,14 +186,11 @@ impl Object for ApubPost {
context: &Data<Self::DataType>,
) -> LemmyResult<()> {
verify_domains_match(page.id.inner(), expected_domain)?;
if let Err(e) = verify_is_remote_object(&page.id, context) {
if let Ok(post) = page.id.dereference_local(context).await {
post.set_not_pending(&mut context.pool()).await?;
}
return Err(e.into());
}
let community = page.community(context).await?;
// Doesnt call verify_is_remote_object() because the community might be edited by a
// remote mod. This is safe as we validate `expected_domain`.
check_apub_id_valid_with_strictness(page.id.inner(), community.local, context).await?;
verify_person_in_community(&page.creator()?, &community, context).await?;
@ -240,24 +256,6 @@ impl Object for ApubPost {
None
};
// Ensure that all posts in NSFW communities are marked as NSFW
let nsfw = if community.nsfw {
Some(true)
} else {
page.sensitive
};
// If NSFW is not allowed, reject NSFW posts and delete existing
// posts that get updated to be NSFW
let block_for_nsfw = check_nsfw_allowed(nsfw, local_site.as_ref());
if let Err(e) = block_for_nsfw {
// TODO: Remove locally generated thumbnail if one exists, depends on
// https://github.com/LemmyNet/lemmy/issues/5564 to be implemented to be able to
// safely do this.
Post::delete_from_apub_id(&mut context.pool(), page.id.inner().clone()).await?;
Err(e)?
}
let url_blocklist = get_url_blocklist(context).await?;
let url = if let Some(url) = url {
@ -280,8 +278,11 @@ impl Object for ApubPost {
let body = process_markdown_opt(&body, &slur_regex, &url_blocklist, context).await?;
let body = markdown_rewrite_remote_links_opt(body, context).await;
let language_id = Some(
LanguageTag::to_language_id_single(page.language.unwrap_or_default(), &mut context.pool())
.await?,
LanguageTag::to_language_id_single(
page.language.clone().unwrap_or_default(),
&mut context.pool(),
)
.await?,
);
let mut form = PostInsertForm {
@ -291,9 +292,10 @@ impl Object for ApubPost {
published_at: page.published,
updated_at: page.updated,
deleted: Some(false),
nsfw,
nsfw: post_nsfw(&page, &community, local_site.as_ref(), context).await?,
ap_id: Some(page.id.clone().into()),
local: Some(false),
// May be a local post which is updated by remote mod.
local: Some(page.id.is_local(context)),
language_id,
..PostInsertForm::new(name, creator.id, community.id)
};
@ -302,6 +304,9 @@ impl Object for ApubPost {
let timestamp = page.updated.or(page.published).unwrap_or_else(Utc::now);
let post = Post::insert_apub(&mut context.pool(), timestamp, &form).await?;
plugin_hook_after("after_receive_federated_post", &post)?;
update_apub_post_tags(&page, &post, context).await?;
let post_ = post.clone();
let context_ = context.clone();
@ -315,6 +320,52 @@ impl Object for ApubPost {
}
}
pub async fn update_apub_post_tags(
page: &Page,
post: &Post,
context: &LemmyContext,
) -> LemmyResult<()> {
let post_tag_ap_ids = page
.tag
.iter()
.filter_map(HashtagOrLemmyTag::community_tag_url)
.collect::<HashSet<_>>();
let community_tags = Tag::read_for_community(&mut context.pool(), post.community_id).await?;
let post_tags = community_tags
.into_iter()
.filter(|t| post_tag_ap_ids.contains(&t.ap_id))
.map(|t| t.id)
.collect::<Vec<_>>();
update_post_tags(post, &post_tags, context).await?;
Ok(())
}
pub async fn post_nsfw(
page: &Page,
community: &Community,
local_site: Option<&LocalSite>,
context: &LemmyContext,
) -> LemmyResult<Option<bool>> {
// Ensure that all posts in NSFW communities are marked as NSFW
let nsfw = if community.nsfw {
Some(true)
} else {
page.sensitive
};
// If NSFW is not allowed, reject NSFW posts and delete existing
// posts that get updated to be NSFW
let block_for_nsfw = check_nsfw_allowed(nsfw, local_site);
if let Err(e) = block_for_nsfw {
// TODO: Remove locally generated thumbnail if one exists, depends on
// https://github.com/LemmyNet/lemmy/issues/5564 to be implemented to be able to
// safely do this.
Post::delete_from_apub_id(&mut context.pool(), page.id.inner().clone()).await?;
Err(e)?
}
Ok(nsfw)
}
#[cfg(test)]
mod tests {
use super::*;
@ -322,7 +373,7 @@ mod tests {
objects::ApubPerson,
utils::test::{file_to_json_object, parse_lemmy_community, parse_lemmy_person},
};
use lemmy_db_schema::source::site::Site;
use lemmy_db_schema::source::{community::Community, person::Person, site::Site};
use pretty_assertions::assert_eq;
use serial_test::serial;

View file

@ -1,5 +1,6 @@
use crate::{
objects::community::ApubCommunity,
protocol::tags::CommunityTag,
utils::protocol::{AttributedTo, Endpoints, ImageObject, LanguageTag, Source},
};
use activitypub_federation::{
@ -57,4 +58,6 @@ pub struct Group {
pub updated: Option<DateTime<Utc>>,
/// https://docs.joinmastodon.org/spec/activitypub/#discoverable
pub(crate) discoverable: Option<bool>,
#[serde(default)]
pub(crate) tag: Vec<CommunityTag>,
}

View file

@ -5,6 +5,7 @@ pub mod note;
pub mod page;
pub mod person;
pub mod private_message;
pub mod tags;
#[cfg(test)]
mod tests {

View file

@ -1,5 +1,6 @@
use crate::{
objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},
protocol::tags::CommunityTag,
utils::protocol::{
AttributedTo,
ImageObject,
@ -70,7 +71,9 @@ pub struct Page {
pub(crate) updated: Option<DateTime<Utc>>,
pub(crate) language: Option<LanguageTag>,
#[serde(deserialize_with = "deserialize_skip_error", default)]
pub(crate) tag: Vec<Hashtag>,
/// Contains hashtags and post tags.
/// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag
pub(crate) tag: Vec<HashtagOrLemmyTag>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -165,6 +168,22 @@ pub enum HashtagType {
Hashtag,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum HashtagOrLemmyTag {
Hashtag(Hashtag),
CommunityTag(CommunityTag),
}
impl HashtagOrLemmyTag {
pub fn community_tag_url(&self) -> Option<Url> {
match self {
HashtagOrLemmyTag::CommunityTag(t) => Some(t.id.clone()),
_ => None,
}
}
}
impl Page {
pub fn creator(&self) -> LemmyResult<ObjectId<ApubPerson>> {
match &self.attributed_to {

View file

@ -0,0 +1,50 @@
use lemmy_db_schema::{
newtypes::CommunityId,
source::tag::{Tag, TagInsertForm},
};
use serde::{Deserialize, Serialize};
use url::Url;
/// The [ActivityStreams vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag)
/// defines that any object can have a list of tags associated with it.
/// Tags in AS can be of any type, so we define our own types.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
enum CommunityTagType {
#[default]
CommunityPostTag,
}
/// A tag that a community owns, that is added to a post.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct CommunityTag {
#[serde(rename = "type")]
kind: CommunityTagType,
pub id: Url,
pub name: String,
/// custom field
pub display_name: Option<String>,
pub content: Option<String>,
}
impl CommunityTag {
pub fn to_json(tag: Tag) -> Self {
CommunityTag {
kind: Default::default(),
id: tag.ap_id.into(),
name: tag.name,
display_name: tag.display_name,
content: tag.description,
}
}
pub fn to_insert_form(&self, community_id: CommunityId) -> TagInsertForm {
TagInsertForm {
ap_id: self.id.clone().into(),
name: self.name.clone(),
display_name: self.display_name.clone(),
description: self.content.clone(),
community_id,
deleted: Some(false),
}
}
}

View file

@ -11,7 +11,7 @@ use activitypub_federation::{
};
use either::Either;
use html2md::parse_html;
use lemmy_api_utils::context::LemmyContext;
use lemmy_api_utils::{context::LemmyContext, utils::check_is_mod_or_admin};
use lemmy_db_schema::{
source::{
community::{Community, CommunityActions, CommunityModeratorForm},
@ -322,3 +322,33 @@ pub fn community_visibility(group: &Group) -> CommunityVisibility {
CommunityVisibility::Public
}
}
/// Verify that mod action in community was performed by a moderator.
///
/// * `mod_id` - Activitypub ID of the mod or admin who performed the action
/// * `object_id` - Activitypub ID of the actor or object that is being moderated
/// * `community` - The community inside which moderation is happening
pub async fn verify_mod_action(
mod_id: &ObjectId<ApubPerson>,
community: &Community,
context: &Data<LemmyContext>,
) -> LemmyResult<()> {
// mod action comes from the same instance as the community, so it was presumably done
// by an instance admin.
// TODO: federate instance admin status and check it here
if mod_id.inner().domain() == community.ap_id.domain() {
return Ok(());
}
let site_view = SiteView::read_local(&mut context.pool()).await?;
let local_instance_id = site_view.site.instance_id;
let mod_ = mod_id.dereference(context).await?;
check_is_mod_or_admin(
&mut context.pool(),
mod_.id,
community.id,
local_instance_id,
)
.await
}

View file

@ -25,7 +25,6 @@ full = [
"bcrypt",
"lemmy_utils",
"activitypub_federation",
"regex",
"serde_json",
"diesel_ltree",
"diesel-async",
@ -61,7 +60,6 @@ diesel-async = { workspace = true, features = [
"postgres",
], optional = true }
diesel-uplete = { workspace = true, optional = true }
regex = { workspace = true, optional = true }
diesel_ltree = { workspace = true, optional = true }
tracing = { workspace = true }
deadpool = { version = "0.12.2", optional = true, features = ["rt_tokio_1"] }

View file

@ -46,7 +46,6 @@ use lemmy_utils::{
CACHE_DURATION_LARGEST_COMMUNITY,
};
use moka::future::Cache;
use regex::Regex;
use std::sync::{Arc, LazyLock};
use url::Url;
@ -267,20 +266,6 @@ impl Community {
.and(community::deleted.eq(false))
}
pub fn build_tag_ap_id(&self, tag_name: &str) -> LemmyResult<DbUrl> {
#[allow(clippy::expect_used)]
// convert a readable name to an id slug that is appended to the community URL to get a unique
// tag url (ap_id).
static VALID_ID_SLUG: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[^a-z0-9_-]+").expect("compile regex"));
let tag_name_lower = tag_name.to_lowercase();
let id_slug = VALID_ID_SLUG.replace_all(&tag_name_lower, "-");
if id_slug.is_empty() {
Err(LemmyErrorType::InvalidUrl)?
}
Ok(Url::parse(&format!("{}/tag/{}", self.ap_id, &id_slug))?.into())
}
pub async fn update_federated_followers(
pool: &mut DbPool<'_>,
for_community_id: CommunityId,

View file

@ -29,7 +29,6 @@ pub mod password_reset_request;
pub mod person;
pub mod post;
pub mod post_report;
pub mod post_tag;
pub mod private_message;
pub mod private_message_report;
pub mod registration_application;

View file

@ -1,60 +0,0 @@
use crate::{
diesel::SelectableHelper,
newtypes::{PostId, TagId},
source::post_tag::{PostTag, PostTagForm},
traits::Crud,
utils::{get_conn, DbPool},
};
use diesel::{delete, insert_into, ExpressionMethods, QueryDsl};
use diesel_async::RunQueryDsl;
use lemmy_db_schema_file::schema::post_tag;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
impl PostTag {
pub async fn set(pool: &mut DbPool<'_>, tags: &[PostTagForm]) -> LemmyResult<Vec<Self>> {
let post_id = tags.first().map(|t| t.post_id).unwrap_or_default();
PostTag::delete_for_post(pool, post_id).await?;
PostTag::create_many(pool, tags).await
}
async fn delete_for_post(pool: &mut DbPool<'_>, post_id: PostId) -> LemmyResult<usize> {
let conn = &mut get_conn(pool).await?;
delete(post_tag::table.filter(post_tag::post_id.eq(post_id)))
.execute(conn)
.await
.with_lemmy_type(LemmyErrorType::Deleted)
}
async fn create_many(pool: &mut DbPool<'_>, forms: &[PostTagForm]) -> LemmyResult<Vec<Self>> {
let conn = &mut get_conn(pool).await?;
insert_into(post_tag::table)
.values(forms)
.returning(Self::as_select())
.get_results(conn)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreatePostTag)
}
}
impl Crud for PostTag {
type InsertForm = PostTagForm;
type UpdateForm = PostTagForm;
type IdType = (PostId, TagId);
async fn create(pool: &mut DbPool<'_>, form: &PostTagForm) -> LemmyResult<Self> {
let conn = &mut get_conn(pool).await?;
insert_into(post_tag::table)
.values(form)
.get_result::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreatePostTag)
}
async fn update(
_pool: &mut DbPool<'_>,
_id: Self::IdType,
_form: &Self::UpdateForm,
) -> LemmyResult<Self> {
Err(LemmyErrorType::CouldntUpdatePostTag.into())
}
}

View file

@ -1,36 +1,28 @@
use crate::{
newtypes::{CommunityId, TagId},
source::tag::{Tag, TagInsertForm, TagUpdateForm, TagsView},
diesel::SelectableHelper,
newtypes::{CommunityId, DbUrl, PostId, TagId},
source::{
post::Post,
tag::{PostTag, PostTagForm, Tag, TagInsertForm, TagUpdateForm, TagsView},
},
traits::Crud,
utils::{get_conn, DbPool},
};
use diesel::{
delete,
deserialize::FromSql,
insert_into,
pg::{Pg, PgValue},
serialize::ToSql,
sql_types::{Json, Nullable},
upsert::excluded,
ExpressionMethods,
QueryDsl,
};
use diesel_async::RunQueryDsl;
use lemmy_db_schema_file::schema::tag;
use diesel_async::{scoped_futures::ScopedFutureExt, RunQueryDsl};
use lemmy_db_schema_file::schema::{post_tag, tag};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
impl Tag {
pub async fn get_by_community(
pool: &mut DbPool<'_>,
search_community_id: CommunityId,
) -> LemmyResult<Vec<Self>> {
let conn = &mut get_conn(pool).await?;
tag::table
.filter(tag::community_id.eq(search_community_id))
.filter(tag::deleted.eq(false))
.load::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
}
use std::collections::HashSet;
impl Crud for Tag {
type InsertForm = TagInsertForm;
@ -56,6 +48,91 @@ impl Crud for Tag {
}
}
impl Tag {
pub async fn read_for_community(
pool: &mut DbPool<'_>,
community_id: CommunityId,
) -> LemmyResult<Vec<Self>> {
let conn = &mut get_conn(pool).await?;
tag::table
.filter(tag::community_id.eq(community_id))
.filter(tag::deleted.eq(false))
.load::<Self>(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
pub async fn update_many(
pool: &mut DbPool<'_>,
mut forms: Vec<TagInsertForm>,
existing_tags: Vec<Tag>,
) -> LemmyResult<()> {
let conn = &mut get_conn(pool).await?;
let new_tag_ids = forms
.iter()
.map(|tag| tag.ap_id.clone())
.collect::<HashSet<_>>();
let delete_forms = existing_tags
.into_iter()
.filter(|tag| !new_tag_ids.contains(&tag.ap_id))
.map(|t| TagInsertForm {
ap_id: t.ap_id,
name: t.name,
display_name: None,
community_id: t.community_id,
deleted: Some(true),
description: None,
});
forms.extend(delete_forms);
conn
.run_transaction(|conn| {
async move {
insert_into(tag::table)
.values(&forms)
.on_conflict(tag::ap_id)
.do_update()
.set((
tag::display_name.eq(excluded(tag::display_name)),
tag::description.eq(excluded(tag::description)),
tag::deleted.eq(excluded(tag::deleted)),
))
.execute(conn)
.await?;
Ok(())
}
.scope_boxed()
})
.await?;
Ok(())
}
pub async fn read_for_post(pool: &mut DbPool<'_>, post_id: PostId) -> LemmyResult<Vec<Tag>> {
let conn = &mut get_conn(pool).await?;
post_tag::table
.inner_join(tag::table)
.filter(post_tag::post_id.eq(post_id))
.filter(tag::deleted.eq(false))
.select(tag::all_columns)
.get_results(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
pub async fn read_apub(pool: &mut DbPool<'_>, ap_id: &DbUrl) -> LemmyResult<Tag> {
let conn = &mut get_conn(pool).await?;
tag::table
.filter(tag::ap_id.eq(ap_id))
.filter(tag::deleted.eq(false))
.select(tag::all_columns)
.get_result(conn)
.await
.with_lemmy_type(LemmyErrorType::NotFound)
}
}
impl FromSql<Nullable<Json>, Pg> for TagsView {
fn from_sql(bytes: PgValue) -> diesel::deserialize::Result<Self> {
let value = <serde_json::Value as FromSql<Json, Pg>>::from_sql(bytes)?;
@ -77,3 +154,39 @@ impl ToSql<Nullable<Json>, Pg> for TagsView {
<serde_json::Value as ToSql<Json, Pg>>::to_sql(&value, &mut out.reborrow())
}
}
impl PostTag {
pub async fn update(
pool: &mut DbPool<'_>,
post: &Post,
tag_ids: &[TagId],
) -> LemmyResult<Vec<Self>> {
let conn = &mut get_conn(pool).await?;
conn
.run_transaction(|conn| {
async move {
delete(post_tag::table.filter(post_tag::post_id.eq(post.id)))
.execute(conn)
.await
.with_lemmy_type(LemmyErrorType::Deleted)?;
let forms = tag_ids
.iter()
.map(|tag_id| PostTagForm {
post_id: post.id,
tag_id: *tag_id,
})
.collect::<Vec<_>>();
insert_into(post_tag::table)
.values(forms)
.returning(Self::as_select())
.get_results(conn)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreatePostTag)
}
.scope_boxed()
})
.await
}
}

View file

@ -36,7 +36,6 @@ pub mod password_reset_request;
pub mod person;
pub mod post;
pub mod post_report;
pub mod post_tag;
pub mod private_message;
pub mod private_message_report;
pub mod registration_application;

View file

@ -1,32 +0,0 @@
use crate::newtypes::{PostId, TagId};
use chrono::{DateTime, Utc};
#[cfg(feature = "full")]
use lemmy_db_schema_file::schema::post_tag;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[cfg_attr(
feature = "full",
derive(Queryable, Selectable, Associations, Identifiable)
)]
#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))]
#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::tag::Tag)))]
#[cfg_attr(feature = "full", diesel(table_name = post_tag))]
#[cfg_attr(feature = "full", diesel(primary_key(post_id, tag_id)))]
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
/// An association between a post and a tag. Created/updated by the post author or mods of a
/// community. In the future, more access controls could be added, for example that specific tag
/// types can only be added by mods.
pub struct PostTag {
pub post_id: PostId,
pub tag_id: TagId,
pub published_at: DateTime<Utc>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = post_tag))]
pub struct PostTagForm {
pub post_id: PostId,
pub tag_id: TagId,
}

View file

@ -1,13 +1,14 @@
use crate::newtypes::{CommunityId, DbUrl, TagId};
use crate::newtypes::{CommunityId, DbUrl, PostId, TagId};
use chrono::{DateTime, Utc};
#[cfg(feature = "full")]
use diesel::{sql_types::Nullable, AsExpression, FromSqlRow};
#[cfg(feature = "full")]
use lemmy_db_schema_file::schema::{post_tag, tag};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
#[cfg(feature = "full")]
use {
diesel::{sql_types::Nullable, AsExpression, FromSqlRow},
lemmy_db_schema_file::schema::tag,
};
/// A tag that is created by community moderators, and assigned to posts by the creator
/// or by mods.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))]
@ -15,23 +16,13 @@ use {
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// A tag that can be assigned to a post within a community.
/// The tag object is created by the community moderators.
/// The assignment happens by the post creator and can be updated by the community moderators.
///
/// A tag is a federatable object that gives additional context to another object, which can be
/// displayed and filtered on. Currently, we only have community post tags, which is a tag that is
/// created by the mods of a community, then assigned to posts by post authors as well as mods of a
/// community, to categorize a post.
///
/// In the future we may add more tag types, depending on the requirements, this will lead to either
/// expansion of this table (community_id optional, addition of tag_type enum) or split of this
/// table / creation of new tables.
pub struct Tag {
pub id: TagId,
pub ap_id: DbUrl,
pub display_name: String,
/// the community that owns this tag
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
/// The community that this tag belongs to
pub community_id: CommunityId,
pub published_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
@ -43,27 +34,56 @@ pub struct Tag {
#[cfg_attr(feature = "full", diesel(table_name = tag))]
pub struct TagInsertForm {
pub ap_id: DbUrl,
pub display_name: String,
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
pub community_id: CommunityId,
pub deleted: Option<bool>,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "full", derive(AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = tag))]
pub struct TagUpdateForm {
pub ap_id: Option<DbUrl>,
pub display_name: Option<String>,
pub display_name: Option<Option<String>>,
pub description: Option<Option<String>>,
pub community_id: Option<CommunityId>,
pub published_at: Option<DateTime<Utc>>,
pub updated_at: Option<Option<DateTime<Utc>>>,
pub deleted: Option<bool>,
}
/// We wrap this in a struct so we can implement FromSqlRow<Json> for it
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)]
#[serde(transparent)]
#[cfg_attr(feature = "full", derive(FromSqlRow, AsExpression))]
#[cfg_attr(feature = "full", diesel(sql_type = Nullable<diesel::sql_types::Json>))]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// we wrap this in a struct so we can implement FromSqlRow<Json> for it
pub struct TagsView(pub Vec<Tag>);
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[cfg_attr(
feature = "full",
derive(Queryable, Selectable, Associations, Identifiable)
)]
#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))]
#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::tag::Tag)))]
#[cfg_attr(feature = "full", diesel(table_name = post_tag))]
#[cfg_attr(feature = "full", diesel(primary_key(post_id, tag_id)))]
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
/// An association between a post and a tag. Created/updated by the post author or mods of a
/// community.
pub struct PostTag {
pub post_id: PostId,
pub tag_id: TagId,
pub published_at: DateTime<Utc>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = post_tag))]
pub struct PostTagForm {
pub post_id: PostId,
pub tag_id: TagId,
}

View file

@ -1113,7 +1113,11 @@ diesel::table! {
tag (id) {
id -> Int4,
ap_id -> Text,
display_name -> Text,
#[max_length = 255]
name -> Varchar,
#[max_length = 255]
display_name -> Nullable<Varchar>,
description -> Nullable<Text>,
community_id -> Int4,
published_at -> Timestamptz,
updated_at -> Nullable<Timestamptz>,

View file

@ -128,16 +128,6 @@ pub struct CreateCommunity {
pub visibility: Option<CommunityVisibility>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Create a tag for a community.
pub struct CreateCommunityTag {
pub community_id: CommunityId,
pub display_name: String,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
@ -281,25 +271,6 @@ pub struct TransferCommunity {
pub person_id: PersonId,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Update a community tag.
pub struct UpdateCommunityTag {
pub tag_id: TagId,
pub display_name: String,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Delete a community tag.
pub struct DeleteCommunityTag {
pub tag_id: TagId,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
@ -373,3 +344,35 @@ pub struct UpdateCommunityNotifications {
pub community_id: CommunityId,
pub mode: CommunityNotificationsMode,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Create a tag for a community.
pub struct CreateCommunityTag {
pub community_id: CommunityId,
pub name: String,
pub display_name: Option<String>,
pub description: Option<String>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Make changes to a community tag
pub struct UpdateCommunityTag {
pub tag_id: TagId,
pub display_name: Option<String>,
pub description: Option<String>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Delete a community tag.
pub struct DeleteCommunityTag {
pub tag_id: TagId,
}

View file

@ -83,6 +83,17 @@ pub struct EditPost {
pub tags: Option<Vec<TagId>>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Mods can change some metadata for posts
pub struct ModEditPost {
pub post_id: PostId,
pub nsfw: Option<bool>,
pub tags: Option<Vec<TagId>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]

View file

@ -599,9 +599,8 @@ mod tests {
PostReadForm,
PostUpdateForm,
},
post_tag::{PostTag, PostTagForm},
site::Site,
tag::{Tag, TagInsertForm},
tag::{PostTag, Tag, TagInsertForm},
},
test_data::TestData,
traits::{Bannable, Blockable, Crud, Followable, Likeable},
@ -729,8 +728,11 @@ mod tests {
pool,
&TagInsertForm {
ap_id: Url::parse(&format!("{}/tags/test_tag1", community.ap_id))?.into(),
display_name: "Test Tag 1".into(),
name: "Test Tag 1".into(),
display_name: None,
description: None,
community_id: community.id,
deleted: Some(false),
},
)
.await?;
@ -738,8 +740,11 @@ mod tests {
pool,
&TagInsertForm {
ap_id: Url::parse(&format!("{}/tags/test_tag2", community.ap_id))?.into(),
display_name: "Test Tag 2".into(),
name: "Test Tag 2".into(),
display_name: None,
description: None,
community_id: community.id,
deleted: Some(false),
},
)
.await?;
@ -770,17 +775,7 @@ mod tests {
};
let post_with_tags = Post::create(pool, &new_post).await?;
let inserted_tags = vec![
PostTagForm {
post_id: post_with_tags.id,
tag_id: tag_1.id,
},
PostTagForm {
post_id: post_with_tags.id,
tag_id: tag_2.id,
},
];
PostTag::set(pool, &inserted_tags).await?;
PostTag::update(pool, &post_with_tags, &[tag_1.id, tag_2.id]).await?;
let tegan = LocalUserView {
local_user: inserted_tegan_local_user,
@ -2356,8 +2351,8 @@ mod tests {
.await?;
assert_eq!(2, post_view.tags.0.len());
assert_eq!(data.tag_1.display_name, post_view.tags.0[0].display_name);
assert_eq!(data.tag_2.display_name, post_view.tags.0[1].display_name);
assert_eq!(data.tag_1.name, post_view.tags.0[0].name);
assert_eq!(data.tag_2.name, post_view.tags.0[1].name);
let all_posts = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(2, all_posts[0].tags.0.len()); // post with tags

View file

@ -136,7 +136,6 @@ pub enum LemmyErrorType {
BanExpirationInPast,
InvalidUnixTime,
InvalidBotAction,
InvalidTagName,
TagNotInCommunity,
CantBlockLocalInstance,
Unknown(String),
@ -171,7 +170,6 @@ pub enum LemmyErrorType {
CouldntCreateTag,
CouldntUpdateTag,
CouldntCreatePostTag,
CouldntUpdatePostTag,
CouldntCreateTagline,
CouldntUpdateTagline,
CouldntCreateImage,

View file

@ -29,8 +29,6 @@ const SITE_NAME_MIN_LENGTH: usize = 1;
const SITE_DESCRIPTION_MAX_LENGTH: usize = 150;
const MIN_LENGTH_BLOCKING_KEYWORD: usize = 3;
const MAX_LENGTH_BLOCKING_KEYWORD: usize = 50;
const TAG_NAME_MIN_LENGTH: usize = 3;
const TAG_NAME_MAX_LENGTH: usize = 100;
fn has_newline(name: &str) -> bool {
name.contains('\n')
@ -141,7 +139,7 @@ pub fn site_name_length_check(name: &str) -> LemmyResult<()> {
}
/// Checks the site / community description length, the limit as defined in the DB.
pub fn site_or_community_description_length_check(description: &str) -> LemmyResult<()> {
pub fn description_length_check(description: &str) -> LemmyResult<()> {
max_length_check(
description,
SITE_DESCRIPTION_MAX_LENGTH,
@ -149,19 +147,6 @@ pub fn site_or_community_description_length_check(description: &str) -> LemmyRes
)
}
pub fn tag_name_length_check(tag_name: &str) -> LemmyResult<()> {
min_length_check(
tag_name,
TAG_NAME_MIN_LENGTH,
LemmyErrorType::InvalidTagName,
)?;
max_length_check(
tag_name,
TAG_NAME_MAX_LENGTH,
LemmyErrorType::InvalidTagName,
)
}
/// Check minimum and maximum length of input string. If the string is too short or too long, the
/// corresponding error is returned.
///
@ -370,6 +355,7 @@ mod tests {
check_urls_are_valid,
clean_url,
clean_urls_in_text,
description_length_check,
is_url_blocked,
is_valid_actor_name,
is_valid_bio_field,
@ -378,7 +364,6 @@ mod tests {
is_valid_post_title,
is_valid_url,
site_name_length_check,
site_or_community_description_length_check,
truncate_for_db,
BIO_MAX_LENGTH,
SITE_DESCRIPTION_MAX_LENGTH,
@ -561,14 +546,14 @@ Line3",
#[test]
fn test_valid_site_description() {
assert!(site_or_community_description_length_check(
assert!(description_length_check(
&(0..SITE_DESCRIPTION_MAX_LENGTH)
.map(|_| 'A')
.collect::<String>()
)
.is_ok());
let invalid_result = site_or_community_description_length_check(
let invalid_result = description_length_check(
&(0..SITE_DESCRIPTION_MAX_LENGTH + 1)
.map(|_| 'A')
.collect::<String>(),

View file

@ -6,7 +6,9 @@
CREATE TABLE tag (
id serial PRIMARY KEY,
ap_id text NOT NULL UNIQUE,
display_name text NOT NULL,
name varchar(255) NOT NULL,
display_name varchar(255),
description text,
community_id int NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE,
published timestamptz NOT NULL DEFAULT now(),
updated timestamptz,

View file

@ -65,6 +65,7 @@ use lemmy_api::{
lock::lock_post,
mark_many_read::mark_posts_as_read,
mark_read::mark_post_as_read,
mod_update::mod_update_post,
save::save_post,
update_notifications::update_post_notifications,
},
@ -295,7 +296,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) {
.route("/save", put().to(save_post))
.route("/report", post().to(create_post_report))
.route("/report/resolve", put().to(resolve_post_report))
.route("/notifications", post().to(update_post_notifications)),
.route("/notifications", post().to(update_post_notifications))
.route("/mod_update", put().to(mod_update_post)),
)
// Comment
.service(