Community post tags (part 2: API methods) (#5389)

* post tags part 2

* post tags part 2 (api methods)

* re-remove tracing

* api tests

* update according to comments

* update lints and api package

* validate tags, fix compile

* insert many simultaneously

* o

* temp revert for tests

* update dep

* Revert "temp revert for tests"

This reverts commit 62f17de60d.

* fix deps

* limit tag length

* fix comments

* rename display_name

* lint

* lint

* bullshit

* clarify comment

* fix after merge

* lint.sh

* upgrade dep

* rename

* dep

---------

Co-authored-by: Dessalines <tyhou13@gmx.com>
This commit is contained in:
phiresky 2025-04-04 20:26:25 +02:00 committed by GitHub
parent 9bfaad8a0d
commit 74701af627
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 698 additions and 145 deletions

View file

@ -12,7 +12,8 @@ variables:
- &install_binstall "wget -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -xvz -C /usr/local/cargo/bin"
- install_diesel_cli: &install_diesel_cli
- apt-get update && apt-get install -y postgresql-client
- cargo install --locked diesel_cli --no-default-features --features postgres
# diesel_cli@2.2.8 is the last version that supports rust 1.81, which we are currently locked on due to perf regressions on rust 1.82+ :(
- cargo install --locked diesel_cli@2.2.8 --no-default-features --features postgres
- export PATH="$CARGO_HOME/bin:$PATH"
- &slow_check_paths
- event: pull_request

View file

@ -10,7 +10,7 @@
"scripts": {
"lint": "tsc --noEmit && eslint --report-unused-disable-directives && prettier --check 'src/**/*.ts'",
"fix": "prettier --write src && eslint --fix src",
"api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i private_community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts ",
"api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i private_community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts && jest -i tags.spec.ts",
"api-test-follow": "jest -i follow.spec.ts",
"api-test-comment": "jest -i comment.spec.ts",
"api-test-post": "jest -i post.spec.ts",
@ -18,18 +18,19 @@
"api-test-community": "jest -i community.spec.ts",
"api-test-private-community": "jest -i private_community.spec.ts",
"api-test-private-message": "jest -i private_message.spec.ts",
"api-test-image": "jest -i image.spec.ts"
"api-test-image": "jest -i image.spec.ts",
"api-test-tags": "jest -i tags.spec.ts"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/jest": "^29.5.12",
"@types/joi": "^17.2.3",
"@types/node": "^22.13.1",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"eslint": "^9.20.0",
"eslint-plugin-prettier": "^5.2.3",
"jest": "^29.5.0",
"lemmy-js-client": "1.0.0-site-person-ban.1",
"lemmy-js-client": "npm:@phiresky/lemmy-js-client@1.0.0-post-tags.7",
"prettier": "^3.5.0",
"ts-jest": "^29.1.0",
"tsoa": "^6.6.0",

View file

@ -8,12 +8,12 @@ importers:
.:
devDependencies:
'@eslint/js':
specifier: ^9.21.0
version: 9.23.0
'@types/jest':
specifier: ^29.5.12
version: 29.5.14
'@types/joi':
specifier: ^17.2.3
version: 17.2.3
'@types/node':
specifier: ^22.13.1
version: 22.13.1
@ -33,8 +33,8 @@ importers:
specifier: ^29.5.0
version: 29.7.0(@types/node@22.13.1)
lemmy-js-client:
specifier: 1.0.0-site-person-ban.1
version: 1.0.0-site-person-ban.1
specifier: npm:@phiresky/lemmy-js-client@1.0.0-post-tags.7
version: '@phiresky/lemmy-js-client@1.0.0-post-tags.7'
prettier:
specifier: ^3.5.0
version: 3.5.0
@ -254,6 +254,10 @@ packages:
resolution: {integrity: sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.23.0':
resolution: {integrity: sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.5':
resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -309,9 +313,6 @@ packages:
'@hapi/hoek@11.0.7':
resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==}
'@hapi/hoek@9.3.0':
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
'@hapi/iron@7.0.1':
resolution: {integrity: sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==}
@ -344,9 +345,6 @@ packages:
resolution: {integrity: sha512-05HumSy3LWfXpmJ9cr6HzwhAavrHkJ1ZRCmNE2qJMihdM5YcWreWPfyN0yKT2ZjCM92au3ZkuodjBxOibxM67A==}
engines: {node: '>=14.0.0'}
'@hapi/topo@5.1.0':
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
'@hapi/topo@6.0.2':
resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==}
@ -487,6 +485,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@phiresky/lemmy-js-client@1.0.0-post-tags.7':
resolution: {integrity: sha512-zuGyIx54ykPW1nI/0T7RG99D+MP1Ix8lYwbWydLNYY4nhcr37baXq0e+LJjsiCJIv2KptfJI/6pndUkUQjr8JQ==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@ -495,15 +496,6 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
'@sideway/formula@3.0.1':
resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==}
'@sideway/pinpoint@2.0.0':
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@ -579,10 +571,6 @@ packages:
'@types/jest@29.5.14':
resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==}
'@types/joi@17.2.3':
resolution: {integrity: sha512-dGjs/lhrWOa+eO0HwgxCSnDm5eMGCsXuvLglMghJq32F6q5LyyNuXb41DHzrg501CKNOSSAHmfB7FDGeUnDmzw==}
deprecated: This is a stub types definition. joi provides its own type definitions, so you do not need this installed.
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1482,9 +1470,6 @@ packages:
node-notifier:
optional: true
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -1528,9 +1513,6 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
lemmy-js-client@1.0.0-site-person-ban.1:
resolution: {integrity: sha512-CcTxrjh1RuTDRDEzG4xPO2VbRA7XEHUD6EJSOAqciyFdgXx7Dwk63cG71FTM2ohpdLycPFI8IhgzJHZIY4jOyQ==}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@ -2416,6 +2398,8 @@ snapshots:
'@eslint/js@9.20.0': {}
'@eslint/js@9.23.0': {}
'@eslint/object-schema@2.1.5': {}
'@eslint/plugin-kit@0.2.5':
@ -2503,8 +2487,6 @@ snapshots:
'@hapi/hoek@11.0.7': {}
'@hapi/hoek@9.3.0': {}
'@hapi/iron@7.0.1':
dependencies:
'@hapi/b64': 6.0.1
@ -2569,10 +2551,6 @@ snapshots:
'@hapi/teamwork@6.0.0': {}
'@hapi/topo@5.1.0':
dependencies:
'@hapi/hoek': 9.3.0
'@hapi/topo@6.0.2':
dependencies:
'@hapi/hoek': 11.0.7
@ -2815,19 +2793,17 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.0
'@phiresky/lemmy-js-client@1.0.0-post-tags.7':
dependencies:
'@tsoa/runtime': 6.6.0
transitivePeerDependencies:
- supports-color
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.1.1': {}
'@sideway/address@4.1.5':
dependencies:
'@hapi/hoek': 9.3.0
'@sideway/formula@3.0.1': {}
'@sideway/pinpoint@2.0.0': {}
'@sinclair/typebox@0.27.8': {}
'@sinonjs/commons@3.0.1':
@ -2949,10 +2925,6 @@ snapshots:
expect: 29.7.0
pretty-format: 29.7.0
'@types/joi@17.2.3':
dependencies:
joi: 17.13.3
'@types/json-schema@7.0.15': {}
'@types/keygrip@1.0.6': {}
@ -4126,14 +4098,6 @@ snapshots:
- supports-color
- ts-node
joi@17.13.3:
dependencies:
'@hapi/hoek': 9.3.0
'@hapi/topo': 5.1.0
'@sideway/address': 4.1.5
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
js-tokens@4.0.0: {}
js-yaml@3.14.1:
@ -4169,8 +4133,6 @@ snapshots:
kleur@3.0.3: {}
lemmy-js-client@1.0.0-site-person-ban.1: {}
leven@3.1.0: {}
levn@0.4.1:

149
api_tests/src/tags.spec.ts Normal file
View file

@ -0,0 +1,149 @@
jest.setTimeout(120000);
import {
alpha,
setupLogins,
createCommunity,
unfollows,
randomString,
createPost,
} 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";
beforeAll(setupLogins);
afterAll(unfollows);
test("Create, update, delete community tag", async () => {
// Create a community first
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 createRes = await alpha.createCommunityTag(createForm);
expect(createRes.id).toBeDefined();
expect(createRes.display_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);
// 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);
// Delete the tag
let deleteForm: DeleteCommunityTag = {
tag_id: createRes.id,
};
let deleteRes = await alpha.deleteCommunityTag(deleteForm);
expect(deleteRes.id).toBe(createRes.id);
// Verify tag is deleted
listRes = await alpha.getCommunity({ id: communityId });
expect(
listRes.community_view.post_tags.find(t => t.id === createRes.id),
).toBeUndefined();
expect(listRes.community_view.post_tags.length).toBe(0);
});
test("Update post tags", async () => {
// Create a community
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
// Create two tags
const tag1Name = randomString(10);
let createForm1: CreateCommunityTag = {
display_name: tag1Name,
community_id: communityId,
};
let tag1Res = await alpha.createCommunityTag(createForm1);
expect(tag1Res.id).toBeDefined();
const tag2Name = randomString(10);
let createForm2: CreateCommunityTag = {
display_name: tag2Name,
community_id: communityId,
};
let tag2Res = await alpha.createCommunityTag(createForm2);
expect(tag2Res.id).toBeDefined();
// Create a post
let postRes = await alpha.createPost({
name: randomString(10),
community_id: communityId,
});
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(),
);
// 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(
alpha,
communityId,
"https://example.com/",
"post with tags",
);
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);
expect(updateRes.post_view.tags?.length).toBe(1);
expect(updateRes.post_view.tags?.[0].id).toBe(tagRes.id);
});

View file

@ -25,12 +25,12 @@ lemmy_api_common = { workspace = true, features = ["full"] }
lemmy_db_schema_file = { workspace = true }
lemmy_email = { workspace = true }
activitypub_federation = { workspace = true }
tracing = { workspace = true }
bcrypt = { workspace = true }
actix-web = { workspace = true }
base64 = { workspace = true }
captcha = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
url = { workspace = true }
regex = { workspace = true }

View file

@ -4,4 +4,5 @@ pub mod block;
pub mod follow;
pub mod pending_follows;
pub mod random;
pub mod tag;
pub mod transfer;

View file

@ -0,0 +1,87 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use chrono::Utc;
use lemmy_api_common::{
community::{CreateCommunityTag, DeleteCommunityTag, UpdateCommunityTag},
context::LemmyContext,
utils::check_community_mod_action,
};
use lemmy_db_schema::{
source::{
community::Community,
tag::{Tag, TagInsertForm, TagUpdateForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{error::LemmyResult, utils::validation::tag_name_length_check};
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?;
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?;
// Create the tag
let tag_form = TagInsertForm {
display_name: data.display_name.clone(),
community_id: data.community_id,
ap_id: community.build_tag_ap_id(&data.display_name)?,
};
let tag = Tag::create(&mut context.pool(), &tag_form).await?;
Ok(Json(tag))
}
pub async fn update_community_tag(
data: Json<UpdateCommunityTag>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Tag>> {
let tag = Tag::read(&mut context.pool(), data.tag_id).await?;
let community = Community::read(&mut context.pool(), tag.community_id).await?;
// 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)?;
// Update the tag
let tag_form = TagUpdateForm {
display_name: Some(data.display_name.clone()),
updated: Some(Some(Utc::now())),
..Default::default()
};
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
Ok(Json(tag))
}
pub async fn delete_community_tag(
data: Json<DeleteCommunityTag>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Tag>> {
let tag = Tag::read(&mut context.pool(), data.tag_id).await?;
let community = Community::read(&mut context.pool(), tag.community_id).await?;
// Verify that only mods can delete tags
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
// Soft delete the tag
let tag_form = TagUpdateForm {
updated: Some(Some(Utc::now())),
deleted: Some(true),
..Default::default()
};
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
Ok(Json(tag))
}

View file

@ -1,5 +1,5 @@
use lemmy_db_schema::{
newtypes::{CommunityId, LanguageId, PersonId},
newtypes::{CommunityId, LanguageId, PersonId, TagId},
source::site::Site,
};
use lemmy_db_schema_file::enums::{CommunityVisibility, ListingType};
@ -15,6 +15,35 @@ use serde_with::skip_serializing_none;
#[cfg(feature = "full")]
use ts_rs::TS;
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(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)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(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 = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]
/// Delete a community tag.
pub struct DeleteCommunityTag {
pub tag_id: TagId,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "full", derive(TS))]

View file

@ -22,6 +22,8 @@ pub mod send_activity;
pub mod site;
pub mod tagline;
#[cfg(feature = "full")]
pub mod tags;
#[cfg(feature = "full")]
pub mod utils;
pub extern crate lemmy_db_schema;

View file

@ -175,11 +175,11 @@ pub struct EditPost {
/// Instead of fetching a thumbnail, use a custom one.
#[cfg_attr(feature = "full", ts(optional))]
pub custom_thumbnail: Option<String>,
#[cfg_attr(feature = "full", ts(optional))]
pub tags: Option<Vec<TagId>>,
/// Time when this post should be scheduled. Null means publish immediately.
#[cfg_attr(feature = "full", ts(optional))]
pub scheduled_publish_time: Option<i64>,
#[cfg_attr(feature = "full", ts(optional))]
pub tags: Option<Vec<TagId>>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)]

View file

@ -0,0 +1,43 @@
use crate::{context::LemmyContext, utils::check_community_mod_action};
use lemmy_db_schema::{
newtypes::TagId,
source::{post::Post, post_tag::PostTag, tag::PostTagInsertForm},
};
use lemmy_db_views::structs::{CommunityView, 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| PostTagInsertForm {
post_id: post.id,
tag_id: *tag_id,
})
.collect();
PostTag::set(&mut context.pool(), post.id, insert_tags).await?;
Ok(())
}

View file

@ -946,7 +946,7 @@ pub fn read_auth_token(req: &HttpRequest) -> LemmyResult<Option<String>> {
}
}
pub fn send_webmention(post: Post, community: Community) {
pub fn send_webmention(post: Post, community: &Community) {
if let Some(url) = post.url.clone() {
if community.visibility.can_view_without_login() {
spawn_try_task(async move {

View file

@ -9,6 +9,7 @@ use lemmy_api_common::{
post::{CreatePost, PostResponse},
request::generate_post_link_metadata,
send_activity::SendActivityData,
tags::update_post_tags,
utils::{
check_community_user_action,
check_nsfw_allowed,
@ -22,14 +23,11 @@ use lemmy_api_common::{
use lemmy_db_schema::{
impls::actor_language::validate_post_language,
newtypes::PostOrCommentId,
source::{
community::Community,
post::{Post, PostActions, PostInsertForm, PostLikeForm, PostReadForm},
},
source::post::{Post, PostActions, PostInsertForm, PostLikeForm, PostReadForm},
traits::{Crud, Likeable, Readable},
utils::diesel_url_create,
};
use lemmy_db_views::structs::{CommunityModeratorView, LocalUserView, SiteView};
use lemmy_db_views::structs::{CommunityModeratorView, CommunityView, LocalUserView, SiteView};
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
utils::{
@ -81,8 +79,15 @@ pub async fn create_post(
is_valid_body_field(body, true)?;
}
let community = Community::read(&mut context.pool(), data.community_id).await?;
check_community_user_action(&local_user_view, &community, &mut context.pool()).await?;
let community_view = CommunityView::read(
&mut context.pool(),
data.community_id,
Some(&local_user_view.local_user),
false,
)
.await?;
let community = &community_view.community;
check_community_user_action(&local_user_view, community, &mut context.pool()).await?;
// If its an NSFW community, then use that as a default
let nsfw = data.nsfw.or(Some(community.nsfw));
@ -113,7 +118,7 @@ pub async fn create_post(
alt_text: data.alt_text.clone(),
nsfw,
language_id: Some(language_id),
federation_pending: Some(community_use_pending(&community, &context).await),
federation_pending: Some(community_use_pending(community, &context).await),
scheduled_publish_time,
..PostInsertForm::new(
data.name.trim().to_string(),
@ -127,8 +132,20 @@ pub async fn create_post(
let inserted_post = Post::create(&mut context.pool(), &post_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreatePost)?;
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?;
}
let community_id = community.id;
let federate_post = if scheduled_publish_time.is_none() {
send_webmention(inserted_post.clone(), community);

View file

@ -9,6 +9,7 @@ use lemmy_api_common::{
post::{EditPost, PostResponse},
request::generate_post_link_metadata,
send_activity::SendActivityData,
tags::update_post_tags,
utils::{
check_community_user_action,
check_nsfw_allowed,
@ -28,7 +29,7 @@ use lemmy_db_schema::{
traits::Crud,
utils::{diesel_string_update, diesel_url_update},
};
use lemmy_db_views::structs::{LocalUserView, PostView, SiteView};
use lemmy_db_views::structs::{CommunityView, LocalUserView, PostView, SiteView};
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
utils::{
@ -98,6 +99,20 @@ 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)?
@ -166,7 +181,7 @@ pub async fn update_post(
// schedule was removed, send create activity and webmention
(Some(_), None) => {
let community = Community::read(&mut context.pool(), orig_post.community.id).await?;
send_webmention(updated_post.clone(), community);
send_webmention(updated_post.clone(), &community);
generate_post_link_metadata(
updated_post.clone(),
custom_thumbnail.flatten().map(Into::into),

View file

@ -44,6 +44,8 @@ use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::structs::Settings,
};
use regex::Regex;
use std::sync::LazyLock;
use url::Url;
impl Crud for Community {
@ -258,6 +260,20 @@ 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 fn local_url(name: &str, settings: &Settings) -> LemmyResult<DbUrl> {
let domain = settings.get_protocol_and_hostname();
Ok(Url::parse(&format!("{domain}/c/{name}"))?.into())

View file

@ -28,6 +28,7 @@ pub mod person_comment_mention;
pub mod person_post_mention;
pub mod post;
pub mod post_report;
pub mod post_tag;
pub mod private_message;
pub mod private_message_report;
pub mod registration_application;

View file

@ -0,0 +1,71 @@
use crate::{
diesel::SelectableHelper,
newtypes::{PostId, TagId},
source::{
post_tag::{PostTag, PostTagForm},
tag::PostTagInsertForm,
},
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;
impl PostTag {
pub async fn set(
pool: &mut DbPool<'_>,
post_id: PostId,
tags: Vec<PostTagInsertForm>,
) -> Result<Vec<Self>, diesel::result::Error> {
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,
) -> Result<usize, diesel::result::Error> {
let conn = &mut get_conn(pool).await?;
delete(post_tag::table.filter(post_tag::post_id.eq(post_id)))
.execute(conn)
.await
}
pub async fn create_many(
pool: &mut DbPool<'_>,
forms: Vec<PostTagInsertForm>,
) -> Result<Vec<Self>, diesel::result::Error> {
let conn = &mut get_conn(pool).await?;
insert_into(post_tag::table)
.values(forms)
.returning(Self::as_select())
.get_results(conn)
.await
}
}
impl Crud for PostTag {
type InsertForm = PostTagInsertForm;
type UpdateForm = PostTagForm;
type IdType = (PostId, TagId);
async fn create(
pool: &mut DbPool<'_>,
form: &PostTagInsertForm,
) -> Result<Self, diesel::result::Error> {
let conn = &mut get_conn(pool).await?;
insert_into(post_tag::table)
.values(form)
.get_result::<Self>(conn)
.await
}
async fn update(
_pool: &mut DbPool<'_>,
_id: Self::IdType,
_form: &Self::UpdateForm,
) -> Result<Self, diesel::result::Error> {
Err(diesel::result::Error::QueryBuilderError(
"PostTag does not support (create+delete only)".into(),
))
}
}

View file

@ -1,18 +1,31 @@
use crate::{
newtypes::TagId,
source::tag::{PostTagInsertForm, Tag, TagInsertForm},
newtypes::{CommunityId, TagId},
source::tag::{PostTagInsertForm, Tag, TagInsertForm, TagUpdateForm},
traits::Crud,
utils::{get_conn, DbPool},
};
use diesel::{insert_into, result::Error, QueryDsl};
use diesel::{insert_into, result::Error, ExpressionMethods, QueryDsl};
use diesel_async::RunQueryDsl;
use lemmy_db_schema_file::schema::{post_tag, tag};
use lemmy_utils::error::LemmyResult;
impl Tag {
pub async fn get_by_community(
pool: &mut DbPool<'_>,
search_community_id: CommunityId,
) -> Result<Vec<Self>, Error> {
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
}
}
impl Crud for Tag {
type InsertForm = TagInsertForm;
type UpdateForm = TagInsertForm;
type UpdateForm = TagUpdateForm;
type IdType = TagId;

View file

@ -34,6 +34,7 @@ pub mod person_comment_mention;
pub mod person_post_mention;
pub mod post;
pub mod post_report;
pub mod post_tag;
pub mod private_message;
pub mod private_message_report;
pub mod registration_application;

View file

@ -0,0 +1,32 @@
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: DateTime<Utc>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = post_tag))]
pub struct PostTagForm {
pub post_id: PostId,
pub tag_id: TagId,
}

View file

@ -7,26 +7,28 @@ use serde_with::skip_serializing_none;
#[cfg(feature = "full")]
use ts_rs::TS;
/// 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 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.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(TS, Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "full", diesel(table_name = tag))]
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
#[cfg_attr(feature = "full", ts(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 name: String,
pub display_name: String,
/// the community that owns this tag
pub community_id: CommunityId,
pub published: DateTime<Utc>,
@ -40,12 +42,20 @@ pub struct Tag {
#[cfg_attr(feature = "full", diesel(table_name = tag))]
pub struct TagInsertForm {
pub ap_id: DbUrl,
pub name: String,
pub display_name: String,
pub community_id: CommunityId,
// default now
}
#[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 community_id: Option<CommunityId>,
pub published: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub deleted: bool,
pub updated: Option<Option<DateTime<Utc>>>,
pub deleted: Option<bool>,
}
#[derive(Debug, Clone)]

View file

@ -1051,7 +1051,7 @@ diesel::table! {
tag (id) {
id -> Int4,
ap_id -> Text,
name -> Text,
display_name -> Text,
community_id -> Int4,
published -> Timestamptz,
updated -> Nullable<Timestamptz>,

View file

@ -366,6 +366,7 @@ impl InternalToCombinedView for InboxCombinedViewInternal {
creator_local_instance_actions: v.creator_local_instance_actions,
creator_community_actions: v.creator_community_actions,
creator_is_admin: v.item_creator_is_admin,
post_tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))
@ -413,6 +414,7 @@ impl InternalToCombinedView for InboxCombinedViewInternal {
image_details: v.image_details,
creator_community_actions: v.creator_community_actions,
creator_is_admin: v.item_creator_is_admin,
post_tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))

View file

@ -224,6 +224,7 @@ impl InternalToCombinedView for PersonContentCombinedViewInternal {
creator_local_instance_actions: v.creator_local_instance_actions,
creator_community_actions: v.creator_community_actions,
creator_is_admin: v.item_creator_is_admin,
post_tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))
@ -241,6 +242,7 @@ impl InternalToCombinedView for PersonContentCombinedViewInternal {
creator_local_instance_actions: v.creator_local_instance_actions,
creator_community_actions: v.creator_community_actions,
creator_is_admin: v.item_creator_is_admin,
tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))

View file

@ -211,6 +211,7 @@ impl InternalToCombinedView for PersonSavedCombinedViewInternal {
creator_local_instance_actions: v.creator_local_instance_actions,
creator_community_actions: v.creator_community_actions,
creator_is_admin: v.item_creator_is_admin,
post_tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))
@ -228,6 +229,7 @@ impl InternalToCombinedView for PersonSavedCombinedViewInternal {
creator_local_instance_actions: v.creator_local_instance_actions,
creator_community_actions: v.creator_community_actions,
creator_is_admin: v.item_creator_is_admin,
tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))

View file

@ -360,6 +360,7 @@ impl InternalToCombinedView for SearchCombinedViewInternal {
person_actions: v.person_actions,
comment_actions: v.comment_actions,
creator_is_admin: v.item_creator_is_admin,
post_tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))
@ -379,6 +380,7 @@ impl InternalToCombinedView for SearchCombinedViewInternal {
creator_community_actions: v.creator_community_actions,
person_actions: v.person_actions,
post_actions: v.post_actions,
tags: v.post_tags,
can_mod: v.can_mod,
creator_banned: v.creator_banned,
}))
@ -388,6 +390,7 @@ impl InternalToCombinedView for SearchCombinedViewInternal {
community_actions: v.community_actions,
instance_actions: v.instance_actions,
can_mod: v.can_mod,
post_tags: v.community_post_tags,
}))
} else if let Some(person) = v.item_creator {
Some(SearchCombinedView::Person(PersonView {

View file

@ -1,5 +1,5 @@
//! see post_view.rs for the reason for this json decoding
use crate::structs::PostTags;
use crate::structs::TagsView;
use diesel::{
deserialize::FromSql,
pg::{Pg, PgValue},
@ -7,22 +7,22 @@ use diesel::{
sql_types::{self, Nullable},
};
impl FromSql<Nullable<sql_types::Json>, Pg> for PostTags {
impl FromSql<Nullable<sql_types::Json>, Pg> for TagsView {
fn from_sql(bytes: PgValue) -> diesel::deserialize::Result<Self> {
let value = <serde_json::Value as FromSql<sql_types::Json, Pg>>::from_sql(bytes)?;
Ok(serde_json::from_value::<PostTags>(value)?)
Ok(serde_json::from_value::<TagsView>(value)?)
}
fn from_nullable_sql(
bytes: Option<<Pg as diesel::backend::Backend>::RawValue<'_>>,
) -> diesel::deserialize::Result<Self> {
match bytes {
Some(bytes) => Self::from_sql(bytes),
None => Ok(Self { tags: vec![] }),
None => Ok(Self(vec![])),
}
}
}
impl ToSql<Nullable<sql_types::Json>, Pg> for PostTags {
impl ToSql<Nullable<sql_types::Json>, Pg> for TagsView {
fn to_sql(&self, out: &mut diesel::serialize::Output<Pg>) -> diesel::serialize::Result {
let value = serde_json::to_value(self)?;
<serde_json::Value as ToSql<sql_types::Json, Pg>>::to_sql(&value, &mut out.reborrow())

View file

@ -609,8 +609,8 @@ mod tests {
post: Post,
bot_post: Post,
post_with_tags: Post,
_tag_1: Tag,
_tag_2: Tag,
tag_1: Tag,
tag_2: Tag,
site: Site,
}
@ -692,11 +692,8 @@ mod tests {
pool,
&TagInsertForm {
ap_id: Url::parse(&format!("{}/tags/test_tag1", community.ap_id))?.into(),
name: "Test Tag 1".into(),
display_name: "Test Tag 1".into(),
community_id: community.id,
published: None,
updated: None,
deleted: false,
},
)
.await?;
@ -704,11 +701,8 @@ mod tests {
pool,
&TagInsertForm {
ap_id: Url::parse(&format!("{}/tags/test_tag2", community.ap_id))?.into(),
name: "Test Tag 2".into(),
display_name: "Test Tag 2".into(),
community_id: community.id,
published: None,
updated: None,
deleted: false,
},
)
.await?;
@ -796,8 +790,8 @@ mod tests {
post,
bot_post,
post_with_tags,
_tag_1: tag_1,
_tag_2: tag_2,
tag_1,
tag_2,
site,
})
}
@ -2215,32 +2209,31 @@ mod tests {
Ok(())
}
#[test_context(Data)]
#[tokio::test]
#[serial]
async fn post_tags_present(data: &mut Data) -> LemmyResult<()> {
let pool = &data.pool();
let pool = &mut pool.into();
// TODO add these back in later
// #[test_context(Data)]
// #[tokio::test]
// #[serial]
// async fn post_tags_present(data: &mut Data) -> LemmyResult<()> {
// let pool = &data.pool();
// let pool = &mut pool.into();
let post_view = PostView::read(
pool,
data.post_with_tags.id,
Some(&data.tegan_local_user_view.local_user),
data.instance.id,
false,
)
.await?;
// let post_view = PostView::read(
// pool,
// data.post_with_tags.id,
// Some(&data.tegan_local_user_view.local_user),
// false,
// )
// .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!(2, post_view.tags.tags.len());
// assert_eq!(data.tag_1.name, post_view.tags.tags[0].name);
// assert_eq!(data.tag_2.name, post_view.tags.tags[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
assert_eq!(0, all_posts[1].tags.0.len()); // bot post
assert_eq!(0, all_posts[2].tags.0.len()); // normal post
// let all_posts = data.default_post_query().list(&data.site, pool).await?;
// assert_eq!(2, all_posts[0].tags.tags.len()); // post with tags
// assert_eq!(0, all_posts[1].tags.tags.len()); // bot post
// assert_eq!(0, all_posts[2].tags.tags.len()); // normal post
// Ok(())
// }
Ok(())
}
}

View file

@ -2,6 +2,7 @@
use crate::utils::{
comment_creator_is_admin,
comment_select_remove_deletes,
community_post_tags_fragment,
creator_banned,
creator_community_actions_select,
creator_home_instance_actions_select,
@ -13,6 +14,7 @@ use crate::utils::{
person1_select,
person2_select,
post_creator_is_admin,
post_tags_fragment,
};
#[cfg(feature = "full")]
use diesel::{
@ -208,6 +210,12 @@ pub struct CommentView {
)
)]
pub creator_is_admin: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = post_tags_fragment()
)
)]
pub post_tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = local_user_can_mod()
@ -405,6 +413,12 @@ pub struct PostView {
)
)]
pub creator_is_admin: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = post_tags_fragment()
)
)]
pub tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = local_user_can_mod()
@ -645,6 +659,12 @@ pub(crate) struct PersonContentCombinedViewInternal {
)
)]
pub item_creator_is_admin: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = post_tags_fragment()
)
)]
pub post_tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = local_user_can_mod()
@ -717,6 +737,12 @@ pub(crate) struct PersonSavedCombinedViewInternal {
)
)]
pub item_creator_is_admin: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = post_tags_fragment()
)
)]
pub post_tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = local_user_can_mod()
@ -796,6 +822,12 @@ pub struct CommunityView {
)
)]
pub can_mod: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = community_post_tags_fragment()
)
)]
pub post_tags: TagsView,
}
/// The community sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html
@ -879,6 +911,7 @@ pub struct PersonPostMentionView {
pub creator_local_instance_actions: Option<InstanceActions>,
#[cfg_attr(feature = "full", ts(optional))]
pub creator_community_actions: Option<CommunityActions>,
pub post_tags: TagsView,
pub creator_is_admin: bool,
pub can_mod: bool,
pub creator_banned: bool,
@ -913,6 +946,7 @@ pub struct CommentReplyView {
#[cfg_attr(feature = "full", ts(optional))]
pub creator_community_actions: Option<CommunityActions>,
pub creator_is_admin: bool,
pub post_tags: TagsView,
pub can_mod: bool,
pub creator_banned: bool,
}
@ -1044,6 +1078,12 @@ pub struct InboxCombinedViewInternal {
)
)]
pub item_creator_is_admin: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = post_tags_fragment()
)
)]
pub post_tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = local_user_can_mod()
@ -1435,6 +1475,20 @@ pub(crate) struct SearchCombinedViewInternal {
)
)]
pub item_creator_is_admin: bool,
#[cfg_attr(feature = "full",
diesel(
select_expression = post_tags_fragment()
)
)]
/// tags of this post
pub post_tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = community_post_tags_fragment()
)
)]
/// available tags in this community
pub community_post_tags: TagsView,
#[cfg_attr(feature = "full",
diesel(
select_expression = local_user_can_mod()
@ -1462,10 +1516,8 @@ pub enum SearchCombinedView {
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)]
#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))]
#[serde(transparent)]
#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))]
#[cfg_attr(feature = "full", diesel(sql_type = Nullable<sql_types::Json>))]
/// we wrap this in a struct so we can implement FromSqlRow<Json> for it
pub struct PostTags {
pub tags: Vec<Tag>,
}
pub struct TagsView(pub Vec<Tag>);

View file

@ -1,6 +1,8 @@
use diesel::{
dsl::{case_when, exists, not, Nullable},
expression::SqlLiteral,
helper_types::{Eq, NotEq},
sql_types::Json,
BoolExpressionMethods,
ExpressionMethods,
JoinOnDsl,
@ -38,6 +40,8 @@ use lemmy_db_schema_file::{
person_actions,
post,
post_actions,
post_tag,
tag,
},
};
@ -179,6 +183,29 @@ pub(crate) fn comment_select_remove_deletes() -> _ {
)
}
#[diesel::dsl::auto_type]
// Gets the post tags set on a specific post
pub(crate) fn post_tags_fragment() -> _ {
let sel: SqlLiteral<Json> = diesel::dsl::sql::<diesel::sql_types::Json>("json_agg(tag.*)");
post_tag::table
.inner_join(tag::table)
.select(sel)
.filter(post_tag::post_id.eq(post::id))
.filter(tag::deleted.eq(false))
.single_value()
}
#[diesel::dsl::auto_type]
/// Gets the post tags available within a specific community
pub(crate) fn community_post_tags_fragment() -> _ {
let sel: SqlLiteral<Json> = diesel::dsl::sql::<diesel::sql_types::Json>("json_agg(tag.*)");
tag::table
.select(sel)
.filter(tag::community_id.eq(community::id))
.filter(tag::deleted.eq(false))
.single_value()
}
/// The select for the person1 alias.
pub(crate) fn person1_select() -> Person1AliasAllColumnsTuple {
person1.fields(person::all_columns)

View file

@ -463,7 +463,7 @@ async fn publish_scheduled_posts(context: &Data<LemmyContext>) -> LemmyResult<()
// send out post via federation and webmention
let send_activity = SendActivityData::CreatePost(post.clone());
ActivityChannel::submit_activity(send_activity, context)?;
send_webmention(post, community);
send_webmention(post, &community);
}
Ok(())
}

View file

@ -140,6 +140,8 @@ pub enum LemmyErrorType {
BanExpirationInPast,
InvalidUnixTime,
InvalidBotAction,
InvalidTagName,
TagNotInCommunity,
CantBlockLocalInstance,
Unknown(String),
UrlLengthOverflow,

View file

@ -26,6 +26,8 @@ const ALT_TEXT_MAX_LENGTH: usize = 1500;
const SITE_NAME_MAX_LENGTH: usize = 20;
const SITE_NAME_MIN_LENGTH: usize = 1;
const SITE_DESCRIPTION_MAX_LENGTH: usize = 150;
const TAG_NAME_MIN_LENGTH: usize = 3;
const TAG_NAME_MAX_LENGTH: usize = 100;
//Invisible unicode characters, taken from https://invisible-characters.com/
const FORBIDDEN_DISPLAY_CHARS: [char; 53] = [
'\u{0009}',
@ -197,6 +199,19 @@ 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.
///

View file

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

View file

@ -17,6 +17,7 @@ use lemmy_api::{
list::get_pending_follows_list,
},
random::get_random_community,
tag::{create_community_tag, delete_community_tag, update_community_tag},
transfer::transfer_community,
},
local_user::{
@ -228,6 +229,9 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
.route("/icon", delete().to(delete_community_icon))
.route("/banner", post().to(upload_community_banner))
.route("/banner", delete().to(delete_community_banner))
.route("/tag", post().to(create_community_tag))
.route("/tag", put().to(update_community_tag))
.route("/tag", delete().to(delete_community_tag))
.service(
scope("/pending_follows")
.route("/count", get().to(get_pending_follows_count))