From 74701af627096cd4e999fc21037acd3247d3fd81 Mon Sep 17 00:00:00 2001 From: phiresky Date: Fri, 4 Apr 2025 20:26:25 +0200 Subject: [PATCH] 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 62f17de60d1ccb7864d62e0f9f23a6a91d92500d. * 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 --- .woodpecker.yml | 3 +- api_tests/package.json | 9 +- api_tests/pnpm-lock.yaml | 78 +++------ api_tests/src/tags.spec.ts | 149 ++++++++++++++++++ crates/api/Cargo.toml | 2 +- crates/api/src/community/mod.rs | 1 + crates/api/src/community/tag.rs | 87 ++++++++++ crates/api_common/src/community.rs | 31 +++- crates/api_common/src/lib.rs | 2 + crates/api_common/src/post.rs | 4 +- crates/api_common/src/tags.rs | 43 +++++ crates/api_common/src/utils.rs | 2 +- crates/api_crud/src/post/create.rs | 33 +++- crates/api_crud/src/post/update.rs | 19 ++- crates/db_schema/src/impls/community.rs | 16 ++ crates/db_schema/src/impls/mod.rs | 1 + crates/db_schema/src/impls/post_tag.rs | 71 +++++++++ crates/db_schema/src/impls/tag.rs | 21 ++- crates/db_schema/src/source/mod.rs | 1 + crates/db_schema/src/source/post_tag.rs | 32 ++++ crates/db_schema/src/source/tag.rs | 40 +++-- crates/db_schema_file/src/schema.rs | 2 +- .../src/combined/inbox_combined_view.rs | 2 + .../combined/person_content_combined_view.rs | 2 + .../combined/person_saved_combined_view.rs | 2 + .../src/combined/search_combined_view.rs | 3 + crates/db_views/src/post/post_tags_view.rs | 10 +- crates/db_views/src/post/post_view.rs | 65 ++++---- crates/db_views/src/structs.rs | 60 ++++++- crates/db_views/src/utils.rs | 27 ++++ crates/routes/src/utils/scheduled_tasks.rs | 2 +- crates/utils/src/error.rs | 2 + crates/utils/src/utils/validation.rs | 15 ++ .../up.sql | 2 +- src/api_routes_v4.rs | 4 + 35 files changed, 698 insertions(+), 145 deletions(-) create mode 100644 api_tests/src/tags.spec.ts create mode 100644 crates/api/src/community/tag.rs create mode 100644 crates/api_common/src/tags.rs create mode 100644 crates/db_schema/src/impls/post_tag.rs create mode 100644 crates/db_schema/src/source/post_tag.rs diff --git a/.woodpecker.yml b/.woodpecker.yml index cd1b0ca0a..9a04aa91c 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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 diff --git a/api_tests/package.json b/api_tests/package.json index bbae4cc38..0dd88915e 100644 --- a/api_tests/package.json +++ b/api_tests/package.json @@ -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", diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml index d1c1a0e24..59c9665b7 100644 --- a/api_tests/pnpm-lock.yaml +++ b/api_tests/pnpm-lock.yaml @@ -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: diff --git a/api_tests/src/tags.spec.ts b/api_tests/src/tags.spec.ts new file mode 100644 index 000000000..be3920912 --- /dev/null +++ b/api_tests/src/tags.spec.ts @@ -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); +}); diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 28cf9c4b8..252a7e7cc 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -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 } diff --git a/crates/api/src/community/mod.rs b/crates/api/src/community/mod.rs index f6a248179..e4844a410 100644 --- a/crates/api/src/community/mod.rs +++ b/crates/api/src/community/mod.rs @@ -4,4 +4,5 @@ pub mod block; pub mod follow; pub mod pending_follows; pub mod random; +pub mod tag; pub mod transfer; diff --git a/crates/api/src/community/tag.rs b/crates/api/src/community/tag.rs new file mode 100644 index 000000000..1b3054704 --- /dev/null +++ b/crates/api/src/community/tag.rs @@ -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, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + 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, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + 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, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + 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)) +} diff --git a/crates/api_common/src/community.rs b/crates/api_common/src/community.rs index 5e8a11976..a98bc39f6 100644 --- a/crates/api_common/src/community.rs +++ b/crates/api_common/src/community.rs @@ -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))] diff --git a/crates/api_common/src/lib.rs b/crates/api_common/src/lib.rs index 9d0d04987..7c96c3aed 100644 --- a/crates/api_common/src/lib.rs +++ b/crates/api_common/src/lib.rs @@ -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; diff --git a/crates/api_common/src/post.rs b/crates/api_common/src/post.rs index bcc949b85..1c1ca56bb 100644 --- a/crates/api_common/src/post.rs +++ b/crates/api_common/src/post.rs @@ -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, - #[cfg_attr(feature = "full", ts(optional))] - pub tags: Option>, /// Time when this post should be scheduled. Null means publish immediately. #[cfg_attr(feature = "full", ts(optional))] pub scheduled_publish_time: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub tags: Option>, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] diff --git a/crates/api_common/src/tags.rs b/crates/api_common/src/tags.rs new file mode 100644 index 000000000..aa5d6b933 --- /dev/null +++ b/crates/api_common/src/tags.rs @@ -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 = 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(()) +} diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index b31cc2875..7bfd7e630 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -946,7 +946,7 @@ pub fn read_auth_token(req: &HttpRequest) -> LemmyResult> { } } -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 { diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index 309f62a7b..176631e44 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -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); diff --git a/crates/api_crud/src/post/update.rs b/crates/api_crud/src/post/update.rs index 519812fc8..ceec2ab22 100644 --- a/crates/api_crud/src/post/update.rs +++ b/crates/api_crud/src/post/update.rs @@ -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), diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index 202f2cebc..bf05c8263 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -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 { + #[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 = + 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 { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/c/{name}"))?.into()) diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index e44730995..2ecaa9680 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -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; diff --git a/crates/db_schema/src/impls/post_tag.rs b/crates/db_schema/src/impls/post_tag.rs new file mode 100644 index 000000000..e52461fc1 --- /dev/null +++ b/crates/db_schema/src/impls/post_tag.rs @@ -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, + ) -> Result, 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 { + 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, + ) -> Result, 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 { + let conn = &mut get_conn(pool).await?; + insert_into(post_tag::table) + .values(form) + .get_result::(conn) + .await + } + + async fn update( + _pool: &mut DbPool<'_>, + _id: Self::IdType, + _form: &Self::UpdateForm, + ) -> Result { + Err(diesel::result::Error::QueryBuilderError( + "PostTag does not support (create+delete only)".into(), + )) + } +} diff --git a/crates/db_schema/src/impls/tag.rs b/crates/db_schema/src/impls/tag.rs index fa734955e..ff1037d03 100644 --- a/crates/db_schema/src/impls/tag.rs +++ b/crates/db_schema/src/impls/tag.rs @@ -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, Error> { + let conn = &mut get_conn(pool).await?; + tag::table + .filter(tag::community_id.eq(search_community_id)) + .filter(tag::deleted.eq(false)) + .load::(conn) + .await + } +} impl Crud for Tag { type InsertForm = TagInsertForm; - type UpdateForm = TagInsertForm; + type UpdateForm = TagUpdateForm; type IdType = TagId; diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index 8f04e5eac..4118e1120 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -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; diff --git a/crates/db_schema/src/source/post_tag.rs b/crates/db_schema/src/source/post_tag.rs new file mode 100644 index 000000000..bdf392a89 --- /dev/null +++ b/crates/db_schema/src/source/post_tag.rs @@ -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, +} + +#[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, +} diff --git a/crates/db_schema/src/source/tag.rs b/crates/db_schema/src/source/tag.rs index 0986167bc..084fba116 100644 --- a/crates/db_schema/src/source/tag.rs +++ b/crates/db_schema/src/source/tag.rs @@ -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, @@ -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, + pub display_name: Option, + pub community_id: Option, pub published: Option>, - pub updated: Option>, - pub deleted: bool, + pub updated: Option>>, + pub deleted: Option, } #[derive(Debug, Clone)] diff --git a/crates/db_schema_file/src/schema.rs b/crates/db_schema_file/src/schema.rs index 53d42e079..5cb66fbd7 100644 --- a/crates/db_schema_file/src/schema.rs +++ b/crates/db_schema_file/src/schema.rs @@ -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, diff --git a/crates/db_views/src/combined/inbox_combined_view.rs b/crates/db_views/src/combined/inbox_combined_view.rs index 7c4edf25f..a86caa619 100644 --- a/crates/db_views/src/combined/inbox_combined_view.rs +++ b/crates/db_views/src/combined/inbox_combined_view.rs @@ -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, })) diff --git a/crates/db_views/src/combined/person_content_combined_view.rs b/crates/db_views/src/combined/person_content_combined_view.rs index 93dd1162c..431297e3a 100644 --- a/crates/db_views/src/combined/person_content_combined_view.rs +++ b/crates/db_views/src/combined/person_content_combined_view.rs @@ -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, })) diff --git a/crates/db_views/src/combined/person_saved_combined_view.rs b/crates/db_views/src/combined/person_saved_combined_view.rs index 4be1c9d2f..0a1977556 100644 --- a/crates/db_views/src/combined/person_saved_combined_view.rs +++ b/crates/db_views/src/combined/person_saved_combined_view.rs @@ -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, })) diff --git a/crates/db_views/src/combined/search_combined_view.rs b/crates/db_views/src/combined/search_combined_view.rs index a049f8a87..e5e0d02fe 100644 --- a/crates/db_views/src/combined/search_combined_view.rs +++ b/crates/db_views/src/combined/search_combined_view.rs @@ -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 { diff --git a/crates/db_views/src/post/post_tags_view.rs b/crates/db_views/src/post/post_tags_view.rs index 5d1492567..3a1e8d626 100644 --- a/crates/db_views/src/post/post_tags_view.rs +++ b/crates/db_views/src/post/post_tags_view.rs @@ -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, Pg> for PostTags { +impl FromSql, Pg> for TagsView { fn from_sql(bytes: PgValue) -> diesel::deserialize::Result { let value = >::from_sql(bytes)?; - Ok(serde_json::from_value::(value)?) + Ok(serde_json::from_value::(value)?) } fn from_nullable_sql( bytes: Option<::RawValue<'_>>, ) -> diesel::deserialize::Result { match bytes { Some(bytes) => Self::from_sql(bytes), - None => Ok(Self { tags: vec![] }), + None => Ok(Self(vec![])), } } } -impl ToSql, Pg> for PostTags { +impl ToSql, Pg> for TagsView { fn to_sql(&self, out: &mut diesel::serialize::Output) -> diesel::serialize::Result { let value = serde_json::to_value(self)?; >::to_sql(&value, &mut out.reborrow()) diff --git a/crates/db_views/src/post/post_view.rs b/crates/db_views/src/post/post_view.rs index b7fb5a59b..a289b4ffd 100644 --- a/crates/db_views/src/post/post_view.rs +++ b/crates/db_views/src/post/post_view.rs @@ -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(()) + } } diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index 9e8091fec..5acdba559 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -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, #[cfg_attr(feature = "full", ts(optional))] pub creator_community_actions: Option, + 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, 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))] /// we wrap this in a struct so we can implement FromSqlRow for it -pub struct PostTags { - pub tags: Vec, -} +pub struct TagsView(pub Vec); diff --git a/crates/db_views/src/utils.rs b/crates/db_views/src/utils.rs index febfaf46f..fd95bea54 100644 --- a/crates/db_views/src/utils.rs +++ b/crates/db_views/src/utils.rs @@ -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 = diesel::dsl::sql::("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 = diesel::dsl::sql::("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) diff --git a/crates/routes/src/utils/scheduled_tasks.rs b/crates/routes/src/utils/scheduled_tasks.rs index 0201a7c2a..6931260aa 100644 --- a/crates/routes/src/utils/scheduled_tasks.rs +++ b/crates/routes/src/utils/scheduled_tasks.rs @@ -463,7 +463,7 @@ async fn publish_scheduled_posts(context: &Data) -> 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(()) } diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index 69a19cf38..b645cbe52 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -140,6 +140,8 @@ pub enum LemmyErrorType { BanExpirationInPast, InvalidUnixTime, InvalidBotAction, + InvalidTagName, + TagNotInCommunity, CantBlockLocalInstance, Unknown(String), UrlLengthOverflow, diff --git a/crates/utils/src/utils/validation.rs b/crates/utils/src/utils/validation.rs index c69671a33..53634b9af 100644 --- a/crates/utils/src/utils/validation.rs +++ b/crates/utils/src/utils/validation.rs @@ -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. /// diff --git a/migrations/2024-12-17-144959_community-post-tags/up.sql b/migrations/2024-12-17-144959_community-post-tags/up.sql index f0c596e09..ace286bfb 100644 --- a/migrations/2024-12-17-144959_community-post-tags/up.sql +++ b/migrations/2024-12-17-144959_community-post-tags/up.sql @@ -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, diff --git a/src/api_routes_v4.rs b/src/api_routes_v4.rs index f18f458fc..785e15a4e 100644 --- a/src/api_routes_v4.rs +++ b/src/api_routes_v4.rs @@ -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))