mirror of
https://github.com/LemmyNet/lemmy.git
synced 2025-09-02 19:23:49 +00:00
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:
parent
04cbabf6f7
commit
7dfd5ef9e7
55 changed files with 938 additions and 562 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3357,7 +3357,6 @@ dependencies = [
|
|||
"lemmy_utils",
|
||||
"moka",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"rustls 0.23.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
71
crates/api/api/src/post/mod_update.rs
Normal file
71
crates/api/api/src/post/mod_update.rs
Normal 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
|
||||
}
|
|
@ -45,7 +45,6 @@ pub mod actions {
|
|||
PurgeCommunity,
|
||||
RemoveCommunity,
|
||||
TransferCommunity,
|
||||
UpdateCommunityTag,
|
||||
};
|
||||
pub use lemmy_db_views_community_follower::{
|
||||
api::{
|
||||
|
|
|
@ -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)?;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)?;
|
||||
}
|
||||
|
||||
|
|
|
@ -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)?;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,5 +5,4 @@ pub mod notify;
|
|||
pub mod plugins;
|
||||
pub mod request;
|
||||
pub mod send_activity;
|
||||
pub mod tags;
|
||||
pub mod utils;
|
||||
|
|
|
@ -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(())
|
||||
}
|
|
@ -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::*;
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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::{
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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::{
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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?;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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)?
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ pub mod note;
|
|||
pub mod page;
|
||||
pub mod person;
|
||||
pub mod private_message;
|
||||
pub mod tags;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
@ -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 {
|
||||
|
|
50
crates/apub_objects/src/protocol/tags.rs
Normal file
50
crates/apub_objects/src/protocol/tags.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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))]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue