Repository: LemmyNet/lemmy Branch: main Commit: 51254f3c169b Files: 1431 Total size: 3.9 MB Directory structure: gitextract__t6y25uc/ ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── BUG_REPORT.yml │ │ ├── FEATURE_REQUEST.yml │ │ └── QUESTION.yml │ └── SECURITY.md ├── .gitignore ├── .gitmodules ├── .rustfmt.toml ├── .woodpecker.yml ├── Cargo.toml ├── LICENSE ├── README.md ├── api_tests/ │ ├── .npmrc │ ├── .prettierrc.json │ ├── eslint.config.mjs │ ├── jest.config.js │ ├── package.json │ ├── pnpm-workspace.yaml │ ├── prepare-drone-federation-test.sh │ ├── run-federation-test.sh │ ├── src/ │ │ ├── apiv3.spec.ts │ │ ├── comment.spec.ts │ │ ├── community.spec.ts │ │ ├── follow.spec.ts │ │ ├── image.spec.ts │ │ ├── post.spec.ts │ │ ├── private_comm.spec.ts │ │ ├── private_message.spec.ts │ │ ├── shared.ts │ │ ├── speed.spec.ts │ │ ├── tags.spec.ts │ │ └── user.spec.ts │ └── tsconfig.json ├── cliff.toml ├── config/ │ ├── config.hjson │ └── defaults.hjson ├── crates/ │ ├── api/ │ │ ├── api/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── comment/ │ │ │ │ ├── distinguish.rs │ │ │ │ ├── like.rs │ │ │ │ ├── list_comment_likes.rs │ │ │ │ ├── lock.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── save.rs │ │ │ │ └── warning.rs │ │ │ ├── community/ │ │ │ │ ├── add_mod.rs │ │ │ │ ├── ban.rs │ │ │ │ ├── block.rs │ │ │ │ ├── follow.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── multi_community_follow.rs │ │ │ │ ├── pending_follows/ │ │ │ │ │ ├── approve.rs │ │ │ │ │ ├── list.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── random.rs │ │ │ │ ├── tag.rs │ │ │ │ ├── transfer.rs │ │ │ │ └── update_notifications.rs │ │ │ ├── federation/ │ │ │ │ ├── fetcher.rs │ │ │ │ ├── list_comments.rs │ │ │ │ ├── list_person_content.rs │ │ │ │ ├── list_posts.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── read_community.rs │ │ │ │ ├── read_multi_community.rs │ │ │ │ ├── read_person.rs │ │ │ │ ├── resolve_object.rs │ │ │ │ ├── search.rs │ │ │ │ └── user_settings_backup.rs │ │ │ ├── lib.rs │ │ │ ├── local_user/ │ │ │ │ ├── add_admin.rs │ │ │ │ ├── ban_person.rs │ │ │ │ ├── block.rs │ │ │ │ ├── change_password.rs │ │ │ │ ├── change_password_after_reset.rs │ │ │ │ ├── donation_dialog_shown.rs │ │ │ │ ├── export_data.rs │ │ │ │ ├── generate_totp_secret.rs │ │ │ │ ├── get_captcha.rs │ │ │ │ ├── list_hidden.rs │ │ │ │ ├── list_liked.rs │ │ │ │ ├── list_logins.rs │ │ │ │ ├── list_media.rs │ │ │ │ ├── list_read.rs │ │ │ │ ├── list_saved.rs │ │ │ │ ├── login.rs │ │ │ │ ├── logout.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── note_person.rs │ │ │ │ ├── notifications/ │ │ │ │ │ ├── list.rs │ │ │ │ │ ├── mark_all_read.rs │ │ │ │ │ ├── mark_notification_read.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── resend_verification_email.rs │ │ │ │ ├── reset_password.rs │ │ │ │ ├── save_settings.rs │ │ │ │ ├── unread_counts.rs │ │ │ │ ├── update_totp.rs │ │ │ │ ├── user_block_instance.rs │ │ │ │ ├── validate_auth.rs │ │ │ │ └── verify_email.rs │ │ │ ├── post/ │ │ │ │ ├── feature.rs │ │ │ │ ├── get_link_metadata.rs │ │ │ │ ├── hide.rs │ │ │ │ ├── like.rs │ │ │ │ ├── list_post_likes.rs │ │ │ │ ├── lock.rs │ │ │ │ ├── mark_many_read.rs │ │ │ │ ├── mark_read.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── mod_update.rs │ │ │ │ ├── save.rs │ │ │ │ ├── update_notifications.rs │ │ │ │ └── warning.rs │ │ │ ├── reports/ │ │ │ │ ├── comment_report/ │ │ │ │ │ ├── create.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── resolve.rs │ │ │ │ ├── community_report/ │ │ │ │ │ ├── create.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── resolve.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── post_report/ │ │ │ │ │ ├── create.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── resolve.rs │ │ │ │ ├── private_message_report/ │ │ │ │ │ ├── create.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── resolve.rs │ │ │ │ └── report_combined/ │ │ │ │ ├── list.rs │ │ │ │ └── mod.rs │ │ │ ├── site/ │ │ │ │ ├── admin_allow_instance.rs │ │ │ │ ├── admin_block_instance.rs │ │ │ │ ├── admin_list_users.rs │ │ │ │ ├── federated_instances.rs │ │ │ │ ├── list_all_media.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── mod_log.rs │ │ │ │ ├── purge/ │ │ │ │ │ ├── comment.rs │ │ │ │ │ ├── community.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── person.rs │ │ │ │ │ └── post.rs │ │ │ │ └── registration_applications/ │ │ │ │ ├── approve.rs │ │ │ │ ├── get.rs │ │ │ │ ├── list.rs │ │ │ │ ├── mod.rs │ │ │ │ └── tests.rs │ │ │ └── sitemap.rs │ │ ├── api_common/ │ │ │ ├── Cargo.toml │ │ │ ├── README.md │ │ │ └── src/ │ │ │ ├── account.rs │ │ │ ├── comment.rs │ │ │ ├── community.rs │ │ │ ├── custom_emoji.rs │ │ │ ├── error.rs │ │ │ ├── federation.rs │ │ │ ├── language.rs │ │ │ ├── lib.rs │ │ │ ├── media.rs │ │ │ ├── modlog.rs │ │ │ ├── notification.rs │ │ │ ├── oauth.rs │ │ │ ├── person.rs │ │ │ ├── plugin.rs │ │ │ ├── post.rs │ │ │ ├── private_message.rs │ │ │ ├── report.rs │ │ │ ├── search.rs │ │ │ ├── site.rs │ │ │ └── tagline.rs │ │ ├── api_crud/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── comment/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── read.rs │ │ │ │ ├── remove.rs │ │ │ │ └── update.rs │ │ │ ├── community/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── list.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── remove.rs │ │ │ │ └── update.rs │ │ │ ├── custom_emoji/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── list.rs │ │ │ │ ├── mod.rs │ │ │ │ └── update.rs │ │ │ ├── lib.rs │ │ │ ├── multi_community/ │ │ │ │ ├── create.rs │ │ │ │ ├── create_entry.rs │ │ │ │ ├── delete_entry.rs │ │ │ │ ├── list.rs │ │ │ │ ├── mod.rs │ │ │ │ └── update.rs │ │ │ ├── oauth_provider/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── mod.rs │ │ │ │ └── update.rs │ │ │ ├── post/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── read.rs │ │ │ │ ├── remove.rs │ │ │ │ └── update.rs │ │ │ ├── private_message/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── mod.rs │ │ │ │ └── update.rs │ │ │ ├── site/ │ │ │ │ ├── create.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── read.rs │ │ │ │ └── update.rs │ │ │ ├── tagline/ │ │ │ │ ├── create.rs │ │ │ │ ├── delete.rs │ │ │ │ ├── list.rs │ │ │ │ ├── mod.rs │ │ │ │ └── update.rs │ │ │ └── user/ │ │ │ ├── create.rs │ │ │ ├── delete.rs │ │ │ ├── mod.rs │ │ │ └── my_user.rs │ │ ├── api_utils/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── build_response.rs │ │ │ ├── claims.rs │ │ │ ├── context.rs │ │ │ ├── lib.rs │ │ │ ├── notify.rs │ │ │ ├── plugins.rs │ │ │ ├── request.rs │ │ │ ├── send_activity.rs │ │ │ └── utils.rs │ │ ├── routes/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ └── lib.rs │ │ └── routes_v3/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── convert.rs │ │ ├── handlers.rs │ │ └── lib.rs │ ├── apub/ │ │ ├── activities/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── activity_lists.rs │ │ │ ├── block/ │ │ │ │ ├── block_user.rs │ │ │ │ ├── mod.rs │ │ │ │ └── undo_block_user.rs │ │ │ ├── community/ │ │ │ │ ├── announce.rs │ │ │ │ ├── collection_add.rs │ │ │ │ ├── collection_remove.rs │ │ │ │ ├── lock.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── report.rs │ │ │ │ ├── resolve_report.rs │ │ │ │ └── update.rs │ │ │ ├── create_or_update/ │ │ │ │ ├── comment.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── note_wrapper.rs │ │ │ │ ├── post.rs │ │ │ │ └── private_message.rs │ │ │ ├── deletion/ │ │ │ │ ├── delete.rs │ │ │ │ ├── mod.rs │ │ │ │ └── undo_delete.rs │ │ │ ├── following/ │ │ │ │ ├── accept.rs │ │ │ │ ├── follow.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── reject.rs │ │ │ │ └── undo_follow.rs │ │ │ ├── lib.rs │ │ │ ├── protocol/ │ │ │ │ ├── block/ │ │ │ │ │ ├── block_user.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── undo_block_user.rs │ │ │ │ ├── community/ │ │ │ │ │ ├── announce.rs │ │ │ │ │ ├── collection_add.rs │ │ │ │ │ ├── collection_remove.rs │ │ │ │ │ ├── lock.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── report.rs │ │ │ │ │ ├── resolve_report.rs │ │ │ │ │ └── update.rs │ │ │ │ ├── create_or_update/ │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── note.rs │ │ │ │ │ ├── note_wrapper.rs │ │ │ │ │ ├── page.rs │ │ │ │ │ └── private_message.rs │ │ │ │ ├── deletion/ │ │ │ │ │ ├── delete.rs │ │ │ │ │ ├── delete_user.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── undo_delete.rs │ │ │ │ ├── following/ │ │ │ │ │ ├── accept.rs │ │ │ │ │ ├── follow.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── reject.rs │ │ │ │ │ └── undo_follow.rs │ │ │ │ ├── mod.rs │ │ │ │ └── voting/ │ │ │ │ ├── mod.rs │ │ │ │ ├── undo_vote.rs │ │ │ │ └── vote.rs │ │ │ └── voting/ │ │ │ ├── mod.rs │ │ │ ├── undo_vote.rs │ │ │ └── vote.rs │ │ ├── apub/ │ │ │ ├── Cargo.toml │ │ │ ├── assets/ │ │ │ │ ├── discourse/ │ │ │ │ │ └── objects/ │ │ │ │ │ ├── group.json │ │ │ │ │ ├── page.json │ │ │ │ │ └── person.json │ │ │ │ ├── friendica/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── create_note.json │ │ │ │ │ │ ├── create_page_1.json │ │ │ │ │ │ ├── create_page_2.json │ │ │ │ │ │ ├── delete.json │ │ │ │ │ │ ├── dislike_page.json │ │ │ │ │ │ ├── like_page.json │ │ │ │ │ │ ├── undo_dislike_page.json │ │ │ │ │ │ └── update_note.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── note_1.json │ │ │ │ │ ├── note_2.json │ │ │ │ │ ├── page_1.json │ │ │ │ │ ├── page_2.json │ │ │ │ │ ├── person_1.json │ │ │ │ │ └── person_2.json │ │ │ │ ├── gnusocial/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── create_note.json │ │ │ │ │ │ ├── create_page.json │ │ │ │ │ │ └── like_note.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── group.json │ │ │ │ │ ├── note.json │ │ │ │ │ ├── page.json │ │ │ │ │ └── person.json │ │ │ │ ├── lemmy/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── block/ │ │ │ │ │ │ │ ├── block_user.json │ │ │ │ │ │ │ └── undo_block_user.json │ │ │ │ │ │ ├── community/ │ │ │ │ │ │ │ ├── add_featured_post.json │ │ │ │ │ │ │ ├── add_mod.json │ │ │ │ │ │ │ ├── announce_create_page.json │ │ │ │ │ │ │ ├── lock_note.json │ │ │ │ │ │ │ ├── lock_page.json │ │ │ │ │ │ │ ├── remove_featured_post.json │ │ │ │ │ │ │ ├── remove_mod.json │ │ │ │ │ │ │ ├── report_page.json │ │ │ │ │ │ │ ├── resolve_report_page.json │ │ │ │ │ │ │ ├── undo_lock_note.json │ │ │ │ │ │ │ ├── undo_lock_page.json │ │ │ │ │ │ │ └── update_community.json │ │ │ │ │ │ ├── create_or_update/ │ │ │ │ │ │ │ ├── create_comment.json │ │ │ │ │ │ │ ├── create_page.json │ │ │ │ │ │ │ ├── create_private_message.json │ │ │ │ │ │ │ └── update_page.json │ │ │ │ │ │ ├── deletion/ │ │ │ │ │ │ │ ├── delete_page.json │ │ │ │ │ │ │ ├── delete_private_message.json │ │ │ │ │ │ │ ├── delete_user.json │ │ │ │ │ │ │ ├── remove_note.json │ │ │ │ │ │ │ ├── undo_delete_page.json │ │ │ │ │ │ │ ├── undo_delete_private_message.json │ │ │ │ │ │ │ └── undo_remove_note.json │ │ │ │ │ │ ├── following/ │ │ │ │ │ │ │ ├── accept.json │ │ │ │ │ │ │ ├── follow.json │ │ │ │ │ │ │ └── undo_follow.json │ │ │ │ │ │ └── voting/ │ │ │ │ │ │ ├── dislike_page.json │ │ │ │ │ │ ├── like_note.json │ │ │ │ │ │ ├── undo_dislike_page.json │ │ │ │ │ │ └── undo_like_note.json │ │ │ │ │ ├── collections/ │ │ │ │ │ │ ├── group_featured_posts.json │ │ │ │ │ │ ├── group_followers.json │ │ │ │ │ │ ├── group_moderators.json │ │ │ │ │ │ ├── group_outbox.json │ │ │ │ │ │ └── person_outbox.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── comment.json │ │ │ │ │ ├── group.json │ │ │ │ │ ├── instance.json │ │ │ │ │ ├── page.json │ │ │ │ │ ├── person.json │ │ │ │ │ ├── private_message.json │ │ │ │ │ └── tombstone.json │ │ │ │ ├── lotide/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── create_note_reply.json │ │ │ │ │ │ ├── create_page.json │ │ │ │ │ │ ├── create_page_image.json │ │ │ │ │ │ ├── delete_note.json │ │ │ │ │ │ └── follow.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── group.json │ │ │ │ │ ├── note.json │ │ │ │ │ ├── page.json │ │ │ │ │ ├── person.json │ │ │ │ │ └── tombstone.json │ │ │ │ ├── mastodon/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── create_note.json │ │ │ │ │ │ ├── delete.json │ │ │ │ │ │ ├── flag.json │ │ │ │ │ │ ├── follow.json │ │ │ │ │ │ ├── like_page.json │ │ │ │ │ │ ├── private_message.json │ │ │ │ │ │ ├── undo_follow.json │ │ │ │ │ │ └── undo_like_page.json │ │ │ │ │ ├── collections/ │ │ │ │ │ │ └── featured.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── note_1.json │ │ │ │ │ ├── note_2.json │ │ │ │ │ ├── page.json │ │ │ │ │ └── person.json │ │ │ │ ├── mbin/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── accept.json │ │ │ │ │ │ └── flag.json │ │ │ │ │ └── objects/ │ │ │ │ │ └── instance.json │ │ │ │ ├── mobilizon/ │ │ │ │ │ └── objects/ │ │ │ │ │ ├── event.json │ │ │ │ │ ├── group.json │ │ │ │ │ └── person.json │ │ │ │ ├── nodebb/ │ │ │ │ │ └── objects/ │ │ │ │ │ ├── group.json │ │ │ │ │ ├── page.json │ │ │ │ │ └── person.json │ │ │ │ ├── peertube/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ └── announce_video.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── group.json │ │ │ │ │ ├── note.json │ │ │ │ │ ├── person.json │ │ │ │ │ └── video.json │ │ │ │ ├── pleroma/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ ├── create_note.json │ │ │ │ │ │ ├── delete.json │ │ │ │ │ │ └── follow.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── chat_message.json │ │ │ │ │ ├── note.json │ │ │ │ │ └── person.json │ │ │ │ ├── smithereen/ │ │ │ │ │ ├── activities/ │ │ │ │ │ │ └── create_note.json │ │ │ │ │ └── objects/ │ │ │ │ │ ├── note.json │ │ │ │ │ └── person.json │ │ │ │ └── wordpress/ │ │ │ │ ├── activities/ │ │ │ │ │ └── announce.json │ │ │ │ └── objects/ │ │ │ │ ├── group.json │ │ │ │ ├── note.json │ │ │ │ ├── page.json │ │ │ │ └── person.json │ │ │ └── src/ │ │ │ ├── collections/ │ │ │ │ ├── community_featured.rs │ │ │ │ ├── community_follower.rs │ │ │ │ ├── community_moderators.rs │ │ │ │ ├── community_outbox.rs │ │ │ │ └── mod.rs │ │ │ ├── http/ │ │ │ │ ├── comment.rs │ │ │ │ ├── community.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── person.rs │ │ │ │ ├── post.rs │ │ │ │ ├── routes.rs │ │ │ │ └── site.rs │ │ │ ├── lib.rs │ │ │ └── protocol/ │ │ │ ├── collections/ │ │ │ │ ├── group_featured.rs │ │ │ │ ├── group_followers.rs │ │ │ │ ├── group_moderators.rs │ │ │ │ ├── group_outbox.rs │ │ │ │ ├── mod.rs │ │ │ │ └── url_collection.rs │ │ │ └── mod.rs │ │ ├── objects/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── lib.rs │ │ │ ├── objects/ │ │ │ │ ├── comment.rs │ │ │ │ ├── community.rs │ │ │ │ ├── instance.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── multi_community.rs │ │ │ │ ├── multi_community_collection.rs │ │ │ │ ├── person.rs │ │ │ │ ├── post.rs │ │ │ │ └── private_message.rs │ │ │ ├── protocol/ │ │ │ │ ├── group.rs │ │ │ │ ├── instance.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── multi_community.rs │ │ │ │ ├── note.rs │ │ │ │ ├── page.rs │ │ │ │ ├── person.rs │ │ │ │ ├── private_message.rs │ │ │ │ └── tags.rs │ │ │ └── utils/ │ │ │ ├── functions.rs │ │ │ ├── markdown_links.rs │ │ │ ├── mentions.rs │ │ │ ├── mod.rs │ │ │ ├── protocol.rs │ │ │ └── test.rs │ │ └── send/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── inboxes.rs │ │ ├── lib.rs │ │ ├── send.rs │ │ ├── stats.rs │ │ ├── util.rs │ │ └── worker.rs │ ├── db_schema/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── impls/ │ │ │ ├── activity.rs │ │ │ ├── actor_language.rs │ │ │ ├── comment.rs │ │ │ ├── comment_report.rs │ │ │ ├── community.rs │ │ │ ├── community_community_follow.rs │ │ │ ├── community_report.rs │ │ │ ├── community_tag.rs │ │ │ ├── custom_emoji.rs │ │ │ ├── email_verification.rs │ │ │ ├── federation_allowlist.rs │ │ │ ├── federation_blocklist.rs │ │ │ ├── federation_queue_state.rs │ │ │ ├── images.rs │ │ │ ├── instance.rs │ │ │ ├── keyword_block.rs │ │ │ ├── language.rs │ │ │ ├── local_site.rs │ │ │ ├── local_site_rate_limit.rs │ │ │ ├── local_site_url_blocklist.rs │ │ │ ├── local_user.rs │ │ │ ├── login_token.rs │ │ │ ├── mod.rs │ │ │ ├── modlog.rs │ │ │ ├── multi_community.rs │ │ │ ├── notification.rs │ │ │ ├── oauth_account.rs │ │ │ ├── oauth_provider.rs │ │ │ ├── password_reset_request.rs │ │ │ ├── person.rs │ │ │ ├── post.rs │ │ │ ├── post_report.rs │ │ │ ├── private_message.rs │ │ │ ├── private_message_report.rs │ │ │ ├── registration_application.rs │ │ │ ├── secret.rs │ │ │ ├── site.rs │ │ │ └── tagline.rs │ │ ├── lib.rs │ │ ├── newtypes.rs │ │ ├── source/ │ │ │ ├── activity.rs │ │ │ ├── actor_language.rs │ │ │ ├── combined/ │ │ │ │ ├── mod.rs │ │ │ │ ├── person_content.rs │ │ │ │ ├── person_liked.rs │ │ │ │ ├── person_saved.rs │ │ │ │ ├── report.rs │ │ │ │ └── search.rs │ │ │ ├── comment.rs │ │ │ ├── comment_report.rs │ │ │ ├── community.rs │ │ │ ├── community_community_follow.rs │ │ │ ├── community_report.rs │ │ │ ├── community_tag.rs │ │ │ ├── custom_emoji.rs │ │ │ ├── custom_emoji_keyword.rs │ │ │ ├── email_verification.rs │ │ │ ├── federation_allowlist.rs │ │ │ ├── federation_blocklist.rs │ │ │ ├── federation_queue_state.rs │ │ │ ├── images.rs │ │ │ ├── instance.rs │ │ │ ├── keyword_block.rs │ │ │ ├── language.rs │ │ │ ├── local_site.rs │ │ │ ├── local_site_rate_limit.rs │ │ │ ├── local_site_url_blocklist.rs │ │ │ ├── local_user.rs │ │ │ ├── login_token.rs │ │ │ ├── mod.rs │ │ │ ├── modlog.rs │ │ │ ├── multi_community.rs │ │ │ ├── notification.rs │ │ │ ├── oauth_account.rs │ │ │ ├── oauth_provider.rs │ │ │ ├── password_reset_request.rs │ │ │ ├── person.rs │ │ │ ├── post.rs │ │ │ ├── post_report.rs │ │ │ ├── private_message.rs │ │ │ ├── private_message_report.rs │ │ │ ├── registration_application.rs │ │ │ ├── secret.rs │ │ │ ├── site.rs │ │ │ └── tagline.rs │ │ ├── test_data.rs │ │ ├── traits.rs │ │ └── utils/ │ │ ├── mod.rs │ │ └── queries/ │ │ ├── filters.rs │ │ ├── mod.rs │ │ └── selects.rs │ ├── db_schema_file/ │ │ ├── Cargo.toml │ │ ├── diesel_ltree.patch │ │ └── src/ │ │ ├── enums.rs │ │ ├── joins.rs │ │ ├── lib.rs │ │ ├── schema.rs │ │ └── table_impls.rs │ ├── db_views/ │ │ ├── comment/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── community/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── community_follower/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── community_follower_approval/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── community_moderator/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── custom_emoji/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── local_image/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── local_user/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── modlog/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── notification/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ ├── lib.rs │ │ │ └── tests.rs │ │ ├── notification_sql/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ └── lib.rs │ │ ├── person/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── person_content_combined/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── person_liked_combined/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── person_saved_combined/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── post/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── db_perf/ │ │ │ │ ├── mod.rs │ │ │ │ └── series.rs │ │ │ ├── impls.rs │ │ │ ├── lib.rs │ │ │ └── test.rs │ │ ├── post_comment_combined/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ └── lib.rs │ │ ├── private_message/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── registration_applications/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── report_combined/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── report_combined_sql/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ └── lib.rs │ │ ├── search_combined/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ ├── site/ │ │ │ ├── Cargo.toml │ │ │ └── src/ │ │ │ ├── api.rs │ │ │ ├── impls.rs │ │ │ └── lib.rs │ │ └── vote/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── impls.rs │ │ └── lib.rs │ ├── diesel_utils/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── replaceable_schema/ │ │ │ ├── triggers.sql │ │ │ └── utils.sql │ │ └── src/ │ │ ├── connection.rs │ │ ├── dburl.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── pagination.rs │ │ ├── schema_setup/ │ │ │ ├── diff_check.rs │ │ │ └── mod.rs │ │ ├── sensitive.rs │ │ ├── traits.rs │ │ └── utils.rs │ ├── email/ │ │ ├── Cargo.toml │ │ ├── build.rs │ │ └── src/ │ │ ├── account.rs │ │ ├── admin.rs │ │ ├── lib.rs │ │ ├── notifications.rs │ │ └── send.rs │ ├── routes/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── feeds/ │ │ │ ├── mod.rs │ │ │ └── negotiate_content.rs │ │ ├── images/ │ │ │ ├── delete.rs │ │ │ ├── download.rs │ │ │ ├── mod.rs │ │ │ ├── upload.rs │ │ │ └── utils.rs │ │ ├── lib.rs │ │ ├── middleware/ │ │ │ ├── idempotency.rs │ │ │ ├── mod.rs │ │ │ └── session.rs │ │ ├── nodeinfo.rs │ │ ├── utils/ │ │ │ ├── mod.rs │ │ │ ├── prometheus_metrics.rs │ │ │ ├── scheduled_tasks.rs │ │ │ └── setup_local_site.rs │ │ └── webfinger.rs │ ├── server/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ └── main.rs │ └── utils/ │ ├── Cargo.toml │ ├── src/ │ │ ├── cache_header.rs │ │ ├── error.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── rate_limit/ │ │ │ ├── backend.rs │ │ │ ├── input.rs │ │ │ └── mod.rs │ │ ├── response.rs │ │ ├── settings/ │ │ │ ├── mod.rs │ │ │ └── structs.rs │ │ └── utils/ │ │ ├── markdown/ │ │ │ ├── identifier_rule.rs │ │ │ ├── image_links.rs │ │ │ ├── link_rule.rs │ │ │ └── mod.rs │ │ ├── mention.rs │ │ ├── mod.rs │ │ ├── slurs.rs │ │ └── validation.rs │ └── tests/ │ └── test_errors_used.rs ├── diesel.toml ├── docker/ │ ├── Dockerfile │ ├── README.md │ ├── customPostgresql.conf │ ├── docker-compose.yml │ ├── docker_db_backup.sh │ ├── docker_update.sh │ ├── federation/ │ │ ├── docker-compose.yml │ │ ├── lemmy_alpha.hjson │ │ ├── lemmy_beta.hjson │ │ ├── lemmy_delta.hjson │ │ ├── lemmy_epsilon.hjson │ │ ├── lemmy_gamma.hjson │ │ ├── nginx.conf │ │ └── start-local-instances.bash │ ├── lemmy.hjson │ ├── nginx.conf │ └── test_deploy.sh ├── migrations/ │ ├── 00000000000000_diesel_initial_setup/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-02-26-002946_create_user/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-02-27-170003_create_community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-03-03-163336_create_post/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-03-05-233828_create_comment/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-03-30-212058_create_post_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-04-03-155205_create_community_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-04-03-155309_create_comment_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-04-07-003142_create_moderation_logs/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-04-08-015947_create_user_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-04-11-144915_create_mod_views/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-04-29-175834_add_delete_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-05-02-051656_community_view_hot_rank/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-06-01-222649_remove_admin/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-08-11-000918_add_nsfw_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-08-29-040006_add_community_count/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-09-05-230317_add_mod_ban_views/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-09-09-042010_add_stickied_posts/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-10-15-181630_add_themes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-10-19-052737_create_user_mention/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-10-21-011237_add_default_sorts/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-10-24-002614_create_password_reset_request/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-12-09-060754_add_lang/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-12-11-181820_add_site_fields/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2019-12-29-164820_add_avatar/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-01-200418_add_email_to_user_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-02-172755_add_show_avatar_and_email_notifications_to_user/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-11-012452_add_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-13-025151_create_materialized_views/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-21-001001_create_private_message/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-29-011901_create_reply_materialized_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-01-29-030825_create_user_mention_materialized_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-02-02-004806_add_case_insensitive_usernames/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-02-06-165953_change_post_title_length/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-02-07-210055_add_comment_subscribed/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-02-08-145624_add_post_newest_activity_time/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-03-06-202329_add_post_iframely_data/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-03-26-192410_add_activitypub_tables/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-04-03-194936_add_activitypub_for_posts_and_comments/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-04-07-135912_add_user_community_apub_constraints/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-04-14-163701_update_views_for_activitypub/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-04-21-123957_remove_unique_user_constraints/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-05-05-210233_add_activitypub_for_private_messages/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-06-30-135809_remove_mat_views/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-07-08-202609_add_creator_published/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-07-12-100442_add_post_title_to_comments_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-07-18-234519_add_unique_community_user_actor_ids/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-08-03-000110_add_preferred_usernames_banners_and_icons/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-08-06-205355_update_community_post_count/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-08-25-132005_add_unique_ap_ids/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-09-07-231141_add_migration_utils/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-10-07-234221_fix_fast_triggers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-10-10-035723_fix_fast_triggers_2/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-10-13-212240_create_report_tables/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-10-23-115011_activity_ap_id_column/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-11-05-152724_activity_remove_user_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-11-10-150835_community_follower_pending/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-11-26-134531_delete_user/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-02-152437_create_site_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-03-035643_create_user_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-04-183345_create_community_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-10-152350_create_post_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-14-020038_create_comment_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-17-030456_create_alias_views/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-12-17-031053_remove_fast_tables_and_views/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-05-200932_add_hot_rank_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-26-173850_default_actor_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-27-202728_active_users_monthly/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-01-31-050334_add_forum_sort_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-02-02-153240_apub_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-02-10-164051_add_new_comments_sort_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-02-13-210612_set_correct_aggregates_time_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-02-25-112959_remove-categories/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-02-28-162616_clean_empty_post_urls/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-04-040229_clean_icon_urls/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-09-171136_split_user_table_2/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-19-014144_add_col_local_user_validator_time/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-20-185321_move_matrix_id_to_person/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-31-103917_add_show_score_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-31-105915_add_bot_account/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-31-144349_add_site_short_description/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-04-01-173552_rename_preferred_username_to_display_name/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-04-01-181826_add_community_agg_active_monthly_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-04-02-021422_remove_community_creator/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-04-20-155001_limit-admins-create-community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-04-24-174047_add_show_read_post_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-07-19-130929_add_show_new_post_notifs_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-07-20-102033_actor_name_length/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-08-02-002342_comment_count_fixes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-08-04-223559_create_user_community_block/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-08-16-004209_fix_remove_bots_from_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-08-17-210508_create_mod_transfer_community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-09-20-112945_jwt-secret/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-10-01-141650_create_admin_purge/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-11-22-135324_add_activity_ap_id_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-11-22-143904_add_required_public_key/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-11-23-031528_add_report_published_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-11-23-132840_email_verification/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-11-23-153753_add_invite_only_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-12-09-225529_add_published_to_email_verification/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-12-14-181537_add_temporary_bans/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-01-04-034553_add_hidden_column/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-01-20-160328_remove_site_creator/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-01-28-104106_instance-actor/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-02-01-154240_add_community_title_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-02-18-210946_default_theme/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-04-04-183652_update_community_aggregates_on_soft_delete/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-04-11-210137_fix_unique_changeme/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-04-12-114352_default_post_listing_type/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-04-12-185205_change_default_listing_type_to_local/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-04-19-111004_default_require_application/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-04-26-105145_only_mod_can_post/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-05-19-153931_legal-information/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-05-20-135341_embed-url/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-06-12-012121_add_site_hide_modlog_names/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-06-13-124806_post_report_name_length/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-06-21-123144_language-tags/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-07-07-182650_comment_ltrees/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-08-04-150644_add_application_email_admins/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-08-04-214722_add_distinguished_comment/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-08-05-203502_add_person_post_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-08-22-193848_comment-language-tags/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-09-07-113813_drop_ccnew_indexes_function/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-09-07-114618_pm-reports/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-09-08-102358_site-and-community-languages/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-09-24-161829_remove_table_aliases/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-10-06-183632_move_blocklist_to_db/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-11-13-181529_create_taglines/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-11-20-032430_sticky_local/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-11-21-143249_remove-federation-settings/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-11-21-204256_user-following/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2022-12-05-110642_registration_mode/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-01-17-165819_cleanup_post_aggregates_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-01-012747_fix_active_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-05-102549_drop-site-federation-debug/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-07-030958_community-collections/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-11-173347_custom_emojis/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-13-172528_add_report_email_admins/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-13-221303_add_instance_software_and_version/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-15-212546_add_post_comment_saved_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-02-16-194139_add_totp_secret/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-04-14-175955_add_listingtype_sorttype_enums/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-04-23-164732_add_person_details_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-05-10-095739_force_enable_undetermined_language/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-06-104440_index_post_url/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-07-105918_add_hot_rank_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-17-175955_add_listingtype_sorttype_hour_enums/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-19-055530_add_retry_worker_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-19-120700_no_double_deletion/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-20-191145_add_listingtype_sorttype_3_6_9_months_enums/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-21-153242_add_captcha/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-22-051755_fix_local_communities_marked_non_local/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-22-101245_increase_user_theme_column_size/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-24-072904_add_open_links_in_new_tab_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-24-185942_aggegates_published_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-06-27-065106_add_ui_settings/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-04-153335_add_optimized_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-05-000058_person-admin/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-06-151124_hot-rank-future/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-08-101154_fix_soft_delete_aggregates/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-10-075550_add-infinite-scroll-setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-11-084714_receive_activity_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-14-154840_add_optimized_indexes_published/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-14-215339_aggregates_nonzero_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-18-082614_post_aggregates_community_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-19-163511_comment_sort_hot_rank_then_score/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-24-232635_trigram-index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-26-000217_create_controversial_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-26-222023_site-aggregates-one/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-07-27-134652_remove-expensive-broken-trigger/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-01-101826_admin_flag_local_user/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-01-115243_persistent-activity-queue/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-02-144930_password-reset-token/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-02-174444_fix-timezones/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-08-163911_add_post_listing_mode_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-09-101305_user_instance_block/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-23-182533_scaled_rank/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-29-183053_add_listing_type_moderator_view/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-08-31-205559_add_image_upload/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-01-112158_auto_resolve_report/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-07-215546_post-queries-efficient/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-11-110040_rework-2fa-setup/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-12-194850_add_federation_worker_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-18-141700_login-token/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-20-110614_drop-show-new-post-notifs/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-09-28-084231_import_user_settings_rate_limit/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-02-145002_community_followers_count_federated/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-06-133405_add_keyboard_navigation_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-13-175712_allow_animated_avatars/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-17-181800_drop_remove_community_expires/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-23-184941_hot_rank_greatest_fix/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-24-030352_change_primary_keys_and_remove_some_id_columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-24-131607_proxy_links/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-24-183747_autocollapse_bot_comments/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-10-27-142514_post_url_content_type/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-01-223740_federation-published/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-02-120140_apub-signed-fetch/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-07-135409_inbox_unique/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-11-22-194806_low_rank_defaults/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-12-06-180359_edit_active_users/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-12-19-210053_tolerable-batch-insert-speed/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2023-12-22-040137_make-mixed-sorting-directions-work-with-tuple-comparison/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-01-02-094916_site-name-not-unique/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-01-05-213000_community_aggregates_add_local_subscribers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-01-15-100133_local-only-community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-01-22-105746_lemmynsfw-changes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-01-25-151400_remove_auto_resolve_report_trigger/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-12-211114_add_vote_display_mode_setting/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-15-171358_default_instance_sort_type/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-24-034523_replaceable-schema/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-27-204628_add_post_alt_text/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-02-28-144211_hide_posts/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-03-06-104706_local_image_user_opt/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-03-06-201637_url_blocklist/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-04-05-153647_alter_vote_display_mode_defaults/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-04-15-105932_community_followers_url_optional/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-04-23-020604_add_post_id_index/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-05-04-140749_separate_triggers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-05-05-162540_add_image_detail_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-06-17-160323_fix_post_aggregates_featured_local/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-06-24-000000_ap_id_triggers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-07-01-014711_exponential_controversy/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-08-03-155932_increase_post_url_max_length/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2024-11-12-090437_move-triggers/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-01-10-135505_donation-dialog/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-02-11-131045_ban-remove-content-pm/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-02-24-173152_search-alt-text-of-posts/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-03-07-094522_enable_english_for_all/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-04-07-100344_registration-rate-limit/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-05-15-154113_missing_post_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-07-29-152742_add_indexes_for_aggregates_activity/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-07-29-152743_post-aggregates-creator-community-indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000000_enable_private_messages/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000002_error_if_code_migrations_needed/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000003_remove_show_scores_column/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000004_custom_emoji_tagline_changes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000005_drop-enable-nsfw/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000006_default_comment_sort_type/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000007_schedule-post/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000008_create_oauth_provider/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000009_add_federation_vote_rejection/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000010_remove_auto_expand/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000011_add_short_community_description/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000012_no-individual-inboxes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000013_comment-vote-remote-postid/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000014_private-community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000015_add_mark_fetched_posts_as_read/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000016_smoosh-tables-together/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000017_forbid_diesel_cli/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000018_custom_migration_runner/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000019_add_report_count/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000020_oauth_pkce/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000021_add_blurhash_to_image_details/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000022_instance-block-mod-log/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000023_add_report_combined_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000024_add_person_content_combined_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000025_add_modlog_combined_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000026_add_inbox_combined_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000027_add_search_combined_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000028_add_index_on_person_id_read_for_read_only_post_actions/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000029_community-post-tags/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000030_optimize_get_random_community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000031_update-replaceable-schema/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000032_community_report/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000033_add_post_keyword_block_table/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000034_no-image-token/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000035_media_filter/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000036_interactions_per_month_schema/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000037_report_to_admins/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000038_ap_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000039_remove_post_sort_type_enums/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000040_block_nsfw/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000041_remove-aggregate-tables/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000042_community-hidden-visibility/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000043_community-local-removed/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000044_post_comment_pending/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000045_site_person_ban/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000047_disable-email-notifications/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000048_cursor_pagination_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000049_add_liked_combined/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000050_show_downvotes_for_others_only/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000051_local_image_person/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000052_lock_reason/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000053_remove_hide_modlog_names/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000054_mod-change-community-vis/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000055_rename_timestamp_add_at/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000056_person_note/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000057_multi-community/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000058_instance_block_communities_persons/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000059_person_votes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000060_rename-rate-limit-columns/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000061_drop-person-ban/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000062_username-instance-unique/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000063_post-or-comment-notification/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000064_add_missing_foreign_key_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000065_group-follow/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000066_modlog-rename/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000067_add_default_items_per_page/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-01-000068_local_user_trigger/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-06-170325_add_indexes_for_aggregates_activity_new/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-08-20-000000_comment-lock/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-01-141127_local-community-collections/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-08-000001_add-video-dimensions/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-08-140711_remove-actor-name-max-length/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-12-093537_mod-reason-mandatory/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-15-090401_remove-keyboard-nav/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-19-090047_notify-mod-action/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-09-19-132648-0000_theme-instance-default/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-10-08-084508-0000_multi-comm-index-lower/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-10-09-101527-0000_community-follower-denied/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-10-15-114811-0000_merge-modlog-tables/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-11-05-181519-0000_add_registration_application_updated_at/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2025-11-08-123111-0000_add_multi_community_subscribers_community_count/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-01-08-132525-0000_community-sidebar-summary/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-01-19-122321-0000_add_community_tag_color/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-01-23-094410-0000_rename-sidebar-again/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-01-23-140244-0000_rename-tag-to-community-tag/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-01-28-115414-0000_captcha-plugin/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-02-01-205644-0000_add_moderator_warn_modlog_kind/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-02-03-235249-0000_add_moderator_warn_constraint_check/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-02-19-120000-0000_add_bulk_to_modlog/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-02-19-192014-0000_rename_suggested_communities/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-02-24-205759-0000_add_notification_creator_id/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-03-02-231448-0000_add_multi_community_sidebar/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-03-03-211442-0000_move_config_pictrs_to_db/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-03-04-143123-0000_add_deleted_by_recip_to_pm/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-03-08-021022-0000_fixup_post_action_indexes/ │ │ ├── down.sql │ │ └── up.sql │ ├── 2026-03-08-202630-0000_add_modlog_foreign_keys/ │ │ ├── down.sql │ │ └── up.sql │ └── 2026-03-09-014616-0000_add_resolved_report_combined/ │ ├── down.sql │ └── up.sql ├── readmes/ │ ├── README.es.md │ ├── README.ja.md │ ├── README.ru.md │ ├── README.zh.hans.md │ └── README.zh.hant.md ├── rust-toolchain.toml └── scripts/ ├── alpine_install_pg_formatter.sh ├── clean-workspace.sh ├── clear_db.sh ├── compilation_benchmark.sh ├── db-init.sh ├── db_perf.sh ├── dump_schema.sh ├── install.sh ├── lint.sh ├── postgres_12_to_15_upgrade.sh ├── postgres_15_to_16_upgrade.sh ├── query_testing/ │ ├── apache_bench_report.sh │ ├── api_benchmark.sh │ ├── bulk_upsert_timings.md │ ├── post_query_hot_rank.sh │ ├── views_old/ │ │ ├── generate_reports.sh │ │ └── timings-2021-01-05_21-06-37.out │ └── views_to_diesel_migration/ │ ├── generate_reports.sh │ └── timings-2021-01-05_21-32-54.out ├── release.bash ├── restore_db.sh ├── sql_format_check.sh ├── start_dev_db.sh ├── test-with-coverage.sh ├── test.sh ├── update_config_defaults.sh ├── update_schema_file.sh ├── update_translations.sh └── upgrade_deps.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Normalize EOL for all files that Git considers text files. * text=auto eol=lf ================================================ FILE: .github/CODEOWNERS ================================================ * @Nutomic @dessalines @phiresky @dullbananas crates/apub/ @Nutomic migrations/ @dessalines @phiresky @dullbananas ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms patreon: dessalines liberapay: Lemmy ================================================ FILE: .github/ISSUE_TEMPLATE/BUG_REPORT.yml ================================================ name: "\U0001F41E Bug Report" description: Create a report to help us improve lemmy title: "[Bug]: " labels: ["bug", "triage"] body: - type: markdown attributes: value: | Found a bug? Please fill out the sections below. 👍 Thanks for taking the time to fill out this bug report! For front end issues, use [lemmy](https://github.com/LemmyNet/lemmy-ui) - type: checkboxes attributes: label: Requirements description: Before you create a bug report please do the following. options: - label: Is this a bug report? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org). required: true - label: Did you check to see if this issue already exists? required: true - label: Is this only a single bug? Do not put multiple bugs in one issue. required: true - label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)? required: true - label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues. required: true - type: textarea id: summary attributes: label: Summary description: A summary of the bug. validations: required: true - type: textarea id: reproduce attributes: label: Steps to Reproduce description: | Describe the steps to reproduce the bug. The better your description is _(go 'here', click 'there'...)_ the fastest you'll get an _(accurate)_ resolution. value: | 1. 2. 3. validations: required: true - type: textarea id: technical attributes: label: Technical Details description: | - Please post your log: `sudo docker-compose logs > lemmy_log.out`. - What OS are you trying to install lemmy on? - Any browser console errors? validations: required: true - type: input id: lemmy-backend-version attributes: label: Version description: Which Lemmy backend version do you use? Displayed in the footer. placeholder: ex. BE 0.17.4 validations: required: true - type: input id: lemmy-instance attributes: label: Lemmy Instance URL description: Which Lemmy instance do you use? The address placeholder: lemmy.ml, lemmy.world, etc ================================================ FILE: .github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml ================================================ name: "\U0001F680 Feature request" description: Suggest an idea for improving Lemmy labels: ["enhancement"] body: - type: markdown attributes: value: | Have a suggestion about Lemmy's UI? For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy) - type: checkboxes attributes: label: Requirements description: Before you create a bug report please do the following. options: - label: Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org). required: true - label: Did you check to see if this issue already exists? required: true - label: Is this only a feature request? Do not put multiple feature requests in one issue. required: true - label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues. required: true - label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)? required: true - type: textarea id: problem attributes: label: Is your proposal related to a problem? description: | Provide a clear and concise description of what the problem is. For example, "I'm always frustrated when..." validations: required: true - type: textarea id: solution attributes: label: Describe the solution you'd like. description: | Provide a clear and concise description of what you want to happen. validations: required: true - type: textarea id: alternatives attributes: label: Describe alternatives you've considered. description: | Let us know about other solutions you've tried or researched. validations: required: true - type: textarea id: context attributes: label: Additional context description: | Is there anything else you can add about the proposal? You might want to link to related issues here, if you haven't already. ================================================ FILE: .github/ISSUE_TEMPLATE/QUESTION.yml ================================================ name: "? Question" description: General questions about Lemmy title: "Question: " labels: ["question", "triage"] body: - type: markdown attributes: value: | For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org). Have a question about how Lemmy works? Please check the docs first: https://join-lemmy.org/docs/en/index.html - type: textarea id: question attributes: label: Question description: What's the question you have about Lemmy? validations: required: true ================================================ FILE: .github/SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Use [Github's security advisory issue system](https://github.com/LemmyNet/lemmy/security/advisories/new). ================================================ FILE: .gitignore ================================================ # local ansible configuration ansible/inventory ansible/passwords/ # docker build files docker/lemmy_mine.hjson docker/dev/env_deploy.sh docker/volumes docker/*.sql.xz # ide config .idea .vscode # local build files target env_setup.sh query_testing/**/reports/*.json # API tests api_tests/node_modules api_tests/.yalc api_tests/yalc.lock api_tests/pict-rs api_tests/speed_tests.sh # pictrs data pictrs/ # The generated typescript bindings bindings # database dumps *.sqldump # diesel migrations/.diesel_lock ================================================ FILE: .gitmodules ================================================ [submodule "crates/utils/translations"] path = crates/email/translations url = https://github.com/LemmyNet/lemmy-translations.git branch = main ================================================ FILE: .rustfmt.toml ================================================ tab_spaces = 2 edition = "2024" imports_layout = "HorizontalVertical" imports_granularity = "Crate" group_imports = "One" wrap_comments = true comment_width = 100 ================================================ FILE: .woodpecker.yml ================================================ # TODO: The when: platform conditionals aren't working currently # See https://github.com/woodpecker-ci/woodpecker/issues/1677 variables: # When updating the rust version here, be sure to update versions in `docker/Dockerfile` # as well. Otherwise release builds can fail if Lemmy or dependencies rely on new Rust # features. In particular the ARM builder image needs to be updated manually in the repo below: # https://github.com/raskyld/lemmy-cross-toolchains # Also be sure to change the version in `rust-toolchain.toml` - &rust_image "rust:1.94" - &rust_nightly_image "rustlang/rust:nightly" - &install_pnpm "npm install -g corepack@latest && corepack enable pnpm" - &install_binstall "wget -q -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" - &slow_check_paths - event: pull_request path: include: [ # rust source code "crates/**", "**/Cargo.toml", "Cargo.lock", # database migrations "migrations/**", # typescript tests "api_tests/**", # config files and scripts used by ci ".woodpecker.yml", ".rustfmt.toml", "scripts/update_config_defaults.sh", "diesel.toml", ".gitmodules", ] steps: prepare_repo: image: alpine:3 commands: - apk add git - git submodule init - git submodule update when: - event: [pull_request, tag] prettier_check: image: jauderho/prettier:3.7.4-alpine commands: - prettier -c . '!**/volumes' '!**/dist' '!target' '!**/translations' '!api_tests/pnpm-lock.yaml' when: - event: pull_request bash_fmt: image: alpine:3 commands: - apk add shfmt - shfmt -i 2 -d */**.bash - shfmt -i 2 -d */**.sh when: - event: pull_request toml_fmt: image: ghcr.io/shaddydc/taplo commands: - taplo format --check when: - event: pull_request sql_fmt: image: *rust_image commands: - apt-get install perl make bash - ./scripts/alpine_install_pg_formatter.sh - ./scripts/sql_format_check.sh when: - event: pull_request cargo_fmt: image: *rust_nightly_image environment: # store cargo data in repo folder so that it gets cached between steps CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - rustup component add rustfmt --toolchain nightly - cargo +nightly fmt -- --check when: - event: pull_request cargo_shear: image: *rust_nightly_image commands: - *install_binstall - cargo binstall -y cargo-shear --disable-strategies compile - cargo shear --deny-warnings when: - event: pull_request api_tests_lint: image: node:24-trixie-slim commands: - *install_pnpm - cd api_tests/ - pnpm i - pnpm lint when: *slow_check_paths ignored_files: image: alpine:3 commands: - apk add git - IGNORED=$(git ls-files --cached -i --exclude-standard) - if [[ "$IGNORED" ]]; then echo "Ignored files present:\n$IGNORED\n"; exit 1; fi when: - event: pull_request no_empty_files: image: alpine:3 commands: # Makes sure there are no files smaller than 2 bytes # Don't use completely empty, as some editors use newlines - EMPTY_FILES=$(find crates migrations api_tests/src config -type f -size -2c) - if [[ "$EMPTY_FILES" ]]; then echo "Empty files present:\n$EMPTY_FILES\n"; exit 1; fi when: - event: pull_request cargo_build: image: *rust_image environment: CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - cargo build - mv target/debug/lemmy_server target/lemmy_server when: *slow_check_paths # `DROP OWNED` doesn't work for default user create_database_user: image: pgautoupgrade/pgautoupgrade:18-alpine environment: PGUSER: postgres PGPASSWORD: password PGHOST: database PGDATABASE: lemmy commands: - psql -c "CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;" when: *slow_check_paths cargo_test: image: *rust_image environment: LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy RUST_BACKTRACE: "1" CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home LEMMY_TEST_FAST_FEDERATION: "1" LEMMY_CONFIG_LOCATION: /woodpecker/src/github.com/LemmyNet/lemmy/config/config.hjson commands: # Install pg_dump for the schema setup test (must match server version) - apt update && apt install -y lsb-release - sh -c 'echo "deb [signed-by=/usr/share/keyrings/postgres-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' - wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgres-keyring.gpg - apt update && apt install -y postgresql-client-18 # Run tests (if they fail, try again) - cargo test --workspace || cargo test --workspace when: *slow_check_paths cargo_clippy: image: *rust_image environment: CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - rustup component add clippy - cargo clippy --workspace --tests --all-targets --all-features -- -D warnings when: *slow_check_paths # make sure api builds with default features (used by other crates relying on lemmy api) check_api_common_default_features: image: *rust_image environment: CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - cargo check --package lemmy_api_common when: *slow_check_paths check_disallowed_dependencies: image: *rust_image environment: CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - "! cargo tree -p lemmy_api_common --no-default-features -i diesel" - "! cargo tree -i aws-lc-sys" when: *slow_check_paths lemmy_api_common_works_with_wasm: image: *rust_image environment: CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - "rustup target add wasm32-unknown-unknown" - "cargo check --target wasm32-unknown-unknown -p lemmy_api_common" when: *slow_check_paths check_diesel_schema: image: *rust_image environment: LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy DATABASE_URL: postgres://lemmy:password@database:5432/lemmy RUST_BACKTRACE: "1" CARGO_HOME: .cargo_home RUSTUP_HOME: .rustup_home commands: - *install_binstall - cp crates/db_schema_file/src/schema.rs tmp.schema - target/lemmy_server migration --all run - apt-get update && apt-get install -y postgresql-client - cargo binstall diesel_cli@2.3.2 -y --disable-strategies compile - export PATH="$CARGO_HOME/bin:$PATH" - diesel print-schema - diff tmp.schema crates/db_schema_file/src/schema.rs when: *slow_check_paths run_federation_tests: image: node:24-trixie-slim environment: LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432 DO_WRITE_HOSTS_FILE: "1" commands: - *install_pnpm - apt-get update && apt-get install -y bash curl postgresql-client - bash api_tests/prepare-drone-federation-test.sh - cd api_tests/ - pnpm i # Unfortunately these tests are unstable on slower CI machines, so try it a few times. - pnpm api-test-multiple when: *slow_check_paths federation_tests_server_output: image: alpine:3 commands: # `|| true` prevents this step from appearing to fail if the server output files don't exist - cat target/log/lemmy_*.out || true - "# If you can't see all output, then use the download button" when: - event: pull_request status: failure publish_release_docker: image: woodpeckerci/plugin-docker-buildx settings: repo: dessalines/lemmy dockerfile: docker/Dockerfile username: from_secret: docker_username password: from_secret: docker_password # On release builds, switch these comment lines to also do arm64 qemu builds. # This takes 8 hours, so its not a good idea to do it for any other release. platforms: linux/amd64 # platforms: linux/amd64,linux/arm64 build_args: RUST_RELEASE_MODE: release build_args_from_env: - CI_PIPELINE_EVENT tag: ${CI_COMMIT_TAG=nightly} when: - event: tag - event: cron # lemmy container doesnt run as root so we need to change permissions to let it copy the binary chmod_for_native_binary: image: alpine:3 commands: - chmod 777 . when: - event: tag # extract lemmy binary from newly built docker image into workspace folder extract_native_binary: image: dessalines/lemmy:${CI_COMMIT_TAG=default} commands: - cp /usr/local/bin/lemmy_server . when: - event: tag prepare_native_binary: image: alpine:3 commands: - sha256sum lemmy_server > sha256sum.txt - gzip lemmy_server when: - event: tag # https://woodpecker-ci.org/plugins/Release publish_native_binary: image: woodpeckerci/plugin-release settings: files: - lemmy_server.gz - sha256sum.txt title: ${CI_COMMIT_TAG} prerelease: true api-key: from_secret: github_token when: - event: tag # using https://github.com/pksunkara/cargo-workspaces publish_to_crates_io: image: *rust_image environment: CARGO_API_TOKEN: from_secret: cargo_api_token commands: - *install_binstall - cargo binstall -y cargo-workspaces@0.4.1 --disable-strategies compile - cp -r migrations crates/db_schema/ - cargo workspaces publish --token "$CARGO_API_TOKEN" --from-git --allow-dirty --no-verify --allow-branch "${CI_COMMIT_TAG}" --yes custom "${CI_COMMIT_TAG}" when: - event: tag notify_success: image: alpine:3 commands: - apk add curl - "curl -H'Title: ✔️ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci" when: - event: pull_request status: [success] notify_failure: image: alpine:3 commands: - apk add curl - "curl -H'Title: ❌ ${CI_REPO_NAME}/${CI_COMMIT_SOURCE_BRANCH}' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci" when: - event: pull_request status: [failure] notify_on_tag_deploy: image: alpine:3 commands: - apk add curl - "curl -H'Title: ${CI_REPO_NAME}:${CI_COMMIT_TAG} deployed' -d'${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci" when: event: tag services: database: image: pgautoupgrade/pgautoupgrade:18-alpine environment: POSTGRES_DB: lemmy POSTGRES_USER: postgres POSTGRES_PASSWORD: password ================================================ FILE: Cargo.toml ================================================ [workspace.package] version = "1.0.0-test-arm-qemu.0" edition = "2024" description = "A link aggregator for the fediverse" license = "AGPL-3.0" homepage = "https://join-lemmy.org/" documentation = "https://join-lemmy.org/docs/en/index.html" repository = "https://github.com/LemmyNet/lemmy" rust-version = "1.92" # See https://github.com/johnthagen/min-sized-rust for additional optimizations [profile.release] lto = "fat" opt-level = 3 # Optimize for speed, not size. codegen-units = 1 # Reduce parallel code generation. # This profile significantly speeds up build time. If debug info is needed you can comment the line # out temporarily, but make sure to leave this in the main branch. [profile.dev] debug = 0 # Optimize procedural macros [profile.dev.build-override] opt-level = 1 [workspace] members = [ "crates/utils", "crates/db_schema", "crates/db_schema_file", "crates/diesel_utils", "crates/email", "crates/db_views/private_message", "crates/db_views/local_user", "crates/db_views/local_image", "crates/db_views/person", "crates/db_views/post", "crates/db_views/vote", "crates/db_views/local_image", "crates/db_views/comment", "crates/db_views/community", "crates/db_views/community_moderator", "crates/db_views/community_follower", "crates/db_views/community_follower_approval", "crates/db_views/custom_emoji", "crates/db_views/notification", "crates/db_views/notification_sql", "crates/db_views/modlog", "crates/db_views/person_content_combined", "crates/db_views/person_saved_combined", "crates/db_views/person_liked_combined", "crates/db_views/post_comment_combined", "crates/db_views/report_combined", "crates/db_views/report_combined_sql", "crates/db_views/search_combined", "crates/db_views/site", "crates/api/api", "crates/api/api_crud", "crates/api/api_common", "crates/api/api_utils", "crates/api/routes", "crates/api/routes_v3", "crates/apub/apub", "crates/apub/activities", "crates/apub/objects", "crates/apub/send", "crates/routes", "crates/server", ] resolver = "3" [workspace.lints.clippy] cast_lossless = "deny" complexity = { level = "deny", priority = -1 } correctness = { level = "deny", priority = -1 } dbg_macro = "deny" explicit_into_iter_loop = "deny" explicit_iter_loop = "deny" get_first = "deny" implicit_clone = "deny" indexing_slicing = "deny" inefficient_to_string = "deny" items-after-statements = "deny" manual_string_new = "deny" needless_collect = "deny" perf = { level = "deny", priority = -1 } redundant_closure_for_method_calls = "deny" style = { level = "deny", priority = -1 } suspicious = { level = "deny", priority = -1 } uninlined_format_args = "allow" unused_self = "deny" unwrap_used = "deny" unimplemented = "deny" unused_async = "deny" map_err_ignore = "deny" expect_used = "deny" as_conversions = "deny" large_futures = "deny" tests_outside_test_module = "deny" try_err = "deny" unreachable = "deny" string_slice = "deny" same_name_method = "deny" return_and_then = "deny" ref_patterns = "deny" redundant_type_annotations = "deny" if_then_some_else_none = "deny" allow_attributes = "deny" [workspace.dependencies] lemmy_api = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/api/api" } lemmy_api_crud = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/api/api_crud" } lemmy_api_routes = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/api/routes" } lemmy_api_routes_v3 = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/api/routes_v3" } lemmy_apub = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/apub/apub" } lemmy_apub_activities = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/apub/activities" } lemmy_apub_objects = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/apub/objects" } lemmy_utils = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/utils", default-features = false } lemmy_db_schema = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_schema" } lemmy_db_schema_file = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_schema_file" } lemmy_diesel_utils = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/diesel_utils" } lemmy_api_utils = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/api/api_utils" } lemmy_routes = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/routes" } lemmy_apub_send = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/apub/send" } lemmy_email = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/email" } lemmy_db_views_comment = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/comment" } lemmy_db_views_community = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/community" } lemmy_db_views_community_follower = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/community_follower" } lemmy_db_views_community_follower_approval = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/community_follower_approval" } lemmy_db_views_community_moderator = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/community_moderator" } lemmy_db_views_custom_emoji = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/custom_emoji" } lemmy_db_views_notification = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/notification" } lemmy_db_views_notification_sql = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/notification_sql" } lemmy_db_views_local_image = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/local_image" } lemmy_db_views_local_user = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/local_user" } lemmy_db_views_modlog = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/modlog" } lemmy_db_views_person = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/person" } lemmy_db_views_person_content_combined = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/person_content_combined" } lemmy_db_views_person_liked_combined = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/person_liked_combined" } lemmy_db_views_person_saved_combined = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/person_saved_combined" } lemmy_db_views_post_comment_combined = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/post_comment_combined" } lemmy_db_views_post = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/post" } lemmy_db_views_private_message = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/private_message" } lemmy_db_views_registration_applications = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/registration_applications" } lemmy_db_views_report_combined = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/report_combined" } lemmy_db_views_report_combined_sql = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/report_combined_sql" } lemmy_db_views_search_combined = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/search_combined" } lemmy_db_views_site = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/site" } lemmy_db_views_vote = { version = "=1.0.0-test-arm-qemu.0", path = "./crates/db_views/vote" } activitypub_federation = { version = "0.7.0-beta.9", default-features = false, features = [ "actix-web", ] } diesel = { version = "2.3.6", features = [ "64-column-tables", "chrono", "postgres", "serde_json", "uuid", ] } diesel_migrations = "2.3.1" diesel-async = "0.7.4" serde = { version = "1.0.228", features = ["derive"] } serde_with = "3.17.0" actix-web = { version = "4.13.0", default-features = false, features = [ "compress-brotli", "compress-gzip", "compress-zstd", "cookies", "macros", "rustls-0_23", ] } tracing = { version = "0.1.44", default-features = false } tracing-actix-web = { version = "0.7.21", default-features = false } tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } url = { version = "2.5.8", features = ["serde"] } reqwest = { version = "0.13.2", default-features = false, features = [ "gzip", "json", "rustls-no-provider", ] } reqwest-middleware = "0.5.1" reqwest-tracing = "0.7.0" clokwerk = "0.4.0" doku = { version = "0.21.1", features = ["url-2"] } bcrypt = "0.19.0" chrono = { version = "0.4.44", features = [ "now", "serde", ], default-features = false } serde_json = { version = "1.0.149", features = ["preserve_order"] } base64 = "0.22.1" uuid = { version = "1.22.0", features = ["serde"] } anyhow = { version = "1.0.102", features = ["backtrace"] } diesel_ltree = "0.4.0" serial_test = "3.4.0" tokio = { version = "1.50.0", features = ["full"] } regex = "1.12.3" diesel-derive-newtype = "2.1.2" diesel-derive-enum = { version = "2.1.0", features = ["postgres"] } enum-map = { version = "2.7" } strum = { version = "0.28.0", features = ["derive"] } itertools = "0.14.0" futures = "0.3.32" futures-util = "0.3.32" http = "1.3" rosetta-i18n = "0.1.3" ts-rs = { version = "12.0.1", features = [ "chrono-impl", "no-serde-warnings", "url-impl", ] } rustls = { version = "0.23.37", features = ["ring"], default-features = false } tokio-postgres = "0.7.16" tokio-postgres-rustls = "0.13.0" urlencoding = "2.1.3" moka = { version = "0.12.14", features = ["future"] } i-love-jesus = { version = "0.3.0" } clap = { version = "4.5.60", features = ["derive", "env"] } pretty_assertions = "1.4.1" derive-new = "0.7.0" html2text = "0.16.7" async-trait = "0.1.89" either = { version = "1.15.0", features = ["serde"] } extism = { version = "1.13.0", default-features = false, features = [ "http", "register-filesystem", "register-http", ] } extism-convert = "1.13.0" unified-diff = "0.2.1" diesel-uplete = { version = "0.2.0" } cfg-if = "1" # Speedup RSA key generation # https://github.com/RustCrypto/RSA/blob/master/README.md#example [profile.dev.package.num-bigint-dig] opt-level = 3 ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================
[![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/releases) [![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy) [![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues) [![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/) [![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/) [![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)](https://github.com/LemmyNet/lemmy/stargazers)

English | Español | Русский | 汉语 | 漢語 | 日本語

Lemmy

A link aggregator and forum for the fediverse.

Join Lemmy · Documentation · Matrix Chat · Report Bug · Request Feature · Releases · Code of Conduct

## About The Project | Desktop | Mobile | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) | [Lemmy](https://github.com/LemmyNet/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse). For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere. It is an easily self-hostable, decentralized alternative to Reddit and other link aggregators, outside of their corporate control and meddling. Each Lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing. ### Why's it called Lemmy? - Lead singer from [Motörhead](https://invidio.us/watch?v=3mbvWn1EY6g). - The old school [video game](). - The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa). - The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/). ### Built With - [Rust](https://www.rust-lang.org) - [Actix](https://actix.rs/) - [Diesel](http://diesel.rs/) - [Inferno](https://infernojs.org) - [Typescript](https://www.typescriptlang.org/) ## Features - Open source, [AGPL License](/LICENSE). - Self hostable, easy to deploy. - Comes with [Docker](https://join-lemmy.org/docs/administration/install_docker.html) and [Ansible](https://join-lemmy.org/docs/administration/install_ansible.html). - Clean, mobile-friendly interface. - Only a minimum of a username and password is required to sign up! - User avatar support. - Live-updating Comment threads. - Full vote scores `(+/-)` like old Reddit. - Themes, including light, dark, and solarized. - Emojis with autocomplete support. Start typing `:` - User tagging using `@`, Community tagging using `!`. - Integrated image uploading in both posts and comments. - A post can consist of a title and any combination of self text, a URL, or nothing else. - Notifications, on comment replies and when you're tagged. - Notifications can be sent via email. - Private messaging support. - i18n / internationalization support. - RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`. - Cross-posting support. - A _similar post search_ when creating new posts. Great for question / answer communities. - Moderation abilities. - Public Moderation Logs. - Can sticky posts to the top of communities. - Both site admins, and community moderators, who can appoint other moderators. - Can lock, remove, and restore posts and comments. - Can ban and unban users from communities and the site. - Can transfer site and communities to others. - Can fully erase your data, replacing all posts and comments. - NSFW post / community support. - High performance. - Server is written in rust. - Supports arm64 / Raspberry Pi. ## Installation - [Lemmy Administration Docs](https://join-lemmy.org/docs/administration/administration.html) ## Lemmy Projects - [awesome-lemmy - A community driven list of apps and tools for lemmy](https://github.com/dbeley/awesome-lemmy) ## Support / Donate Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project. Lemmy is made possible by a generous grant from the [NLnet foundation](https://nlnet.nl/). - [Support on Liberapay](https://liberapay.com/Lemmy). - [Support on Ko-fi](https://ko-fi.com/lemmynet). - [Support on OpenCollective](https://opencollective.com/lemmy). - [Support on Patreon](https://www.patreon.com/dessalines). ### Crypto - bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` - ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` ## Contributing Read the following documentation to setup the development environment and start coding: - [Contributing instructions](https://join-lemmy.org/docs/contributors/01-overview.html) - [Docker Development](https://join-lemmy.org/docs/contributors/03-docker-development.html) - [Local Development](https://join-lemmy.org/docs/contributors/02-local-development.html) When working on an issue or pull request, you can comment with any questions you may have so that maintainers can answer them. You can also join the [Matrix Development Chat](https://matrix.to/#/#lemmydev:matrix.org) for general assistance. ### Translations - If you want to help with translating, take a look at [Weblate](https://weblate.join-lemmy.org/projects/lemmy/). You can also help by [translating the documentation](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language). ## Community - [Matrix Space](https://matrix.to/#/#lemmy-space:matrix.org) - [Lemmy Forum](https://lemmy.ml/c/lemmy) - [Lemmy Support Forum](https://lemmy.ml/c/lemmy_support) ## Code Mirrors - [GitHub](https://github.com/LemmyNet/lemmy) - [Gitea](https://git.join-lemmy.org/LemmyNet/lemmy) - [Codeberg](https://codeberg.org/LemmyNet/lemmy) ## Credits Logo made by Andy Cuccaro (@andycuccaro) under the CC-BY-SA 4.0 license. ================================================ FILE: api_tests/.npmrc ================================================ package-manager-strict=false ================================================ FILE: api_tests/.prettierrc.json ================================================ { "arrowParens": "avoid", "semi": true } ================================================ FILE: api_tests/eslint.config.mjs ================================================ import pluginJs from "@eslint/js"; import tseslint from "typescript-eslint"; export default [ pluginJs.configs.recommended, ...tseslint.configs.recommended, { languageOptions: { parser: tseslint.parser, }, }, // For some reason this has to be in its own block { ignores: [ "putTypesInIndex.js", "dist/*", "docs/*", ".yalc", "jest.config.js", ], }, { files: ["src/**/*"], rules: { "@typescript-eslint/no-empty-interface": 0, "@typescript-eslint/no-empty-function": 0, "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/explicit-module-boundary-types": 0, "@typescript-eslint/no-var-requires": 0, "arrow-body-style": 0, curly: 0, "eol-last": 0, eqeqeq: 0, "func-style": 0, "import/no-duplicates": 0, "max-statements": 0, "max-params": 0, "new-cap": 0, "no-console": 0, "no-duplicate-imports": 0, "no-extra-parens": 0, "no-return-assign": 0, "no-throw-literal": 0, "no-trailing-spaces": 0, "no-unused-expressions": 0, "no-useless-constructor": 0, "no-useless-escape": 0, "no-var": 0, "prefer-const": 0, "prefer-rest-params": 0, "quote-props": 0, "unicorn/filename-case": 0, }, }, ]; ================================================ FILE: api_tests/jest.config.js ================================================ module.exports = { preset: "ts-jest", testEnvironment: "node", }; ================================================ FILE: api_tests/package.json ================================================ { "name": "api_tests", "version": "0.0.1", "description": "API tests for lemmy backend", "main": "index.js", "repository": "https://github.com/LemmyNet/lemmy", "author": "Dessalines", "license": "AGPL-3.0", "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", "scripts": { "lint": "tsc --noEmit && eslint --report-unused-disable-directives && prettier --check 'src/**/*.ts'", "fix": "prettier --write src && eslint --fix src", "api-test": "jest -i --bail --verbose --silent --testPathIgnorePatterns speed.spec.ts", "api-test-multiple": "pnpm run api-test || pnpm run api-test || pnpm run api-test || pnpm run api-test", "api-test-follow": "jest -i follow.spec.ts", "api-test-comment": "jest -i comment.spec.ts", "api-test-post": "jest -i post.spec.ts", "api-test-user": "jest -i user.spec.ts", "api-test-community": "jest -i community.spec.ts", "api-test-private-community": "jest -i private_comm.spec.ts", "api-test-private-message": "jest -i private_message.spec.ts", "api-test-image": "jest -i image.spec.ts", "api-test-tags": "jest -i tags.spec.ts", "api-test-speed": "jest -i speed.spec.ts", "api-test-apiv3": "jest -i apiv3.spec.ts" }, "devDependencies": { "@eslint/js": "^9.29.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.3", "@typescript-eslint/eslint-plugin": "^8.34.1", "@typescript-eslint/parser": "^8.34.1", "eslint": "^9.29.0", "eslint-plugin-prettier": "^5.5.0", "jest": "^30.0.0", "joi": "^18.0.0", "lemmy-js-client": "1.0.0-pictrs-fields-db.0", "lemmy-js-client-019": "npm:lemmy-js-client@0.19.9", "prettier": "^3.5.3", "ts-jest": "^29.4.0", "tsoa": "^6.6.0", "typescript": "^5.8.3", "typescript-eslint": "^8.34.1" } } ================================================ FILE: api_tests/pnpm-workspace.yaml ================================================ onlyBuiltDependencies: - unrs-resolver ================================================ FILE: api_tests/prepare-drone-federation-test.sh ================================================ #!/usr/bin/env bash # IMPORTANT NOTE: this script does not use the normal LEMMY_DATABASE_URL format # it is expected that this script is called by run-federation-test.sh script. set -e if [ -z "$LEMMY_LOG_LEVEL" ]; then LEMMY_LOG_LEVEL=info fi export RUST_BACKTRACE=1 export RUST_LOG="warn,lemmy_server=$LEMMY_LOG_LEVEL,lemmy_federate=$LEMMY_LOG_LEVEL,lemmy_api=$LEMMY_LOG_LEVEL,lemmy_api_common=$LEMMY_LOG_LEVEL,lemmy_api_crud=$LEMMY_LOG_LEVEL,lemmy_apub=$LEMMY_LOG_LEVEL,lemmy_db_schema=$LEMMY_LOG_LEVEL,lemmy_db_views=$LEMMY_LOG_LEVEL,lemmy_routes=$LEMMY_LOG_LEVEL,lemmy_utils=$LEMMY_LOG_LEVEL,lemmy_websocket=$LEMMY_LOG_LEVEL" export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min PICTRS_PATH="api_tests/pict-rs" PICTRS_EXPECTED_HASH="7f7ac2a45ef9b13403ee139b7512135be6b060ff2f6460e0c800e18e1b49d2fd api_tests/pict-rs" # Pictrs setup. Download file with hash check and up to 3 retries. if [ ! -f "$PICTRS_PATH" ]; then count=0 while [ ! -f "$PICTRS_PATH" ] && [ "$count" -lt 3 ]; do # This one sometimes goes down curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.17-pre.9/pict-rs-linux-amd64" -o "$PICTRS_PATH" # curl "https://codeberg.org/asonix/pict-rs/releases/download/v0.5.5/pict-rs-linux-amd64" -o "$PICTRS_PATH" PICTRS_HASH=$(sha256sum "$PICTRS_PATH") if [[ "$PICTRS_HASH" != "$PICTRS_EXPECTED_HASH" ]]; then echo "Pictrs binary hash mismatch, was $PICTRS_HASH but expected $PICTRS_EXPECTED_HASH" rm "$PICTRS_PATH" let count=count+1 fi done chmod +x "$PICTRS_PATH" fi ./api_tests/pict-rs \ run -a 0.0.0.0:8080 \ --danger-dummy-mode \ --api-key "my-pictrs-key" \ filesystem -p /tmp/pictrs/files \ sled -p /tmp/pictrs/sled-repo 2>&1 & for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do echo "DB URL: ${LEMMY_DATABASE_URL} INSTANCE: $INSTANCE" psql "${LEMMY_DATABASE_URL}/lemmy" -c "DROP DATABASE IF EXISTS $INSTANCE" echo "create database" psql "${LEMMY_DATABASE_URL}/lemmy" -c "CREATE DATABASE $INSTANCE" done if [ -z "$DO_WRITE_HOSTS_FILE" ]; then if ! grep -q lemmy-alpha /etc/hosts; then echo "Please add the following to your /etc/hosts file, then press enter: 127.0.0.1 lemmy-alpha 127.0.0.1 lemmy-beta 127.0.0.1 lemmy-gamma 127.0.0.1 lemmy-delta 127.0.0.1 lemmy-epsilon" read -p "" fi else for INSTANCE in lemmy-alpha lemmy-beta lemmy-gamma lemmy-delta lemmy-epsilon; do echo "127.0.0.1 $INSTANCE" >>/etc/hosts done fi echo "$PWD" LOG_DIR=target/log mkdir -p $LOG_DIR echo "start alpha" LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_alpha.hjson \ LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_alpha" \ target/lemmy_server >$LOG_DIR/lemmy_alpha.out 2>&1 & echo "start beta" LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_beta.hjson \ LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_beta" \ target/lemmy_server >$LOG_DIR/lemmy_beta.out 2>&1 & echo "start gamma" LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \ LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_gamma" \ target/lemmy_server >$LOG_DIR/lemmy_gamma.out 2>&1 & echo "start delta" LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \ LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_delta" \ target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 & echo "start epsilon" LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \ LEMMY_PLUGIN_PATH=api_tests/plugins \ LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_epsilon" \ target/lemmy_server >$LOG_DIR/lemmy_epsilon.out 2>&1 & echo "wait for all instances to start" while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-alpha:8541/api/v4/site')" != "200" ]]; do sleep 1; done echo "alpha started" while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-beta:8551/api/v4/site')" != "200" ]]; do sleep 1; done echo "beta started" while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-gamma:8561/api/v4/site')" != "200" ]]; do sleep 1; done echo "gamma started" while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-delta:8571/api/v4/site')" != "200" ]]; do sleep 1; done echo "delta started" while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-epsilon:8581/api/v4/site')" != "200" ]]; do sleep 1; done echo "epsilon started. All started" ================================================ FILE: api_tests/run-federation-test.sh ================================================ #!/usr/bin/env bash set -e export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432 pushd .. cargo build rm target/lemmy_server || true cp target/debug/lemmy_server target/lemmy_server killall -s1 lemmy_server || true ./api_tests/prepare-drone-federation-test.sh popd pnpm i pnpm api-test || true killall -s1 lemmy_server || true killall -s1 pict-rs || true for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do psql "$LEMMY_DATABASE_URL" -c "DROP DATABASE $INSTANCE" done rm -r /tmp/pictrs ================================================ FILE: api_tests/src/apiv3.spec.ts ================================================ jest.setTimeout(180000); import { LemmyHttp, Login, CreatePost, ResolveObject, } from "lemmy-js-client-019"; import { beta, betaUrl, setupLogins, unfollows } from "./shared"; import { CreateComment } from "lemmy-js-client"; beforeAll(async () => { await setupLogins(); }); afterAll(unfollows); test("API v3", async () => { let login_form: Login = { username_or_email: "lemmy_beta", password: "lemmylemmy", }; const login = await beta.login(login_form); expect(login.jwt).toBeDefined(); let user = new LemmyHttp(betaUrl, { headers: { Authorization: `Bearer ${login.jwt ?? ""}` }, }); let resolve_form: ResolveObject = { q: "!main@lemmy-beta:8551", }; const community = await user .resolveObject(resolve_form) .then(a => a.community); expect(community?.community).toBeDefined(); const post_form: CreatePost = { name: "post from api v3", community_id: community!.community.id, }; const post = await user.createPost(post_form); expect(post.post_view.post).toBeDefined(); const post_id = post.post_view.post.id; const post_listing = await user.getPosts(); expect( post_listing.posts.find(p => { return p.post.id === post_id; })?.post, ).toStrictEqual(post.post_view.post); const comment_form: CreateComment = { content: "comment from api v3", post_id, }; const comment = await user.createComment(comment_form); expect(comment.comment_view.comment).toBeDefined(); const comment_listing = await user.getComments({ post_id }); expect(comment_listing.comments[0].comment).toStrictEqual( comment.comment_view.comment, ); }); ================================================ FILE: api_tests/src/comment.spec.ts ================================================ jest.setTimeout(180000); import { PostResponse } from "lemmy-js-client/dist/types/PostResponse"; import { alpha, beta, gamma, setupLogins, createPost, getPost, resolveComment, likeComment, followBeta, resolveBetaCommunity, createComment, editComment, deleteComment, removeComment, resolvePost, unfollowRemotes, createCommunity, registerUser, reportComment, randomString, unfollows, getComment, getComments, getCommentParentId, resolveCommunity, waitUntil, waitForPost, alphaUrl, betaUrl, followCommunity, blockCommunity, saveUserSettings, listReports, listPersonContent, listNotifications, lockComment, statusNotFound, statusBadRequest, jestLemmyError, getUnreadCounts, } from "./shared"; import { CommentReportView, CommentView, CommunityView, DistinguishComment, LemmyError, ReportCombinedView, SaveUserSettings, } from "lemmy-js-client"; let betaCommunity: CommunityView | undefined; let postOnAlphaRes: PostResponse; beforeAll(async () => { await setupLogins(); await Promise.allSettled([followBeta(alpha), followBeta(gamma)]); betaCommunity = await resolveBetaCommunity(alpha); if (betaCommunity) { postOnAlphaRes = await createPost(alpha, betaCommunity.community.id); } }); afterAll(unfollows); function assertCommentFederation( commentOne?: CommentView, commentTwo?: CommentView, ) { expect(commentOne?.comment.ap_id).toBe(commentTwo?.comment.ap_id); expect(commentOne?.comment.content).toBe(commentTwo?.comment.content); expect(commentOne?.creator.name).toBe(commentTwo?.creator.name); expect(commentOne?.community.ap_id).toBe(commentTwo?.community.ap_id); expect(commentOne?.comment.published_at).toBe( commentTwo?.comment.published_at, ); expect(commentOne?.comment.updated_at).toBe(commentOne?.comment.updated_at); expect(commentOne?.comment.deleted).toBe(commentOne?.comment.deleted); expect(commentOne?.comment.removed).toBe(commentOne?.comment.removed); } test("Create a comment", async () => { let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); expect(commentRes.comment_view.comment.content).toBeDefined(); expect(commentRes.comment_view.community.local).toBe(false); expect(commentRes.comment_view.creator.local).toBe(true); expect(commentRes.comment_view.comment.score).toBe(1); // Make sure that comment is liked on beta let betaComment = await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.score === 1, ); expect(betaComment).toBeDefined(); expect(betaComment?.community.local).toBe(true); expect(betaComment?.creator.local).toBe(false); expect(betaComment?.comment.score).toBe(1); assertCommentFederation(betaComment, commentRes.comment_view); }); test("Create a comment in a non-existent post", async () => { await jestLemmyError( () => createComment(alpha, -1), new LemmyError("not_found", statusNotFound), ); }); test("Update a comment", async () => { let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); // Federate the comment first let betaComment = await resolveComment(beta, commentRes.comment_view.comment); assertCommentFederation(betaComment, commentRes.comment_view); let updateCommentRes = await editComment( alpha, commentRes.comment_view.comment.id, ); expect(updateCommentRes.comment_view.comment.content).toBe( "A jest test federated comment update", ); expect(updateCommentRes.comment_view.community.local).toBe(false); expect(updateCommentRes.comment_view.creator.local).toBe(true); // Make sure that post is updated on beta let betaCommentUpdated = await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.content === "A jest test federated comment update", ); assertCommentFederation(betaCommentUpdated, updateCommentRes.comment_view); }); test("Delete a comment", async () => { let post = await createPost(alpha, betaCommunity!.community.id); // creating a comment on alpha (remote from home of community) let commentRes = await createComment(alpha, post.post_view.post.id); // Find the comment on beta (home of community) let betaComment = await resolveComment(beta, commentRes.comment_view.comment); if (!betaComment) { throw "Missing beta comment before delete"; } // Find the comment on remote instance gamma let gammaComment = ( await waitUntil( () => resolveComment(gamma, commentRes.comment_view.comment).catch(e => e), r => r.message !== "not_found", ) ).comment; if (!gammaComment) { throw "Missing gamma comment (remote-home-remote replication) before delete"; } let deleteCommentRes = await deleteComment( alpha, true, commentRes.comment_view.comment.id, ); expect(deleteCommentRes.comment_view.comment.deleted).toBe(true); // Make sure that comment is deleted on beta await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.deleted === true, ); // Make sure that comment is deleted on gamma after delete await waitUntil( () => resolveComment(gamma, commentRes.comment_view.comment), c => c?.comment.deleted === true, ); // Test undeleting the comment let undeleteCommentRes = await deleteComment( alpha, false, commentRes.comment_view.comment.id, ); expect(undeleteCommentRes.comment_view.comment.deleted).toBe(false); // Make sure that comment is undeleted on beta let betaComment2 = await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.deleted === false, ); assertCommentFederation(betaComment2, undeleteCommentRes.comment_view); }); test.skip("Remove a comment from admin and community on the same instance", async () => { let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); // Get the id for beta let betaCommentId = ( await resolveComment(beta, commentRes.comment_view.comment) )?.comment.id; if (!betaCommentId) { throw "beta comment id is missing"; } // The beta admin removes it (the community lives on beta) let removeCommentRes = await removeComment(beta, true, betaCommentId); expect(removeCommentRes.comment_view.comment.removed).toBe(true); // Make sure that comment is removed on alpha (it gets pushed since an admin from beta removed it) let refetchedPostComments = await listPersonContent( alpha, commentRes.comment_view.comment.creator_id, "comments", ); let firstRefetchedComment = refetchedPostComments.items[0] as CommentView; expect(firstRefetchedComment.comment.removed).toBe(true); // beta will unremove the comment let unremoveCommentRes = await removeComment(beta, false, betaCommentId); expect(unremoveCommentRes.comment_view.comment.removed).toBe(false); // Make sure that comment is unremoved on alpha let refetchedPostComments2 = await getComments( alpha, postOnAlphaRes.post_view.post.id, ); expect(refetchedPostComments2.items[0].comment.removed).toBe(false); assertCommentFederation( refetchedPostComments2.items[0], unremoveCommentRes.comment_view, ); }); test("Remove a comment from admin and community on different instance", async () => { let newAlphaApi = await registerUser(alpha, alphaUrl); // New alpha user creates a community, post, and comment. let newCommunity = await createCommunity(newAlphaApi); let newPost = await createPost( newAlphaApi, newCommunity.community_view.community.id, ); let commentRes = await createComment(newAlphaApi, newPost.post_view.post.id); expect(commentRes.comment_view.comment.content).toBeDefined(); // Beta searches that to cache it, then removes it let betaComment = await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment !== undefined, ); if (!betaComment) { throw "beta comment missing"; } let removeCommentRes = await removeComment( beta, true, betaComment.comment.id, ); expect(removeCommentRes.comment_view.comment.removed).toBe(true); // Comment text is also hidden from list let listComments = await getComments( beta, removeCommentRes.comment_view.post.id, ); expect(listComments.items.length).toBe(1); expect(listComments.items[0].comment.removed).toBe(true); // Make sure its not removed on alpha let refetchedPostComments = await getComments( alpha, newPost.post_view.post.id, ); expect(refetchedPostComments.items[0].comment.removed).toBe(false); assertCommentFederation( refetchedPostComments.items[0], commentRes.comment_view, ); }); test("Unlike a comment", async () => { let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); // Lemmy automatically creates 1 like (vote) by author of comment. // Make sure that comment is liked (voted up) on gamma, downstream peer // This is testing replication from remote-home-remote (alpha-beta-gamma) let gammaComment1 = await waitUntil( () => resolveComment(gamma, commentRes.comment_view.comment), c => c?.comment.score === 1, ); expect(gammaComment1).toBeDefined(); expect(gammaComment1?.community.local).toBe(false); expect(gammaComment1?.creator.local).toBe(false); expect(gammaComment1?.comment.score).toBe(1); let unlike = await likeComment( alpha, undefined, commentRes.comment_view.comment, ); expect(unlike.comment_view.comment.score).toBe(0); // Make sure that comment is unliked on beta let betaComment = await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.score === 0, ); expect(betaComment).toBeDefined(); expect(betaComment?.community.local).toBe(true); expect(betaComment?.creator.local).toBe(false); expect(betaComment?.comment.score).toBe(0); // Make sure that comment is unliked on gamma, downstream peer // This is testing replication from remote-home-remote (alpha-beta-gamma) let gammaComment = await waitUntil( () => resolveComment(gamma, commentRes.comment_view.comment), c => c?.comment.score === 0, ); expect(gammaComment).toBeDefined(); expect(gammaComment?.community.local).toBe(false); expect(gammaComment?.creator.local).toBe(false); expect(gammaComment?.comment.score).toBe(0); }); test("Federated comment like", async () => { let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.score === 1, ); // Find the comment on beta let betaComment = await resolveComment(beta, commentRes.comment_view.comment); if (!betaComment) { throw "Missing beta comment"; } let like = await likeComment(beta, true, betaComment.comment); expect(like.comment_view.comment.score).toBe(2); // Get the post from alpha, check the likes let postComments = await waitUntil( () => getComments(alpha, postOnAlphaRes.post_view.post.id), c => c.items[0].comment.score === 2, ); expect(postComments.items[0].comment.score).toBe(2); }); test("Reply to a comment from another instance, get notification", async () => { await alpha.markAllNotificationsAsRead(); let betaCommunity = await waitUntil( () => resolveBetaCommunity(alpha), c => !!c?.community.instance_id, ); if (!betaCommunity) { throw "Missing beta community"; } const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id); // Create a root-level trunk-branch comment on alpha let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); // find that comment id on beta let betaComment = await waitUntil( () => resolveComment(beta, commentRes.comment_view.comment), c => c?.comment.score === 1, ); if (!betaComment) { throw "Missing beta comment"; } // Reply from beta, extending the branch let replyRes = await createComment( beta, betaComment.post.id, betaComment.comment.id, ); expect(replyRes.comment_view.comment.content).toBeDefined(); expect(replyRes.comment_view.community.local).toBe(true); expect(replyRes.comment_view.creator.local).toBe(true); expect(getCommentParentId(replyRes.comment_view.comment)).toBe( betaComment.comment.id, ); expect(replyRes.comment_view.comment.score).toBe(1); // Make sure that reply comment is seen on alpha let commentSearch = await waitUntil( () => resolveComment(alpha, replyRes.comment_view.comment), c => c?.comment.score === 1, ); let alphaComment = commentSearch!; let postComments = await waitUntil( () => getComments(alpha, postOnAlphaRes.post_view.post.id), pc => pc.items.length >= 2, ); // Note: this test fails when run twice and this count will differ expect(postComments.items.length).toBeGreaterThanOrEqual(2); expect(alphaComment.comment.content).toBeDefined(); expect(getCommentParentId(alphaComment.comment)).toBe( postComments.items[1].comment.id, ); expect(alphaComment.community.local).toBe(false); expect(alphaComment.creator.local).toBe(false); expect(alphaComment.comment.score).toBe(1); assertCommentFederation(alphaComment, replyRes.comment_view); // Did alpha get notified of the reply from beta? let alphaUnreadCountRes = await waitUntil( () => getUnreadCounts(alpha), e => e.notification_count >= 1, ); expect(alphaUnreadCountRes.notification_count).toBeGreaterThanOrEqual(1); // check inbox of replies on alpha, fetching read/unread both let alphaRepliesRes = await waitUntil( () => listNotifications(alpha, "reply"), r => r.items.length > 0, ); const alphaReply = alphaRepliesRes.items.find( r => r.data.type_ == "comment" && r.data.comment.id === alphaComment.comment.id, ); expect(alphaReply).toBeDefined(); if (!alphaReply) throw Error(); const alphaReplyData = alphaReply.data as CommentView; expect(alphaReplyData.comment!.content).toBeDefined(); expect(alphaReplyData.community!.local).toBe(false); expect(alphaReplyData.creator.local).toBe(false); expect(alphaReplyData.comment!.score).toBe(1); // ToDo: interesting alphaRepliesRes.replies[0].comment_reply.id is 1, meaning? how did that come about? expect(alphaReplyData.comment!.id).toBe(alphaComment.comment.id); // this is a new notification, getReplies fetch was for read/unread both, confirm it is unread. expect(alphaReply.notification.read).toBe(false); }); test("Bot reply notifications are filtered when bots are hidden", async () => { const newAlphaBot = await registerUser(alpha, alphaUrl); let form: SaveUserSettings = { bot_account: true, }; await saveUserSettings(newAlphaBot, form); const alphaCommunity = await resolveCommunity( alpha, "!main@lemmy-alpha:8541", ); if (!alphaCommunity) { throw "Missing alpha community"; } await alpha.markAllNotificationsAsRead(); form = { show_bot_accounts: false, }; await saveUserSettings(alpha, form); const postOnAlphaRes = await createPost(alpha, alphaCommunity.community.id); // Bot reply to alpha's post let commentRes = await createComment( newAlphaBot, postOnAlphaRes.post_view.post.id, ); expect(commentRes).toBeDefined(); let alphaUnreadCountRes = await getUnreadCounts(alpha); expect(alphaUnreadCountRes.notification_count).toBe(0); // This both restores the original state that may be expected by other tests // implicitly and is used by the next steps to ensure replies are still // returned when a user later decides to show bot accounts again. form = { show_bot_accounts: true, }; await saveUserSettings(alpha, form); alphaUnreadCountRes = await getUnreadCounts(alpha); expect(alphaUnreadCountRes.notification_count).toBe(1); let alphaUnreadRepliesRes = await listNotifications(alpha, "reply", true); expect(alphaUnreadRepliesRes.items.length).toBe(1); expect(alphaUnreadRepliesRes.items[0].notification.comment_id).toBe( commentRes.comment_view.comment.id, ); }); test("Mention beta from alpha comment", async () => { if (!betaCommunity) throw Error("no community"); const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id); // Create a new branch, trunk-level comment branch, from alpha instance let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); // Create a reply comment to previous comment, this has a mention in body let mentionContent = "A test mention of @lemmy_beta@lemmy-beta:8551"; let mentionRes = await createComment( alpha, postOnAlphaRes.post_view.post.id, commentRes.comment_view.comment.id, mentionContent, ); expect(mentionRes.comment_view.comment.content).toBeDefined(); expect(mentionRes.comment_view.community.local).toBe(false); expect(mentionRes.comment_view.creator.local).toBe(true); expect(mentionRes.comment_view.comment.score).toBe(1); // get beta's localized copy of the alpha post let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post); if (!betaPost) { throw "unable to locate post on beta"; } expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id); expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name); // Make sure that both new comments are seen on beta and have parent/child relationship let betaPostComments = await waitUntil( () => getComments(beta, betaPost!.post.id), c => c.items[1]?.comment.score === 1, ); expect(betaPostComments.items.length).toEqual(2); // the trunk-branch root comment will be older than the mention reply comment, so index 1 let betaRootComment = betaPostComments.items[1]; // the trunk-branch root comment should not have a parent expect(getCommentParentId(betaRootComment.comment)).toBeUndefined(); expect(betaRootComment.comment.content).toBeDefined(); // the mention reply comment should have parent that points to the branch root level comment expect(getCommentParentId(betaPostComments.items[0].comment)).toBe( betaPostComments.items[1].comment.id, ); expect(betaRootComment.community.local).toBe(true); expect(betaRootComment.creator.local).toBe(false); expect(betaRootComment.comment.score).toBe(1); assertCommentFederation(betaRootComment, commentRes.comment_view); let mentionsRes = await waitUntil( () => listNotifications(beta, "mention"), m => !!m.items[0], ); const firstMention = mentionsRes.items[0]; let firstMentionData = firstMention.data as CommentView; expect(firstMentionData.comment!.content).toBeDefined(); expect(firstMentionData.community!.local).toBe(true); expect(firstMentionData.creator.local).toBe(false); expect(firstMentionData.comment!.score).toBe(1); // the reply comment with mention should be the most fresh, newest, index 0 expect(firstMentionData.comment!.id).toBe( betaPostComments.items[0].comment.id, ); }); test("Comment Search", async () => { let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id); let betaComment = await resolveComment(beta, commentRes.comment_view.comment); assertCommentFederation(betaComment, commentRes.comment_view); }); test("A and G subscribe to B (center) A posts, G mentions B, it gets announced to A", async () => { // Create a local post let alphaCommunity = await resolveCommunity(alpha, "!main@lemmy-alpha:8541"); if (!alphaCommunity) { throw "Missing alpha community"; } // follow community from beta so that it accepts the mention let betaCommunity = await resolveCommunity( beta, alphaCommunity.community.ap_id, ); await followCommunity(beta, true, betaCommunity!.community.id); let alphaPost = await createPost(alpha, alphaCommunity.community.id); expect(alphaPost.post_view.community.local).toBe(true); // Make sure gamma sees it let gammaPost = await resolvePost(gamma, alphaPost.post_view.post); if (!gammaPost) { throw "Missing gamma post"; } let commentContent = "A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8551"; let commentRes = await createComment( gamma, gammaPost.post.id, undefined, commentContent, ); expect(commentRes.comment_view.comment.content).toBe(commentContent); expect(commentRes.comment_view.community.local).toBe(false); expect(commentRes.comment_view.creator.local).toBe(true); expect(commentRes.comment_view.comment.score).toBe(1); // Make sure alpha sees it let alphaPostComments2 = await waitUntil( () => getComments(alpha, alphaPost.post_view.post.id), e => e.items[0]?.comment.score === 1, ); expect(alphaPostComments2.items[0].comment.content).toBe(commentContent); expect(alphaPostComments2.items[0].community.local).toBe(true); expect(alphaPostComments2.items[0].creator.local).toBe(false); expect(alphaPostComments2.items[0].comment.score).toBe(1); assertCommentFederation(alphaPostComments2.items[0], commentRes.comment_view); // Make sure beta has mentions let relevantMention = await waitUntil( () => listNotifications(beta, "mention").then(m => m.items.find(m => { let data = m.data as CommentView; return ( m.notification.kind == "mention" && data.comment.ap_id === commentRes.comment_view.comment.ap_id ); }), ), e => !!e, ); if (!relevantMention) throw Error("could not find mention"); let relevantMentionData = relevantMention.data as CommentView; expect(relevantMentionData.comment!.content).toBe(commentContent); expect(relevantMentionData.community!.local).toBe(false); expect(relevantMentionData.creator.local).toBe(false); // TODO this is failing because fetchInReplyTos aren't getting score // expect(mentionsRes.mentions[0].score).toBe(1); }); test("Check that activity from another instance is sent to third instance", async () => { // Alpha and gamma users follow beta community let alphaFollow = await followBeta(alpha); expect(alphaFollow.community_view.community.local).toBe(false); expect(alphaFollow.community_view.community.name).toBe("main"); let gammaFollow = await followBeta(gamma); expect(gammaFollow.community_view.community.local).toBe(false); expect(gammaFollow.community_view.community.name).toBe("main"); await waitUntil( () => resolveBetaCommunity(alpha), c => c?.community_actions?.follow_state === "accepted", ); await waitUntil( () => resolveBetaCommunity(gamma), c => c?.community_actions?.follow_state === "accepted", ); // Create a post on beta let betaPost = await createPost(beta, 2); expect(betaPost.post_view.community.local).toBe(true); // Make sure gamma and alpha see it let gammaPost = await waitForPost(gamma, betaPost.post_view.post); if (!gammaPost) { throw "Missing gamma post"; } expect(gammaPost.post).toBeDefined(); let alphaPost = await waitForPost(alpha, betaPost.post_view.post); if (!alphaPost) { throw "Missing alpha post"; } expect(alphaPost.post).toBeDefined(); // The bug: gamma comments, and alpha should see it. let commentContent = "Comment from gamma"; let commentRes = await createComment( gamma, gammaPost.post.id, undefined, commentContent, ); expect(commentRes.comment_view.comment.content).toBe(commentContent); expect(commentRes.comment_view.community.local).toBe(false); expect(commentRes.comment_view.creator.local).toBe(true); expect(commentRes.comment_view.comment.score).toBe(1); // Make sure alpha sees it let alphaPostComments2 = await waitUntil( () => getComments(alpha, alphaPost!.post.id), e => e.items[0]?.comment.score === 1, ); expect(alphaPostComments2.items[0].comment.content).toBe(commentContent); expect(alphaPostComments2.items[0].community.local).toBe(false); expect(alphaPostComments2.items[0].creator.local).toBe(false); expect(alphaPostComments2.items[0].comment.score).toBe(1); assertCommentFederation(alphaPostComments2.items[0], commentRes.comment_view); await Promise.allSettled([unfollowRemotes(alpha), unfollowRemotes(gamma)]); }); test("Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.", async () => { // Unfollow all remote communities let my_user = await unfollowRemotes(alpha); expect(my_user.follows.filter(c => c.community.local == false).length).toBe( 0, ); // B creates a post, and two comments, should be invisible to A let postOnBetaRes = await createPost(beta, 2); expect(postOnBetaRes.post_view.post.name).toBeDefined(); let parentCommentContent = "An invisible top level comment from beta"; let parentCommentRes = await createComment( beta, postOnBetaRes.post_view.post.id, undefined, parentCommentContent, ); expect(parentCommentRes.comment_view.comment.content).toBe( parentCommentContent, ); // B creates a comment, then a child one of that. let childCommentContent = "An invisible child comment from beta"; let childCommentRes = await createComment( beta, postOnBetaRes.post_view.post.id, parentCommentRes.comment_view.comment.id, childCommentContent, ); expect(childCommentRes.comment_view.comment.content).toBe( childCommentContent, ); // Follow beta again let follow = await followBeta(alpha); expect(follow.community_view.community.local).toBe(false); expect(follow.community_view.community.name).toBe("main"); // An update to the child comment on beta, should push the post, parent, and child to alpha now let updatedCommentContent = "An update child comment from beta"; let updateRes = await editComment( beta, childCommentRes.comment_view.comment.id, updatedCommentContent, ); expect(updateRes.comment_view.comment.content).toBe(updatedCommentContent); // Get the post from alpha let alphaPostB = await waitForPost(alpha, postOnBetaRes.post_view.post); if (!alphaPostB) { throw "Missing alpha post B"; } let alphaPost = await getPost(alpha, alphaPostB.post.id); let alphaPostComments = await waitUntil( () => getComments(alpha, alphaPostB!.post.id), c => c.items[1]?.comment.content === parentCommentRes.comment_view.comment.content && c.items[0]?.comment.content === updateRes.comment_view.comment.content, ); expect(alphaPost.post_view.post.name).toBeDefined(); assertCommentFederation( alphaPostComments.items[1], parentCommentRes.comment_view, ); assertCommentFederation(alphaPostComments.items[0], updateRes.comment_view); expect(alphaPost.post_view.community.local).toBe(false); expect(alphaPost.post_view.creator.local).toBe(false); await unfollowRemotes(alpha); }); test("Report a comment", async () => { let betaCommunity = await resolveBetaCommunity(beta); if (!betaCommunity) { throw "Missing beta community"; } let postOnBetaRes = (await createPost(beta, betaCommunity.community.id)) .post_view.post; expect(postOnBetaRes).toBeDefined(); let commentRes = (await createComment(beta, postOnBetaRes.id)).comment_view .comment; expect(commentRes).toBeDefined(); let alphaComment = await resolveComment(alpha, commentRes); if (!alphaComment) { throw "Missing alpha comment"; } const reason = randomString(10); let alphaReport = ( await reportComment(alpha, alphaComment.comment.id, reason) ).comment_report_view.comment_report; let betaReport = ( (await waitUntil( () => listReports(beta).then(p => p.items.find(r => { return checkCommentReportReason(r, reason); }), ), e => !!e, )!) as CommentReportView ).comment_report; expect(betaReport).toBeDefined(); expect(betaReport.resolved).toBe(false); expect(betaReport.original_comment_text).toBe( alphaReport.original_comment_text, ); expect(betaReport.reason).toBe(alphaReport.reason); }); test("Dont send a comment reply to a blocked community", async () => { await beta.markAllNotificationsAsRead(); let newCommunity = await createCommunity(beta); let newCommunityId = newCommunity.community_view.community.id; // Create a post on beta let betaPost = await createPost(beta, newCommunityId); let alphaPost = await resolvePost(alpha, betaPost.post_view.post); if (!alphaPost) { throw "unable to locate post on alpha"; } // Check beta's inbox count let unreadCount = await getUnreadCounts(beta); expect(unreadCount.notification_count).toBe(0); // Beta blocks the new beta community let blockRes = await blockCommunity(beta, newCommunityId, true); expect(blockRes.community_view.community_actions?.blocked_at).toBeDefined(); // Alpha creates a comment let commentRes = await createComment(alpha, alphaPost.post.id); expect(commentRes.comment_view.comment.content).toBeDefined(); let alphaComment = await resolveComment( beta, commentRes.comment_view.comment, ); if (!alphaComment) { throw "Missing alpha comment before block"; } // Check beta's inbox count, make sure it stays the same unreadCount = await getUnreadCounts(beta); expect(unreadCount.notification_count).toBe(0); let replies = await listNotifications(beta, "reply", true); expect(replies.items.length).toBe(0); // Unblock the community blockRes = await blockCommunity(beta, newCommunityId, false); expect(blockRes.community_view.community_actions?.blocked_at).toBeUndefined(); }); /// Fetching a deeply nested comment can lead to stack overflow as all parent comments are also /// fetched recursively. Ensure that it works properly. test("Fetch a deeply nested comment", async () => { const alphaCommunity = await resolveCommunity( alpha, "!main@lemmy-alpha:8541", ); if (!alphaCommunity) { throw "Missing alpha community"; } const postOnAlphaRes = await createPost(alpha, alphaCommunity.community.id); let lastComment; for (let i = 1; i < 50; i++) { let commentRes = await createComment( alpha, postOnAlphaRes.post_view.post.id, lastComment?.comment_view.comment.id, ); expect(commentRes.comment_view.comment).toBeDefined(); lastComment = commentRes; } let betaComment = await resolveComment( beta, lastComment!.comment_view.comment, ); expect(betaComment?.comment).toBeDefined(); expect(betaComment?.post).toBeDefined(); }); test("Distinguish comment", async () => { const community = (await resolveBetaCommunity(beta))?.community; let post = await createPost(beta, community!.id); let commentRes = await createComment(beta, post.post_view.post.id); const form: DistinguishComment = { comment_id: commentRes.comment_view.comment.id, distinguished: true, }; await beta.distinguishComment(form); let alphaPost = await resolvePost(alpha, post.post_view.post); // Find the comment on alpha (home of community) let alphaComments = await waitUntil( () => getComments(alpha, alphaPost?.post.id), c => c.items[0].comment.distinguished, ); assertCommentFederation(alphaComments.items[0], commentRes.comment_view); }); test("Lock comment", async () => { let newBetaApi = await registerUser(beta, betaUrl); const alphaCommunity = await resolveCommunity( alpha, "!main@lemmy-alpha:8541", ); if (!alphaCommunity) { throw "Missing alpha community"; } let post = await createPost(alpha, alphaCommunity.community.id); let betaPost = await resolvePost(beta, post.post_view.post); if (!betaPost) { throw "unable to locate post on beta"; } // Create a comment hierarchy like this: // 1 // | \ // 2 4 // | // 3 let comment1 = await createComment(alpha, post.post_view.post.id); let betaComment1 = await resolveComment(beta, comment1.comment_view.comment); if (!betaComment1) { throw "unable to locate comment on beta"; } await followCommunity(newBetaApi, true, betaComment1!.community.id); let comment2 = await createComment( alpha, post.post_view.post.id, comment1.comment_view.comment.id, ); let betaComment2 = await resolveComment(beta, comment2.comment_view.comment); if (!betaComment2) { throw "unable to locate comment on beta"; } let comment3 = await createComment( newBetaApi, betaPost.post.id, betaComment2.comment.id, ); // Lock comment2 and wait for it to federate await lockComment(alpha, true, comment2.comment_view.comment); const comment_ap_id = comment3.comment_view.comment.ap_id; await waitUntil( () => getComments(newBetaApi, betaPost.post.id), c => { const find = c.items.find(c => c.comment.ap_id == comment_ap_id); return find?.comment.locked ?? false; }, ); // Make sure newBeta can't respond to comment3 await jestLemmyError( () => createComment( newBetaApi, betaPost.post.id, comment3.comment_view.comment.id, ), new LemmyError("locked", statusBadRequest), ); // newBeta should still be able to respond to comment1 expect( await createComment(newBetaApi, betaPost.post.id, betaComment1.comment.id), ).toBeDefined(); }); test("Remove children", async () => { const alphaCommunity = await resolveCommunity( alpha, "!main@lemmy-alpha:8541", ); if (!alphaCommunity) { throw "Missing alpha community"; } let post = await createPost(alpha, alphaCommunity.community.id); let betaPost = await resolvePost(beta, post.post_view.post); if (!betaPost) { throw "unable to locate post on beta"; } await followCommunity(beta, true, betaPost.community.id); let comment1 = await createComment(beta, betaPost.post.id); let comment2 = await createComment( beta, betaPost.post.id, comment1.comment_view.comment.id, ); await createComment(beta, betaPost.post.id, comment2.comment_view.comment.id); await createComment(beta, betaPost.post.id, comment1.comment_view.comment.id); // Wait until the comments have federated await waitUntil( () => getPost(alpha, post.post_view.post.id), p => p.post_view.post.comments == 4, ); let commentOnAlpha = await resolveComment( alpha, comment1.comment_view.comment, ); if (!commentOnAlpha) { throw "unable to locate comment on alpha"; } await removeComment(alpha, true, commentOnAlpha.comment.id, true); let post2 = await getPost(alpha, post.post_view.post.id); expect(post2.post_view.post.comments).toBe(0); // Wait until the remove has federated await waitUntil( () => getComment(beta, comment1.comment_view.comment.id), c => c.comment_view.comment.removed, ); // Make sure removal federates properly let betaPost2 = await resolvePost(beta, post.post_view.post); if (!betaPost2) { throw "unable to locate post on beta"; } expect(betaPost2.post.comments).toBe(0); }); function checkCommentReportReason(rcv: ReportCombinedView, reason: string) { switch (rcv.type_) { case "comment": return rcv.comment_report.reason === reason; default: return false; } } ================================================ FILE: api_tests/src/community.spec.ts ================================================ jest.setTimeout(120000); import { AddModToCommunity } from "lemmy-js-client/dist/types/AddModToCommunity"; import { alpha, beta, gamma, setupLogins, resolveCommunity, createCommunity, deleteCommunity, removeCommunity, getCommunity, followCommunity, banPersonFromCommunity, resolvePerson, createPost, getPost, resolvePost, registerUser, getPosts, getComments, createComment, getCommunityByName, waitUntil, alphaUrl, delta, editCommunity, unfollows, getMyUser, userBlockInstanceCommunities, resolveBetaCommunity, reportCommunity, randomString, assertCommunityFederation, listReports, statusBadRequest, jestLemmyError, } from "./shared"; import { AdminAllowInstanceParams } from "lemmy-js-client/dist/types/AdminAllowInstanceParams"; import { CommunityReport, CommunityReportView, EditCommunity, FollowMultiCommunity, GetPosts, LemmyError, MultiCommunityView, ReportCombinedView, ResolveCommunityReport, Search, } from "lemmy-js-client"; beforeAll(setupLogins); afterAll(unfollows); test("Create community", async () => { let communityRes = await createCommunity(alpha); expect(communityRes.community_view.community.name).toBeDefined(); // A dupe check let prevName = communityRes.community_view.community.name; await jestLemmyError( () => createCommunity(alpha, prevName), new LemmyError("already_exists", statusBadRequest), ); // Cache the community on beta, make sure it has the other fields let searchShort = `!${prevName}@lemmy-alpha:8541`; let betaCommunity = await resolveCommunity(beta, searchShort); assertCommunityFederation(betaCommunity, communityRes.community_view); }); test("Delete community", async () => { let communityRes = await createCommunity(beta); // Cache the community on Alpha let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`; let alphaCommunity = await resolveCommunity(alpha, searchShort); if (!alphaCommunity) { throw "Missing alpha community"; } assertCommunityFederation(alphaCommunity, communityRes.community_view); // Follow the community from alpha let follow = await followCommunity(alpha, true, alphaCommunity.community.id); // Make sure the follow response went through expect(follow.community_view.community.local).toBe(false); let deleteCommunityRes = await deleteCommunity( beta, true, communityRes.community_view.community.id, ); expect(deleteCommunityRes.community_view.community.deleted).toBe(true); expect(deleteCommunityRes.community_view.community.title).toBe( communityRes.community_view.community.title, ); // Make sure it got deleted on A let communityOnAlphaDeleted = await waitUntil( () => getCommunity(alpha, alphaCommunity!.community.id), g => g.community_view.community.deleted, ); expect(communityOnAlphaDeleted.community_view.community.deleted).toBe(true); // Undelete let undeleteCommunityRes = await deleteCommunity( beta, false, communityRes.community_view.community.id, ); expect(undeleteCommunityRes.community_view.community.deleted).toBe(false); // Make sure it got undeleted on A let communityOnAlphaUnDeleted = await waitUntil( () => getCommunity(alpha, alphaCommunity!.community.id), g => !g.community_view.community.deleted, ); expect(communityOnAlphaUnDeleted.community_view.community.deleted).toBe( false, ); }); test("Remove community", async () => { let communityRes = await createCommunity(beta); // Cache the community on Alpha let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`; let alphaCommunity = await resolveCommunity(alpha, searchShort); if (!alphaCommunity) { throw "Missing alpha community"; } assertCommunityFederation(alphaCommunity, communityRes.community_view); // Follow the community from alpha let follow = await followCommunity(alpha, true, alphaCommunity.community.id); // Make sure the follow response went through expect(follow.community_view.community.local).toBe(false); let removeCommunityRes = await removeCommunity( beta, true, communityRes.community_view.community.id, ); expect(removeCommunityRes.community_view.community.removed).toBe(true); expect(removeCommunityRes.community_view.community.title).toBe( communityRes.community_view.community.title, ); // Make sure it got Removed on A let communityOnAlphaRemoved = await waitUntil( () => getCommunity(alpha, alphaCommunity!.community.id), g => g.community_view.community.removed, ); expect(communityOnAlphaRemoved.community_view.community.removed).toBe(true); // unremove let unremoveCommunityRes = await removeCommunity( beta, false, communityRes.community_view.community.id, ); expect(unremoveCommunityRes.community_view.community.removed).toBe(false); // Make sure it got undeleted on A let communityOnAlphaUnRemoved = await waitUntil( () => getCommunity(alpha, alphaCommunity!.community.id), g => !g.community_view.community.removed, ); expect(communityOnAlphaUnRemoved.community_view.community.removed).toBe( false, ); }); test("Report a community", async () => { // Create community on alpha let alphaCommunity = await createCommunity(alpha); expect(alphaCommunity.community_view.community).toBeDefined(); // Send report from beta let betaCommunity = await resolveCommunity( beta, alphaCommunity.community_view.community.ap_id, ); let betaReport = ( await reportCommunity(beta, betaCommunity!.community.id, randomString(10)) ).community_report_view.community_report; expect(betaReport).toBeDefined(); // Report was federated to alpha let alphaReport = ( (await waitUntil( () => listReports(alpha).then(p => p.items.find(r => { return checkCommunityReportName(r, betaReport); }), ), res => !!res, ))! as CommunityReportView ).community_report; expect(alphaReport).toBeDefined(); expect(alphaReport.resolved).toBe(false); expect(alphaReport.original_community_name).toBe( betaReport.original_community_name, ); expect(alphaReport.original_community_title).toBe( betaReport.original_community_title, ); expect(alphaReport.original_community_banner).toBe( betaReport.original_community_banner, ); expect(alphaReport.original_community_sidebar).toBe( betaReport.original_community_sidebar, ); expect(alphaReport.original_community_icon).toBe( betaReport.original_community_icon, ); expect(alphaReport.original_community_sidebar).toBe( betaReport.original_community_sidebar, ); expect(alphaReport.reason).toBe(betaReport.reason); // Resolve report as admin of the community's instance let resolveParams: ResolveCommunityReport = { report_id: alphaReport.id, resolved: true, }; let resolve = await alpha.resolveCommunityReport(resolveParams); expect(resolve.community_report_view.community_report.resolved).toBeTruthy(); // Report should be marked resolved on reporter's instance let resolvedReport = ( (await waitUntil( () => listReports(beta).then(p => p.items.find(r => { return ( checkCommunityReportName(r, alphaReport) && r.resolver != null ); }), ), res => !!res, ))! as CommunityReportView ).community_report; expect(resolvedReport).toBeDefined(); expect(resolvedReport.resolved).toBe(true); }); test("Search for beta community", async () => { let communityRes = await createCommunity(beta); expect(communityRes.community_view.community.name).toBeDefined(); let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`; let alphaCommunity = await resolveCommunity(alpha, searchShort); assertCommunityFederation(alphaCommunity, communityRes.community_view); }); test("Admin actions in remote community are not federated to origin", async () => { // create a community on alpha let communityRes = (await createCommunity(alpha)).community_view; expect(communityRes.community.name).toBeDefined(); // gamma follows community and posts in it let gammaCommunity = await resolveCommunity( gamma, communityRes.community.ap_id, ); if (!gammaCommunity) { throw "Missing gamma community"; } await followCommunity(gamma, true, gammaCommunity.community.id); gammaCommunity = await waitUntil( () => resolveCommunity(gamma, communityRes.community.ap_id), g => g?.community_actions?.follow_state == "accepted", ); if (!gammaCommunity) { throw "Missing gamma community"; } expect(gammaCommunity.community_actions?.follow_state).toBe("accepted"); let gammaPost = (await createPost(gamma, gammaCommunity.community.id)) .post_view; expect(gammaPost.post.id).toBeDefined(); expect(gammaPost.creator_banned_from_community).toBe(false); // admin of beta decides to ban gamma from community let betaCommunity = await resolveCommunity( beta, communityRes.community.ap_id, ); if (!betaCommunity) { throw "Missing beta community"; } let bannedUserInfo1 = (await getMyUser(gamma)).local_user_view.person; if (!bannedUserInfo1) { throw "Missing banned user 1"; } let bannedUserInfo2 = await resolvePerson(beta, bannedUserInfo1.ap_id); if (!bannedUserInfo2) { throw "Missing banned user 2"; } let banRes = await banPersonFromCommunity( beta, bannedUserInfo2.person.id, betaCommunity.community.id, true, true, ); expect(banRes).toBeDefined(); // ban doesn't federate to community's origin instance alpha let alphaPost = await resolvePost(alpha, gammaPost.post); expect(alphaPost?.creator_banned_from_community).toBe(false); // and neither to gamma let gammaPost2 = await getPost(gamma, gammaPost.post.id); expect(gammaPost2.post_view.creator_banned_from_community).toBe(false); }); test("moderator view", async () => { // register a new user with their own community on alpha and post to it let otherUser = await registerUser(alpha, alphaUrl); let otherCommunity = (await createCommunity(otherUser)).community_view; expect(otherCommunity.community.name).toBeDefined(); let otherPost = (await createPost(otherUser, otherCommunity.community.id)) .post_view; expect(otherPost.post.id).toBeDefined(); let otherComment = (await createComment(otherUser, otherPost.post.id)) .comment_view; expect(otherComment.comment.id).toBeDefined(); // create a community and post on alpha let alphaCommunity = (await createCommunity(alpha)).community_view; expect(alphaCommunity.community.name).toBeDefined(); let alphaPost = (await createPost(alpha, alphaCommunity.community.id)) .post_view; expect(alphaPost.post.id).toBeDefined(); let alphaComment = (await createComment(otherUser, alphaPost.post.id)) .comment_view; expect(alphaComment.comment.id).toBeDefined(); // other user also posts on alpha's community let otherAlphaPost = ( await createPost(otherUser, alphaCommunity.community.id) ).post_view; expect(otherAlphaPost.post.id).toBeDefined(); let otherAlphaComment = ( await createComment(otherUser, otherAlphaPost.post.id) ).comment_view; expect(otherAlphaComment.comment.id).toBeDefined(); // alpha lists posts and comments on home page, should contain all posts that were made let posts = (await getPosts(alpha, "all")).items; expect(posts).toBeDefined(); let postIds = posts.map(post => post.post.id); let comments = (await getComments(alpha, undefined, "all")).items; expect(comments).toBeDefined(); let commentIds = comments.map(comment => comment.comment.id); expect(postIds).toContain(otherPost.post.id); expect(commentIds).toContain(otherComment.comment.id); expect(postIds).toContain(alphaPost.post.id); expect(commentIds).toContain(alphaComment.comment.id); expect(postIds).toContain(otherAlphaPost.post.id); expect(commentIds).toContain(otherAlphaComment.comment.id); // in moderator view, alpha should not see otherPost, wich was posted on a community alpha doesn't moderate posts = (await getPosts(alpha, "moderator_view")).items; expect(posts).toBeDefined(); postIds = posts.map(post => post.post.id); comments = (await getComments(alpha, undefined, "moderator_view")).items; expect(comments).toBeDefined(); commentIds = comments.map(comment => comment.comment.id); expect(postIds).not.toContain(otherPost.post.id); expect(commentIds).not.toContain(otherComment.comment.id); expect(postIds).toContain(alphaPost.post.id); expect(commentIds).toContain(alphaComment.comment.id); expect(postIds).toContain(otherAlphaPost.post.id); expect(commentIds).toContain(otherAlphaComment.comment.id); }); test("Get community for different casing on domain", async () => { let communityRes = await createCommunity(alpha); expect(communityRes.community_view.community.name).toBeDefined(); // A dupe check let prevName = communityRes.community_view.community.name; await jestLemmyError( () => createCommunity(alpha, prevName), new LemmyError("already_exists", statusBadRequest), ); // Cache the community on beta, make sure it has the other fields let communityName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`; let betaCommunity = (await getCommunityByName(beta, communityName)) .community_view; assertCommunityFederation(betaCommunity, communityRes.community_view); }); test("User blocks instance, communities are hidden", async () => { // create community and post on beta let communityRes = await createCommunity(beta); expect(communityRes.community_view.community.name).toBeDefined(); let postRes = await createPost( beta, communityRes.community_view.community.id, ); expect(postRes.post_view.post.id).toBeDefined(); // fetch post to alpha let alphaPost = await resolvePost(alpha, postRes.post_view.post); expect(alphaPost?.post).toBeDefined(); // post should be included in listing let listing = await getPosts(alpha, "all"); let listing_ids = listing.items.map(p => p.post.ap_id); expect(listing_ids).toContain(postRes.post_view.post.ap_id); // block the beta instance await userBlockInstanceCommunities( alpha, alphaPost!.community.instance_id, true, ); // after blocking, post should not be in listing let listing2 = await getPosts(alpha, "all"); let listing_ids2 = listing2.items.map(p => p.post.ap_id); expect(listing_ids2.indexOf(postRes.post_view.post.ap_id)).toBe(-1); // unblock instance again await userBlockInstanceCommunities( alpha, alphaPost!.community.instance_id, false, ); // post should be included in listing let listing3 = await getPosts(alpha, "all"); let listing_ids3 = listing3.items.map(p => p.post.ap_id); expect(listing_ids3).toContain(postRes.post_view.post.ap_id); }); // TODO: this test keeps failing randomly in CI test.skip("Community follower count is federated", async () => { // Follow the beta community from alpha let community = await createCommunity(beta); let communityActorId = community.community_view.community.ap_id; let resolved = await resolveCommunity(alpha, communityActorId); if (!resolved?.community) { throw "Missing beta community"; } await followCommunity(alpha, true, resolved.community.id); let followed = await waitUntil( () => resolveCommunity(alpha, communityActorId), c => c?.community_actions?.follow_state == "accepted", ); // Make sure there is 1 subscriber expect(followed?.community.subscribers).toBe(1); // Follow the community from gamma resolved = await resolveCommunity(gamma, communityActorId); if (!resolved?.community) { throw "Missing beta community"; } await followCommunity(gamma, true, resolved.community.id); followed = await waitUntil( () => resolveCommunity(gamma, communityActorId), c => c?.community_actions?.follow_state == "accepted", ); // Make sure there are 2 subscribers expect(followed?.community?.subscribers).toBe(2); // Follow the community from delta resolved = await resolveCommunity(delta, communityActorId); if (!resolved?.community) { throw "Missing beta community"; } await followCommunity(delta, true, resolved.community.id); followed = await waitUntil( () => resolveCommunity(delta, communityActorId), c => c?.community_actions?.follow_state == "accepted", ); }); test("Dont receive community activities after unsubscribe", async () => { let communityRes = await createCommunity(alpha); expect(communityRes.community_view.community.name).toBeDefined(); expect(communityRes.community_view.community.subscribers).toBe(1); let betaCommunity = await resolveCommunity( beta, communityRes.community_view.community.ap_id, ); assertCommunityFederation(betaCommunity, communityRes.community_view); // follow alpha community from beta await followCommunity(beta, true, betaCommunity!.community.id); // ensure that follower count was updated let communityRes1 = await getCommunity( alpha, communityRes.community_view.community.id, ); expect(communityRes1.community_view.community.subscribers).toBe(2); // temporarily block alpha, so that it doesn't know about unfollow let allow_instance_params: AdminAllowInstanceParams = { instance: "lemmy-alpha", allow: false, reason: "allow", }; await beta.adminAllowInstance(allow_instance_params); // unfollow await followCommunity(beta, false, betaCommunity!.community.id); // ensure that alpha still sees beta as follower let communityRes2 = await getCommunity( alpha, communityRes.community_view.community.id, ); expect(communityRes2.community_view.community.subscribers).toBe(2); // unblock alpha allow_instance_params.allow = true; await beta.adminAllowInstance(allow_instance_params); // create a post, it shouldnt reach beta let postRes = await createPost( alpha, communityRes.community_view.community.id, ); expect(postRes.post_view.post.id).toBeDefined(); // await longDelay(); let form: Search = { q: postRes.post_view.post.name, type_: "posts", listing_type: "all", }; let res = await beta.search(form); expect(res.search.length).toBe(0); }); test("Fetch community, includes posts", async () => { let communityRes = await createCommunity(alpha); expect(communityRes.community_view.community.name).toBeDefined(); expect(communityRes.community_view.community.subscribers).toBe(1); let postRes = await createPost( alpha, communityRes.community_view.community.id, ); expect(postRes.post_view.post).toBeDefined(); let resolvedCommunity = await waitUntil( () => resolveCommunity(beta, communityRes.community_view.community.ap_id), c => c?.community.id != undefined, ); let betaCommunity = resolvedCommunity; expect(betaCommunity?.community.ap_id).toBe( communityRes.community_view.community.ap_id, ); let post_listing = await waitUntil( () => getPosts(beta, "all", betaCommunity?.community.id), p => p.items.length == 1, ); expect(post_listing.items[0].post.ap_id).toBe(postRes.post_view.post.ap_id); }); test("Content in local-only community doesn't federate", async () => { // create a community and set it local-only let communityRes = (await createCommunity(alpha)).community_view.community; let form: EditCommunity = { community_id: communityRes.id, visibility: "local_only_public", }; await editCommunity(alpha, form); // cant resolve the community from another instance await jestLemmyError( () => resolveCommunity(beta, communityRes.ap_id), new LemmyError("resolve_object_failed", statusBadRequest), false, ); // create a post, also cant resolve it let postRes = await createPost(alpha, communityRes.id); await jestLemmyError( () => resolvePost(beta, postRes.post_view.post), new LemmyError("resolve_object_failed", statusBadRequest), false, ); }); test("Remote mods can edit communities", async () => { let communityRes = await createCommunity(alpha); let betaCommunity = await resolveCommunity( beta, communityRes.community_view.community.ap_id, ); if (!betaCommunity?.community) { throw "Missing beta community"; } let betaOnAlpha = await resolvePerson(alpha, "lemmy_beta@lemmy-beta:8551"); let form: AddModToCommunity = { community_id: communityRes.community_view.community.id, person_id: betaOnAlpha?.person.id as number, added: true, }; alpha.addModToCommunity(form); let form2: EditCommunity = { community_id: betaCommunity.community.id as number, sidebar: "Example sidebar", }; await editCommunity(beta, form2); const communityId = communityRes.community_view.community.id; await waitUntil( () => getCommunity(alpha, communityId), c => c.community_view.community.sidebar == "Example sidebar", ); }); test("Remote mods can add mods", async () => { let alphaCommunity = await createCommunity(alpha); let betaCommunity = await resolveCommunity( beta, alphaCommunity.community_view.community.ap_id, ); if (!betaCommunity?.community) { throw "Missing beta community"; } let betaOnAlpha = await resolvePerson(alpha, "lemmy_beta@lemmy-beta:8551"); let gammaOnBeta = await resolvePerson(beta, "lemmy_gamma@lemmy-gamma:8561"); // Follow so we get activities await followCommunity(beta, true, betaCommunity.community.id); let form: AddModToCommunity = { community_id: alphaCommunity.community_view.community.id, person_id: betaOnAlpha?.person.id as number, added: true, }; await alpha.addModToCommunity(form); await waitUntil( () => getCommunity(beta, betaCommunity.community.id), c => c.moderators.length == 2, ); let form2: AddModToCommunity = { community_id: betaCommunity.community.id, person_id: gammaOnBeta?.person.id as number, added: true, }; await beta.addModToCommunity(form2); await waitUntil( () => getCommunity(beta, betaCommunity.community.id), c => c.moderators.length == 3, ); await waitUntil( () => getCommunity(alpha, alphaCommunity.community_view.community.id), c => c.moderators.length == 3, ); }); test("Community name with non-ascii chars", async () => { const name = "това_ме_ядосва" + Math.random().toString().slice(2, 6); let communityRes = await createCommunity(alpha, name); let betaCommunity1 = await resolveCommunity( beta, communityRes.community_view.community.ap_id, ); expect(betaCommunity1?.community.name).toBe(name); let alphaCommunity2 = await getCommunityByName(alpha, name); expect(alphaCommunity2.community_view.community.name).toBe(name); let fediName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`; let betaCommunity2 = await getCommunityByName(beta, fediName); expect(betaCommunity2.community_view.community.name).toBe(name); let postRes = await createPost(beta, betaCommunity1!.community.id); let form: GetPosts = { community_name: fediName, }; let posts = await beta.getPosts(form); expect(posts.items.length).toBe(1); expect(posts.items[0].post.name).toBe(postRes.post_view.post.name); }); test("Multi-community", async () => { // create multi const multiName = randomString(10); let res = await alpha.createMultiCommunity({ name: multiName }); let myUser = await getMyUser(alpha); expect(res.multi_community_view.multi.name).toBe(multiName); expect(res.multi_community_view.multi.ap_id).toBe( `http://lemmy-alpha:8541/m/${multiName}`, ); expect(res.multi_community_view.owner.id).toBe( myUser.local_user_view.person.id, ); // add initial community let community1 = (await createCommunity(alpha)).community_view.community; let entryRes = await alpha.createMultiCommunityEntry({ id: res.multi_community_view.multi.id, community_id: community1.id, }); expect(entryRes.community_view.community.id).toBe(community1.id); // resolve over federation let betaMulti = ( await beta.resolveObject({ q: res.multi_community_view.multi.ap_id }) ).resolve as MultiCommunityView; expect(betaMulti.multi.ap_id).toBe(res.multi_community_view.multi.ap_id); // follow multi over federation let form: FollowMultiCommunity = { multi_community_id: betaMulti.multi.id, follow: true, }; await beta.followMultiCommunity(form); let betaRes = await waitUntil( () => beta.getMultiCommunity({ id: betaMulti.multi.id }), m => m.communities.length >= 1, ); expect(betaRes.communities[0].community.ap_id).toBe(community1.ap_id); let followed = await waitUntil( () => beta.listMultiCommunities({}), m => m.items.length >= 1, ); expect(followed.items[0].multi.ap_id).toBe(betaMulti.multi.ap_id); // add community to multi let community2 = await waitUntil( () => resolveBetaCommunity(alpha), c => !!c?.community.instance_id, ); if (!community2) { throw "Missing beta community"; } let entryRes2 = await alpha.createMultiCommunityEntry({ id: res.multi_community_view.multi.id, community_id: community2!.community.id, }); expect(entryRes2.community_view.community.id).toBe(community2.community.id); // federated to beta betaRes = await waitUntil( () => beta.getMultiCommunity({ id: betaMulti.multi.id }), m => m.communities.length >= 2, ); let ap_ids = betaRes.communities.map(c => c.community.ap_id); expect(ap_ids.includes(community2!.community.ap_id)).toBeTruthy(); let post = await createPost(alpha, community2!.community.id); await waitUntil( () => beta.getPosts({ multi_community_id: betaRes.multi_community_view.multi.id, }), p => p.items.map(p => p.post.ap_id).includes(post.post_view.post.ap_id), ); }); test("Mark existing community as local-only, ensure it federates", async () => { let communityRes = await createCommunity(alpha); expect(communityRes.community_view.community.name).toBeDefined(); let community = communityRes.community_view.community; let betaCommunity = await resolveCommunity(beta, community.ap_id); assertCommunityFederation(betaCommunity, communityRes.community_view); await followCommunity(beta, true, betaCommunity!.community.id); await waitUntil( () => getCommunity(beta, betaCommunity!.community.id), g => g?.community_view.community_actions?.follow_state == "accepted", ); let res = await editCommunity(alpha, { community_id: community.id, visibility: "local_only_private", }); expect(res.community_view.community.visibility).toBe("local_only_private"); await waitUntil( () => getCommunity(beta, betaCommunity!.community.id), g => g?.community_view.community?.deleted, ); let res2 = await editCommunity(alpha, { community_id: community.id, visibility: "public", }); expect(res2.community_view.community.visibility).toBe("public"); await waitUntil( () => getCommunity(beta, betaCommunity!.community.id), g => !g?.community_view.community?.deleted, ); }); function checkCommunityReportName( rcv: ReportCombinedView, report: CommunityReport, ) { switch (rcv.type_) { case "community": return ( rcv.community_report.original_community_name === report.original_community_name ); default: return false; } } ================================================ FILE: api_tests/src/follow.spec.ts ================================================ jest.setTimeout(120000); import { alpha, setupLogins, resolveBetaCommunity, followCommunity, waitUntil, beta, betaUrl, registerUser, unfollows, getMyUser, alphaUrl, } from "./shared"; beforeAll(setupLogins); afterAll(unfollows); test("Follow local community", async () => { let user = await registerUser(beta, betaUrl); let community = await resolveBetaCommunity(user); let follow = await followCommunity(user, true, community!.community.id); // Make sure the follow response went through expect(follow.community_view.community.local).toBe(true); expect(follow.community_view.community_actions?.follow_state).toBe( "accepted", ); expect(follow.community_view.community.subscribers).toBe( community!.community.subscribers + 1, ); expect(follow.community_view.community.subscribers_local).toBe( community!.community.subscribers_local + 1, ); // Test an unfollow let unfollow = await followCommunity(user, false, community!.community.id); expect( unfollow.community_view.community_actions?.follow_state, ).toBeUndefined(); expect(unfollow.community_view.community.subscribers).toBe( community?.community.subscribers, ); expect(unfollow.community_view.community.subscribers_local).toBe( community?.community.subscribers_local, ); }); test("Follow federated community", async () => { let user = await registerUser(alpha, alphaUrl); const betaCommunityInitial = await waitUntil( () => resolveBetaCommunity(user), c => !!c?.community && c.community.subscribers >= 1, ); if (!betaCommunityInitial) { throw "Missing beta community"; } let follow = await followCommunity( user, true, betaCommunityInitial.community.id, ); expect(follow.community_view.community_actions?.follow_state).toBe("pending"); const betaCommunity = await waitUntil( () => resolveBetaCommunity(user), c => c?.community_actions?.follow_state === "accepted", ); // Make sure the follow response went through expect(betaCommunity?.community.local).toBe(false); expect(betaCommunity?.community.name).toBe("main"); expect(betaCommunity?.community_actions?.follow_state).toBe("accepted"); expect(betaCommunity?.community.subscribers_local).toBe( betaCommunityInitial.community.subscribers_local + 1, ); // check that unfollow was federated let communityOnBeta1 = await resolveBetaCommunity(beta); expect(communityOnBeta1?.community.subscribers).toBe( betaCommunityInitial.community.subscribers + 1, ); // Check it from local let my_user = await getMyUser(user); let remoteCommunityId = my_user?.follows.find( c => c.community.local == false && c.community.id === betaCommunityInitial.community.id, )?.community.id; expect(remoteCommunityId).toBeDefined(); if (!remoteCommunityId) { throw "Missing remote community id"; } // Test an unfollow let unfollow = await followCommunity(user, false, remoteCommunityId); expect( unfollow.community_view.community_actions?.follow_state, ).toBeUndefined(); // Make sure you are unsubbed locally let siteUnfollowCheck = await getMyUser(user); expect( siteUnfollowCheck.follows.find( c => c.community.id === betaCommunityInitial.community.id, ), ).toBe(undefined); // check that unfollow was federated let communityOnBeta2 = await waitUntil( () => resolveBetaCommunity(beta), c => c?.community.subscribers === betaCommunityInitial.community.subscribers, ); expect(communityOnBeta2?.community.subscribers).toBe( betaCommunityInitial.community.subscribers, ); expect(communityOnBeta2?.community.subscribers_local).toBe(1); }); ================================================ FILE: api_tests/src/image.spec.ts ================================================ jest.setTimeout(120000); import { UploadImage, PurgePerson, PurgePost, DeleteImageParams, } from "lemmy-js-client"; import { alpha, alphaImage, alphaUrl, beta, betaUrl, createCommunity, createPost, deleteAllMedia, epsilon, followCommunity, gamma, imageFetchLimit, registerUser, resolveBetaCommunity, resolveCommunity, resolvePost, setupLogins, waitForPost, unfollows, getPost, waitUntil, createPostWithThumbnail, sampleImage, sampleSite, getMyUser, } from "./shared"; beforeAll(setupLogins); afterAll(async () => { await Promise.allSettled([unfollows(), deleteAllMedia(alpha)]); }); test("Upload image and delete it", async () => { const health = await alpha.imageHealth(); expect(health.success).toBeTruthy(); // Upload test image. We use a simple string buffer as pictrs doesn't require an actual image // in testing mode. const upload_form: UploadImage = { image: Buffer.from("test"), }; const upload = await alphaImage.uploadImage(upload_form); expect(upload.image_url).toBeDefined(); expect(upload.filename).toBeDefined(); // ensure that image download is working. theres probably a better way to do this const response = await fetch(upload.image_url ?? ""); const content = await response.text(); expect(content.length).toBeGreaterThan(0); // Ensure that it comes back with the list_media endpoint const listMediaRes = await alphaImage.listMedia(); expect(listMediaRes.items.length).toBe(1); // Ensure that it also comes back with the admin all images const listMediaAdminRes = await alpha.listMediaAdmin({ limit: imageFetchLimit, }); // This number comes from all the previous thumbnails fetched in other tests. const previousThumbnails = 1; expect(listMediaAdminRes.items.length).toBe(previousThumbnails); // Make sure the uploader is correct expect(listMediaRes.items[0].person.ap_id).toBe( `http://lemmy-alpha:8541/u/lemmy_alpha`, ); // delete image const delete_form: DeleteImageParams = { filename: upload.filename, }; const delete_ = await alphaImage.deleteMedia(delete_form); expect(delete_.success).toBe(true); // ensure that image is deleted const response2 = await fetch(upload.image_url ?? ""); const content2 = await response2.text(); expect(content2).toBe(""); // Ensure that it shows the image is deleted const deletedListMediaRes = await alphaImage.listMedia(); expect(deletedListMediaRes.items.length).toBe(0); // Ensure that the admin shows its deleted const deletedListAllMediaRes = await alphaImage.listMediaAdmin({ limit: imageFetchLimit, }); expect(deletedListAllMediaRes.items.length).toBe(previousThumbnails - 1); }); test("Purge user, uploaded image removed", async () => { let user = await registerUser(alphaImage, alphaUrl); // upload test image const upload_form: UploadImage = { image: Buffer.from("test"), }; const upload = await user.uploadImage(upload_form); expect(upload.filename).toBeDefined(); expect(upload.image_url).toBeDefined(); // ensure that image download is working. theres probably a better way to do this const response = await fetch(upload.image_url ?? ""); const content = await response.text(); expect(content.length).toBeGreaterThan(0); // purge user let my_user = await getMyUser(user); const purgeForm: PurgePerson = { person_id: my_user.local_user_view.person.id, reason: "purge", }; const delete_ = await alphaImage.purgePerson(purgeForm); expect(delete_.success).toBe(true); // ensure that image is deleted const response2 = await fetch(upload.image_url ?? ""); const content2 = await response2.text(); expect(content2).toBe(""); }); test("Purge post, linked image removed", async () => { let user = await registerUser(beta, betaUrl); // upload test image const upload_form: UploadImage = { image: Buffer.from("test"), }; const upload = await user.uploadImage(upload_form); expect(upload.filename).toBeDefined(); expect(upload.image_url).toBeDefined(); // ensure that image download is working. theres probably a better way to do this const response = await fetch(upload.image_url ?? ""); const content = await response.text(); expect(content.length).toBeGreaterThan(0); let community = await resolveBetaCommunity(user); let post = await createPost(user, community!.community.id, upload.image_url); expect(post.post_view.post.url).toBe(upload.image_url); expect(post.post_view.image_details).toBeDefined(); // purge post const purgeForm: PurgePost = { post_id: post.post_view.post.id, reason: "purge", }; const delete_ = await beta.purgePost(purgeForm); expect(delete_.success).toBe(true); // ensure that image is deleted const response2 = await fetch(upload.image_url ?? ""); const content2 = await response2.text(); expect(content2).toBe(""); }); test("Images in remote image post are proxied if setting enabled", async () => { let community = await createCommunity(gamma); let postRes = await createPost( gamma, community.community_view.community.id, sampleImage, `![](${sampleImage})`, ); const post = postRes.post_view.post; expect(post).toBeDefined(); // Make sure it fetched the image details expect(postRes.post_view.image_details).toBeDefined(); // remote image gets proxied after upload expect( post.thumbnail_url?.startsWith( "http://lemmy-gamma:8561/api/v4/image/proxy?url", ), ).toBeTruthy(); expect( post.body?.startsWith("![](http://lemmy-gamma:8561/api/v4/image/proxy?url"), ).toBeTruthy(); // Make sure that it contains `jpg`, to be sure its an image expect(post.thumbnail_url?.includes(".jpg")).toBeTruthy(); let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post); expect(epsilonPostRes?.post).toBeDefined(); // Fetch the post again, the metadata should be backgrounded now // Wait for the metadata to get fetched, since this is backgrounded now let epsilonPostRes2 = await waitUntil( () => getPost(epsilon, epsilonPostRes!.post.id), p => p.post_view.post.thumbnail_url != undefined, ); const epsilonPost = epsilonPostRes2.post_view.post; expect( epsilonPost.thumbnail_url?.startsWith( "http://lemmy-epsilon:8581/api/v4/image/proxy?url", ), ).toBeTruthy(); expect( epsilonPost.body?.startsWith( "![](http://lemmy-epsilon:8581/api/v4/image/proxy?url", ), ).toBeTruthy(); // Make sure that it contains `jpg`, to be sure its an image expect(epsilonPost.thumbnail_url?.includes(".jpg")).toBeTruthy(); }); test("Thumbnail of remote image link is proxied if setting enabled", async () => { let community = await createCommunity(gamma); let postRes = await createPost( gamma, community.community_view.community.id, // The sample site metadata thumbnail ends in png sampleSite, ); const post = postRes.post_view.post; expect(post).toBeDefined(); // remote image gets proxied after upload expect( post.thumbnail_url?.startsWith( "http://lemmy-gamma:8561/api/v4/image/proxy?url", ), ).toBeTruthy(); // Make sure that it contains `png`, to be sure its an image expect(post.thumbnail_url?.includes(".png")).toBeTruthy(); let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post); expect(epsilonPostRes?.post).toBeDefined(); let epsilonPostRes2 = await waitUntil( () => getPost(epsilon, epsilonPostRes!.post.id), p => p.post_view.post.thumbnail_url != undefined, ); const epsilonPost = epsilonPostRes2.post_view.post; expect( epsilonPost.thumbnail_url?.startsWith( "http://lemmy-epsilon:8581/api/v4/image/proxy?url", ), ).toBeTruthy(); // Make sure that it contains `png`, to be sure its an image expect(epsilonPost.thumbnail_url?.includes(".png")).toBeTruthy(); }); test("No image proxying if setting is disabled", async () => { let user = await registerUser(beta, betaUrl); let community = await createCommunity(alpha); let betaCommunity = await resolveCommunity( beta, community.community_view.community.ap_id, ); await followCommunity(beta, true, betaCommunity!.community.id); const upload_form: UploadImage = { image: Buffer.from("test"), }; const upload = await user.uploadImage(upload_form); let post = await createPost( alpha, community.community_view.community.id, upload.image_url, `![](${sampleImage})`, ); expect(post.post_view.post).toBeDefined(); // remote image doesn't get proxied after upload expect( post.post_view.post.url?.startsWith("http://lemmy-beta:8551/api/v4/image/"), ).toBeTruthy(); expect(post.post_view.post.body).toBe(`![](${sampleImage})`); let betaPost = await waitForPost(beta, post.post_view.post, res => { return res?.post.alt_text != null; }); expect(betaPost.post).toBeDefined(); // remote image doesn't get proxied after federation expect( betaPost.post.url?.startsWith("http://lemmy-beta:8551/api/v4/image/"), ).toBeTruthy(); expect(betaPost.post.body).toBe(`![](${sampleImage})`); // Make sure the alt text got federated expect(post.post_view.post.alt_text).toBe(betaPost.post.alt_text); }); test("Make regular post, and give it a custom thumbnail", async () => { const uploadForm1: UploadImage = { image: Buffer.from("testRegular1"), }; const upload1 = await alphaImage.uploadImage(uploadForm1); const community = await createCommunity(alphaImage); // Use wikipedia since it has an opengraph image const wikipediaUrl = "https://wikipedia.org/"; let post = await createPostWithThumbnail( alphaImage, community.community_view.community.id, wikipediaUrl, upload1.image_url!, ); // Wait for the metadata to get fetched, since this is backgrounded now post = await waitUntil( () => getPost(alphaImage, post.post_view.post.id), p => p.post_view.post.thumbnail_url != undefined, ); expect(post.post_view.post.url).toBe(wikipediaUrl); // Make sure it uses custom thumbnail expect(post.post_view.post.thumbnail_url).toBe(upload1.image_url); }); test("Create an image post, and make sure a custom thumbnail doesn't overwrite it", async () => { const uploadForm1: UploadImage = { image: Buffer.from("test1"), }; const upload1 = await alphaImage.uploadImage(uploadForm1); const uploadForm2: UploadImage = { image: Buffer.from("test2"), }; const upload2 = await alphaImage.uploadImage(uploadForm2); const community = await createCommunity(alphaImage); let post = await createPostWithThumbnail( alphaImage, community.community_view.community.id, upload1.image_url!, upload2.image_url!, ); post = await waitUntil( () => getPost(alphaImage, post.post_view.post.id), p => p.post_view.post.thumbnail_url != undefined, ); expect(post.post_view.post.url).toBe(upload1.image_url); // Make sure the custom thumbnail is ignored expect(post.post_view.post.thumbnail_url == upload2.image_url).toBe(false); }); ================================================ FILE: api_tests/src/post.spec.ts ================================================ jest.setTimeout(120000); import { CommunityView } from "lemmy-js-client/dist/types/CommunityView"; import { alpha, beta, gamma, delta, epsilon, setupLogins, createPost, editPost, featurePost, lockPost, resolvePost, likePost, followBeta, resolveBetaCommunity, createComment, deletePost, removePost, getPost, unfollowRemotes, resolvePerson, banPersonFromSite, followCommunity, banPersonFromCommunity, reportPost, randomString, registerUser, unfollows, resolveCommunity, waitUntil, waitForPost, alphaUrl, loginUser, createCommunity, listReports, getMyUser, listNotifications, getModlog, statusNotFound, statusBadRequest, getSite, jestLemmyError, } from "./shared"; import { PostView } from "lemmy-js-client/dist/types/PostView"; import { AdminBlockInstanceParams } from "lemmy-js-client/dist/types/AdminBlockInstanceParams"; import { AddModToCommunity, EditSite, EditPost, PostReport, PostReportView, ReportCombinedView, ResolveObject, ResolvePostReport, LemmyError, } from "lemmy-js-client"; let betaCommunity: CommunityView | undefined; beforeAll(async () => { await setupLogins(); betaCommunity = await resolveBetaCommunity(alpha); expect(betaCommunity).toBeDefined(); // Hack: Force outgoing federation queue for beta to be created on epsilon, // otherwise report test fails let person = await resolvePerson(epsilon, "@lemmy_beta@lemmy-beta:8551"); expect(person?.person).toBeDefined(); }); afterAll(unfollows); async function assertPostFederation( postOne: PostView, postTwo: PostView, waitForMeta = true, ) { // Link metadata is generated in background task and may not be ready yet at this time, // so wait for it explicitly. For removed posts we cant refetch anything. if (waitForMeta) { postOne = await waitForPost(beta, postOne.post, res => { return res === null || !!res?.post.embed_title; }); postTwo = await waitForPost( beta, postTwo.post, res => res === null || !!res?.post.embed_title, ); } expect(postOne?.post.ap_id).toBe(postTwo?.post.ap_id); expect(postOne?.post.name).toBe(postTwo?.post.name); expect(postOne?.post.body).toBe(postTwo?.post.body); // TODO url clears arent working // expect(postOne?.post.url).toBe(postTwo?.post.url); expect(postOne?.post.nsfw).toBe(postTwo?.post.nsfw); expect(postOne?.post.embed_title).toBe(postTwo?.post.embed_title); expect(postOne?.post.embed_description).toBe(postTwo?.post.embed_description); expect(postOne?.post.embed_video_url).toBe(postTwo?.post.embed_video_url); expect(postOne?.post.published_at).toBe(postTwo?.post.published_at); expect(postOne?.community.ap_id).toBe(postTwo?.community.ap_id); expect(postOne?.post.locked).toBe(postTwo?.post.locked); expect(postOne?.post.removed).toBe(postTwo?.post.removed); expect(postOne?.post.deleted).toBe(postTwo?.post.deleted); } test("Create a post", async () => { // Block alpha let block_instance_params: AdminBlockInstanceParams = { instance: "lemmy-alpha", block: true, reason: "block", }; await epsilon.adminBlockInstance(block_instance_params); if (!betaCommunity) { throw "Missing beta community"; } let postRes = await createPost( alpha, betaCommunity.community.id, "https://example.com/", "აშშ ითხოვს ირანს დაუყოვნებლივ გაანთავისუფლოს დაკავებული ნავთობის ტანკერი", ); expect(postRes.post_view.post).toBeDefined(); expect(postRes.post_view.community.local).toBe(false); expect(postRes.post_view.creator.local).toBe(true); expect(postRes.post_view.post.score).toBe(1); // Make sure that post is liked on beta const betaPost = await waitForPost( beta, postRes.post_view.post, res => res?.post.score === 1, ); expect(betaPost).toBeDefined(); expect(betaPost?.community.local).toBe(true); expect(betaPost?.creator.local).toBe(false); expect(betaPost?.post.score).toBe(1); await assertPostFederation(betaPost, postRes.post_view); // Delta only follows beta, so it should not see an alpha ap_id await jestLemmyError( () => resolvePost(delta, postRes.post_view.post), new LemmyError( "resolve_object_failed", statusBadRequest, 'Domain "lemmy-alpha" is not in allowlist', ), ); // Epsilon has alpha blocked, it should not see the alpha post await jestLemmyError( () => resolvePost(epsilon, postRes.post_view.post), new LemmyError( "resolve_object_failed", statusBadRequest, 'Domain "lemmy-alpha" is blocked', ), ); // remove blocked instance block_instance_params.block = false; await epsilon.adminBlockInstance(block_instance_params); }); test("Create a post in a non-existent community", async () => { await jestLemmyError( () => createPost(alpha, -2), new LemmyError("not_found", statusNotFound), ); }); test("Unlike a post", async () => { if (!betaCommunity) { throw "Missing beta community"; } let postRes = await createPost(alpha, betaCommunity.community.id); let unlike = await likePost(alpha, undefined, postRes.post_view.post); expect(unlike.post_view.post.score).toBe(0); // Try to unlike it again, make sure it stays at 0 let unlike2 = await likePost(alpha, undefined, postRes.post_view.post); expect(unlike2.post_view.post.score).toBe(0); // Make sure that post is unliked on beta const betaPost = await waitForPost( beta, postRes.post_view.post, post => post?.post.score === 0, ); expect(betaPost).toBeDefined(); expect(betaPost?.community.local).toBe(true); expect(betaPost?.creator.local).toBe(false); expect(betaPost?.post.score).toBe(0); await assertPostFederation(betaPost, postRes.post_view); }); test("Update a post", async () => { if (!betaCommunity) { throw "Missing beta community"; } let postRes = await createPost(alpha, betaCommunity.community.id); let updatedName = "A jest test federated post, updated"; let updatedPost = await editPost(alpha, postRes.post_view.post); expect(updatedPost.post_view.post.name).toBe(updatedName); expect(updatedPost.post_view.community.local).toBe(false); expect(updatedPost.post_view.creator.local).toBe(true); // Make sure that post is updated on beta let betaPost = await waitForPost(beta, updatedPost.post_view.post); expect(betaPost.community.local).toBe(true); expect(betaPost.creator.local).toBe(false); expect(betaPost.post.name).toBe(updatedName); await assertPostFederation(betaPost, updatedPost.post_view); // Make sure lemmy beta cannot update the post await jestLemmyError( () => editPost(beta, betaPost.post), new LemmyError("no_post_edit_allowed", statusBadRequest), ); }); test("Sticky a post", async () => { if (!betaCommunity) { throw "Missing beta community"; } let postRes = await createPost(alpha, betaCommunity.community.id); let betaPost1 = await waitForPost(beta, postRes.post_view.post); if (!betaPost1) { throw "Missing beta post1"; } let stickiedPostRes = await featurePost(beta, true, betaPost1.post); expect(stickiedPostRes.post_view.post.featured_community).toBe(true); // Make sure that post is stickied on beta let betaPost = await resolvePost(beta, postRes.post_view.post); expect(betaPost?.community.local).toBe(true); expect(betaPost?.creator.local).toBe(false); expect(betaPost?.post.featured_community).toBe(true); // Unsticky a post let unstickiedPost = await featurePost(beta, false, betaPost1.post); expect(unstickiedPost.post_view.post.featured_community).toBe(false); // Make sure that post is unstickied on beta let betaPost2 = await resolvePost(beta, postRes.post_view.post); expect(betaPost2?.community.local).toBe(true); expect(betaPost2?.creator.local).toBe(false); expect(betaPost2?.post.featured_community).toBe(false); // Make sure that gamma cannot sticky the post on beta let gammaPost = await resolvePost(gamma, postRes.post_view.post); if (!gammaPost) { throw "Missing gamma post"; } // This has been failing occasionally await featurePost(gamma, true, gammaPost.post); let betaPost3 = await resolvePost(beta, postRes.post_view.post); // expect(gammaTrySticky.post_view.post.featured_community).toBe(true); expect(betaPost3?.post.featured_community).toBe(false); }); test("Collection of featured posts gets federated", async () => { // create a new community and feature a post let community = await createCommunity(alpha); let post = await createPost(alpha, community.community_view.community.id); let featuredPost = await featurePost(alpha, true, post.post_view.post); expect(featuredPost.post_view.post.featured_community).toBe(true); // fetch the community, ensure that post is also fetched and marked as featured let betaCommunity = await resolveCommunity( beta, community.community_view.community.ap_id, ); expect(betaCommunity).toBeDefined(); const betaPost = await waitForPost( beta, post.post_view.post, post => post?.post.featured_community === true, ); expect(betaPost).toBeDefined(); }); test("Lock a post", async () => { if (!betaCommunity) { throw "Missing beta community"; } await followCommunity(alpha, true, betaCommunity.community.id); await waitUntil( () => resolveBetaCommunity(alpha), c => c?.community_actions?.follow_state == "accepted", ); let postRes = await createPost(alpha, betaCommunity.community.id); let betaPost1 = await waitForPost(beta, postRes.post_view.post); // Lock the post let lockedPostRes = await lockPost(beta, true, betaPost1.post); expect(lockedPostRes.post_view.post.locked).toBe(true); // Make sure that post is locked on alpha let alphaPost1 = await waitForPost( alpha, postRes.post_view.post, post => !!post && post.post.locked, ); // Try to make a new comment there, on alpha. For this we need to create a normal // user account because admins/mods can comment in locked posts. let user = await registerUser(alpha, alphaUrl); await jestLemmyError( () => createComment(user, alphaPost1.post.id), new LemmyError("locked", statusBadRequest), ); // Unlock a post let unlockedPost = await lockPost(beta, false, betaPost1.post); expect(unlockedPost.post_view.post.locked).toBe(false); // Make sure that post is unlocked on alpha let alphaPost2 = await waitForPost( alpha, postRes.post_view.post, post => !!post && !post.post.locked, ); expect(alphaPost2.community.local).toBe(false); expect(alphaPost2.creator.local).toBe(true); expect(alphaPost2.post.locked).toBe(false); // Try to create a new comment, on alpha let commentAlpha = await createComment(user, alphaPost1.post.id); expect(commentAlpha).toBeDefined(); }); test("Delete a post", async () => { if (!betaCommunity) { throw "Missing beta community"; } let postRes = await createPost(alpha, betaCommunity.community.id); expect(postRes.post_view.post).toBeDefined(); await waitForPost(beta, postRes.post_view.post, p => p?.post.id != undefined); let deletedPost = await deletePost(alpha, true, postRes.post_view.post); // Make sure lemmy alpha sees post is deleted await waitUntil( () => getPost(alpha, postRes.post_view.post.id), p => p.post_view.post.deleted, ); expect(deletedPost.post_view.post.name).toBe(postRes.post_view.post.name); // Make sure lemmy beta sees post is deleted // This will be undefined because of the tombstone await waitForPost(beta, postRes.post_view.post, p => p?.post == undefined); // Undelete let undeletedPost = await deletePost(alpha, false, postRes.post_view.post); await waitUntil( () => getPost(alpha, postRes.post_view.post.id), p => !p.post_view.post.deleted, ); // Make sure lemmy beta sees post is undeleted let betaPost2 = await waitForPost( beta, postRes.post_view.post, p => !!p && !p.post.deleted, ); if (!betaPost2) { throw "Missing beta post 2"; } expect(betaPost2.post.deleted).toBe(false); await assertPostFederation(betaPost2, undeletedPost.post_view); // Make sure lemmy beta cannot delete the post await jestLemmyError( () => deletePost(beta, true, betaPost2.post), new LemmyError("no_post_edit_allowed", statusBadRequest), ); }); test("Remove a post from admin and community on different instance", async () => { if (!betaCommunity) { throw "Missing beta community"; } let gammaCommunity = ( await resolveCommunity(gamma, betaCommunity.community.ap_id) )?.community; if (!gammaCommunity) { throw "Missing gamma community"; } let postRes = await createPost(gamma, gammaCommunity.id); let alphaPost = await resolvePost(alpha, postRes.post_view.post); if (!alphaPost) { throw "Missing alpha post"; } let removedPost = await removePost(alpha, true, alphaPost.post); expect(removedPost.post_view.post.removed).toBe(true); expect(removedPost.post_view.post.name).toBe(postRes.post_view.post.name); // Make sure lemmy beta sees post is NOT removed let betaPost = await resolvePost(beta, postRes.post_view.post); if (!betaPost) { throw "Missing beta post"; } expect(betaPost.post.removed).toBe(false); // Undelete let undeletedPost = await removePost(alpha, false, alphaPost.post); expect(undeletedPost.post_view.post.removed).toBe(false); // Make sure lemmy beta sees post is undeleted let betaPost2 = await resolvePost(beta, postRes.post_view.post); expect(betaPost2?.post.removed).toBe(false); await assertPostFederation(betaPost2!, undeletedPost.post_view); }); test("Remove a post from admin and community on same instance", async () => { if (!betaCommunity) { throw "Missing beta community"; } await followBeta(alpha); let gammaCommunity = await resolveCommunity( gamma, betaCommunity.community.ap_id, ); let postRes = await createPost(gamma, gammaCommunity!.community.id); expect(postRes.post_view.post).toBeDefined(); // Get the id for beta let betaPost = await waitForPost(beta, postRes.post_view.post); expect(betaPost).toBeDefined(); let alphaPost0 = await waitForPost(alpha, postRes.post_view.post); expect(alphaPost0).toBeDefined(); // The beta admin removes it (the community lives on beta) let removePostRes = await removePost(beta, true, betaPost.post); expect(removePostRes.post_view.post.removed).toBe(true); // Make sure lemmy alpha sees post is removed let alphaPost = await waitUntil( () => getPost(alpha, alphaPost0.post.id), p => p?.post_view.post.removed, ); expect(alphaPost?.post_view.post.removed).toBe(true); await assertPostFederation( alphaPost.post_view, removePostRes.post_view, false, ); // Undelete let undeletedPost = await removePost(beta, false, betaPost.post); expect(undeletedPost.post_view.post.removed).toBe(false); // Make sure lemmy alpha sees post is undeleted let alphaPost2 = await waitForPost( alpha, postRes.post_view.post, p => !!p && !p.post.removed, ); expect(alphaPost2.post.removed).toBe(false); await assertPostFederation(alphaPost2, undeletedPost.post_view); await unfollowRemotes(alpha); }); test("Search for a post", async () => { if (!betaCommunity) { throw "Missing beta community"; } await unfollowRemotes(alpha); let postRes = await createPost(alpha, betaCommunity.community.id); expect(postRes.post_view.post).toBeDefined(); let betaPost = await waitForPost(beta, postRes.post_view.post); expect(betaPost?.post.name).toBeDefined(); }); test("Enforce site ban federation for local user", async () => { if (!betaCommunity) { throw "Missing beta community"; } // create a test user let alphaUserHttp = await registerUser(alpha, alphaUrl); let alphaUserPerson = (await getMyUser(alphaUserHttp)).local_user_view.person; let alphaUserActorId = alphaUserPerson?.ap_id; if (!alphaUserActorId) { throw "Missing alpha user actor id"; } expect(alphaUserActorId).toBeDefined(); await followBeta(alphaUserHttp); let alphaPerson = await resolvePerson(alphaUserHttp, alphaUserActorId!); if (!alphaPerson) { throw "Missing alpha person"; } expect(alphaPerson).toBeDefined(); // alpha makes post in beta community, it federates to beta instance let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id); let searchBeta1 = await waitForPost(beta, postRes1.post_view.post); // ban alpha from its own instance let banAlpha = await banPersonFromSite( alpha, alphaPerson.person.id, true, true, ); expect(banAlpha.person_view.banned).toBe(true); // alpha ban should be federated to beta let alphaUserOnBeta1 = await waitUntil( () => resolvePerson(beta, alphaUserActorId!), res => res?.banned == true, ); expect(alphaUserOnBeta1?.banned).toBe(true); // existing alpha post should be removed on beta let betaBanRes = await waitUntil( () => getPost(beta, searchBeta1.post.id), s => s.post_view.post.removed, ); expect(betaBanRes.post_view.post.removed).toBe(true); // Unban alpha let unBanAlpha = await banPersonFromSite( alpha, alphaPerson.person.id, false, true, ); expect(unBanAlpha.person_view.banned).toBe(false); // existing alpha post should be restored on beta betaBanRes = await waitUntil( () => getPost(beta, searchBeta1.post.id), s => !s.post_view.post.removed, ); expect(betaBanRes.post_view.post.removed).toBe(false); // Login gets invalidated by ban, need to login again if (!alphaUserPerson) { throw "Missing alpha person"; } let newAlphaUserJwt = await loginUser(alpha, alphaUserPerson.name); alphaUserHttp.setHeaders({ Authorization: "Bearer " + newAlphaUserJwt.jwt, }); // alpha makes new post in beta community, it federates let postRes2 = await createPost(alphaUserHttp, betaCommunity!.community.id); await waitForPost(beta, postRes2.post_view.post); await unfollowRemotes(alpha); }); test("Enforce site ban federation for federated user", async () => { if (!betaCommunity) { throw "Missing beta community"; } // create a test user let alphaUserHttp = await registerUser(alpha, alphaUrl); let alphaUserPerson = (await getMyUser(alphaUserHttp)).local_user_view.person; let alphaUserActorId = alphaUserPerson?.ap_id; if (!alphaUserActorId) { throw "Missing alpha user actor id"; } expect(alphaUserActorId).toBeDefined(); await followBeta(alphaUserHttp); let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId!); expect(alphaUserOnBeta2?.banned).toBe(false); if (!alphaUserOnBeta2?.person) { throw "Missing alpha person"; } // alpha makes post in beta community, it federates to beta instance let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id); let searchBeta1 = await waitForPost(beta, postRes1.post_view.post); expect(searchBeta1.post).toBeDefined(); // Now ban and remove their data from beta let banAlphaOnBeta = await banPersonFromSite( beta, alphaUserOnBeta2.person.id, true, true, ); expect(banAlphaOnBeta.person_view.banned).toBe(true); // existing alpha post should be removed on beta let betaRemovedPost = await getPost(beta, searchBeta1.post.id); expect(betaRemovedPost.post_view.post.removed).toBe(true); // post should also be removed on alpha let alphaRemovedPost = await waitUntil( () => getPost(alpha, postRes1.post_view.post.id), s => s.post_view.post.removed, ); expect(alphaRemovedPost.post_view.post.removed).toBe(true); // User should not be shown to be banned from alpha let alphaPerson2 = (await getMyUser(alphaUserHttp)).local_user_view; expect(alphaPerson2.banned).toBe(false); // post to beta community is rejected await jestLemmyError( () => createPost(alphaUserHttp, betaCommunity!.community.id), new LemmyError("site_ban", statusBadRequest), ); await unfollowRemotes(alpha); }); test("Enforce community ban for federated user", async () => { if (!betaCommunity) { throw "Missing beta community"; } await followBeta(alpha); let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`; let alphaPerson = await resolvePerson(beta, alphaShortname); if (!alphaPerson) { throw "Missing alpha person"; } expect(alphaPerson).toBeDefined(); // make a post in beta, it goes through let postRes1 = await createPost(alpha, betaCommunity.community.id); let searchBeta1 = await waitForPost(beta, postRes1.post_view.post); expect(searchBeta1.post).toBeDefined(); // ban alpha from beta community let banAlpha = await banPersonFromCommunity( beta, alphaPerson.person.id, searchBeta1.community.id, true, true, ); expect(banAlpha).toBeDefined(); // ensure that the post by alpha got removed let removePostRes = await waitUntil( () => getPost(alpha, postRes1.post_view.post.id), s => s.post_view.post.removed, ); expect(removePostRes.post_view.post.removed).toBe(true); expect(removePostRes.post_view.creator_banned_from_community).toBe(true); expect( removePostRes.community_view.community_actions?.received_ban_at, ).toBeDefined(); // Alpha tries to make post on beta, but it fails because of ban await jestLemmyError( () => createPost(alpha, betaCommunity!.community.id), new LemmyError("person_is_banned_from_community", statusBadRequest), ); // Unban alpha let unBanAlpha = await banPersonFromCommunity( beta, alphaPerson.person.id, searchBeta1.community.id, false, false, ); expect(unBanAlpha).toBeDefined(); // Check that unban was federated to alpha await waitUntil( () => getModlog(alpha), m => m.items[0].modlog.kind == "mod_ban_from_community" && m.items[0].modlog.is_revert == true, ); let postRes3 = await createPost(alpha, betaCommunity.community.id); expect(postRes3.post_view.post).toBeDefined(); expect(postRes3.post_view.community.local).toBe(false); expect(postRes3.post_view.creator.local).toBe(true); expect(postRes3.post_view.post.score).toBe(1); // Make sure that post makes it to beta community let postRes4 = await waitForPost(beta, postRes3.post_view.post); expect(postRes4.post).toBeDefined(); expect(postRes4.creator_banned).toBe(false); await unfollowRemotes(alpha); }); test("A and G subscribe to B (center) A posts, it gets announced to G", async () => { if (!betaCommunity) { throw "Missing beta community"; } await followBeta(alpha); let postRes = await createPost(alpha, betaCommunity.community.id); expect(postRes.post_view.post).toBeDefined(); let betaPost = await resolvePost(gamma, postRes.post_view.post); expect(betaPost?.post.name).toBeDefined(); await unfollowRemotes(alpha); }); test("Report a post", async () => { // Create post from alpha let alphaCommunity = await resolveBetaCommunity(alpha); await followBeta(alpha); let alphaPost = await createPost(alpha, alphaCommunity!.community.id); expect(alphaPost.post_view.post).toBeDefined(); // add remote mod on epsilon await followBeta(epsilon); let betaCommunity = await resolveBetaCommunity(beta); let epsilonUser = await resolvePerson( beta, "@lemmy_epsilon@lemmy-epsilon:8581", ); let mod_params: AddModToCommunity = { community_id: betaCommunity!.community.id, person_id: epsilonUser!.person.id, added: true, }; let res = await beta.addModToCommunity(mod_params); expect(res.moderators.length).toBe(2); // Send report from gamma let gammaPost = await resolvePost(gamma, alphaPost.post_view.post); let gammaReport = ( await reportPost(gamma, gammaPost!.post.id, randomString(10)) ).post_report_view.post_report; expect(gammaReport).toBeDefined(); // Report was federated to community instance let betaReport = ( (await waitUntil( () => listReports(beta).then(p => p.items.find(r => { return checkPostReportName(r, gammaReport); }), ), res => !!res, ))! as PostReportView ).post_report; expect(betaReport).toBeDefined(); expect(betaReport.resolved).toBe(false); expect(betaReport.original_post_name).toBe(gammaReport.original_post_name); //expect(betaReport.original_post_url).toBe(gammaReport.original_post_url); expect(betaReport.original_post_body).toBe(gammaReport.original_post_body); expect(betaReport.reason).toBe(gammaReport.reason); await unfollowRemotes(alpha); // Report was federated to poster's instance. Alpha is not a community mod and doesnt see // the report by default, so we need to pass show_mod_reports = true. let alphaReport = ( (await waitUntil( () => listReports(alpha, true).then(p => p.items.find(r => { return checkPostReportName(r, gammaReport); }), ), res => !!res, ))! as PostReportView ).post_report; expect(alphaReport).toBeDefined(); expect(alphaReport.resolved).toBe(false); expect(alphaReport.original_post_name).toBe(gammaReport.original_post_name); //expect(alphaReport.original_post_url).toBe(gammaReport.original_post_url); expect(alphaReport.original_post_body).toBe(gammaReport.original_post_body); expect(alphaReport.reason).toBe(gammaReport.reason); // Report was federated to remote mod instance let epsilonReport = ( (await waitUntil( () => listReports(epsilon).then(p => p.items.find(r => { return checkPostReportName(r, gammaReport); }), ), res => !!res, ))! as PostReportView ).post_report; expect(epsilonReport).toBeDefined(); expect(epsilonReport.resolved).toBe(false); expect(epsilonReport.original_post_name).toBe(gammaReport.original_post_name); // Resolve report as remote mod let resolve_params: ResolvePostReport = { report_id: epsilonReport.id, resolved: true, }; let resolve = await epsilon.resolvePostReport(resolve_params); expect(resolve.post_report_view.post_report.resolved).toBeTruthy(); // Report should be marked resolved on community instance let resolvedReport = ( (await waitUntil( () => listReports(beta).then(p => p.items.find(r => { return checkPostReportName(r, gammaReport) && !!r.resolver; }), ), res => !!res, ))! as PostReportView ).post_report; expect(resolvedReport).toBeDefined(); expect(resolvedReport.resolved).toBe(true); }); test("Fetch post via redirect", async () => { await followBeta(alpha); let alphaPost = await createPost(alpha, betaCommunity!.community.id); expect(alphaPost.post_view.post).toBeDefined(); // Make sure that post is liked on beta const betaPost = await waitForPost( beta, alphaPost.post_view.post, res => res?.post.score === 1, ); expect(betaPost).toBeDefined(); expect(betaPost.post?.ap_id).toBe(alphaPost.post_view.post.ap_id); // Fetch post from url on beta instance instead of ap_id let q = `http://lemmy-beta:8551/post/${betaPost.post.id}`; let form: ResolveObject = { q, }; let gammaPost = await gamma .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "post" ? a : undefined)); expect(gammaPost).toBeDefined(); expect(gammaPost?.post.ap_id).toBe(alphaPost.post_view.post.ap_id); await unfollowRemotes(alpha); }); test("Block post that contains banned URL", async () => { let editSiteForm: EditSite = { blocked_urls: ["https://evil.com/"], }; await epsilon.editSite(editSiteForm); await waitUntil( () => epsilon.getSite(), s => s.blocked_urls.length == 1, ); if (!betaCommunity) { throw "Missing beta community"; } await jestLemmyError( () => createPost(epsilon, betaCommunity!.community.id, "https://evil.com"), new LemmyError("blocked_url", statusBadRequest), ); // Later tests need this to be empty editSiteForm.blocked_urls = []; await epsilon.editSite(editSiteForm); }); test("Fetch post with redirect", async () => { let alphaPost = await createPost(alpha, betaCommunity!.community.id); expect(alphaPost.post_view.post).toBeDefined(); // beta fetches from alpha as usual let betaPost = await resolvePost(beta, alphaPost.post_view.post); expect(betaPost?.post).toBeDefined(); // gamma fetches from beta, and gets redirected to alpha let gammaPost = await resolvePost(gamma, betaPost!.post); expect(gammaPost?.post).toBeDefined(); // fetch remote object from local url, which redirects to the original url let form: ResolveObject = { q: `http://lemmy-gamma:8561/post/${gammaPost?.post.id}`, }; let gammaPost2 = await gamma .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "post" ? a : undefined)); expect(gammaPost2?.post).toBeDefined(); }); test("Mention beta from alpha post body", async () => { if (!betaCommunity) throw Error("no community"); let mentionContent = "A test mention of @lemmy_beta@lemmy-beta:8551"; const postOnAlphaRes = await createPost( alpha, betaCommunity.community.id, undefined, mentionContent, ); expect(postOnAlphaRes.post_view.post.body).toBeDefined(); expect(postOnAlphaRes.post_view.community.local).toBe(false); expect(postOnAlphaRes.post_view.creator.local).toBe(true); expect(postOnAlphaRes.post_view.post.score).toBe(1); // get beta's localized copy of the alpha post let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post); if (!betaPost) { throw "unable to locate post on beta"; } expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id); expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name); await assertPostFederation(betaPost, postOnAlphaRes.post_view); let mentionsRes = await waitUntil( () => listNotifications(beta, "mention"), m => !!m.items[0], ); const firstMention = mentionsRes.items[0].data as PostView; expect(firstMention.post!.body).toBeDefined(); expect(firstMention.community!.local).toBe(true); expect(firstMention.creator.local).toBe(false); expect(firstMention.post!.score).toBe(1); }); test("Rewrite markdown links", async () => { const community = await resolveBetaCommunity(beta); // create a post let postRes1 = await createPost(beta, community!.community.id); // link to this post in markdown let postRes2 = await createPost( beta, community!.community.id, "https://example.com/", `[link](${postRes1.post_view.post.ap_id})`, ); expect(postRes2.post_view.post).toBeDefined(); // fetch both posts from another instance const alphaPost1 = await resolvePost(alpha, postRes1.post_view.post); const alphaPost2 = await resolvePost(alpha, postRes2.post_view.post); // remote markdown link is replaced with local link expect(alphaPost2?.post.body).toBe( `[link](http://lemmy-alpha:8541/post/${alphaPost1?.post.id})`, ); }); test("Don't allow NSFW posts on instances that disable it", async () => { // Disallow NSFW on gamma let editSiteForm: EditSite = { disallow_nsfw_content: true, }; await gamma.editSite(editSiteForm); // Wait for cache on Gamma's LocalSite await waitUntil( () => getSite(gamma), s => s.site_view.local_site.disallow_nsfw_content, ); if (!betaCommunity) { throw "Missing beta community"; } // Make a NSFW post let postRes = await createPost(beta, betaCommunity.community.id); let form: EditPost = { nsfw: true, post_id: postRes.post_view.post.id, }; let updatePost = await beta.editPost(form); // Gamma reject resolving the post await jestLemmyError( () => resolvePost(gamma, updatePost.post_view.post), new LemmyError("resolve_object_failed", statusBadRequest, "NsfwNotAllowed"), ); // Local users can't create NSFW post on Gamma let gammaCommunity = await resolveCommunity( gamma, betaCommunity.community.ap_id, ); if (!gammaCommunity) { throw "Missing gamma community"; } let gammaPost = await createPost(gamma, gammaCommunity.community.id); let form2: EditPost = { nsfw: true, post_id: gammaPost.post_view.post.id, }; await jestLemmyError( () => gamma.editPost(form2), new LemmyError("nsfw_not_allowed", statusBadRequest), ); }); test("Plugin test", async () => { let community = await createCommunity(epsilon); let postRes1 = await createPost( epsilon, community.community_view.community.id, "https://example.com/", randomString(10), "Rust", ); expect(postRes1.post_view.post.name).toBe("Go"); await jestLemmyError( () => createPost( epsilon, community.community_view.community.id, "https://example.com/", randomString(10), "Java", ), new LemmyError("plugin_error", statusBadRequest, "We dont talk about Java"), ); }); function checkPostReportName(rcv: ReportCombinedView, report: PostReport) { switch (rcv.type_) { case "post": return rcv.post_report.original_post_name === report.original_post_name; default: return false; } } ================================================ FILE: api_tests/src/private_comm.spec.ts ================================================ jest.setTimeout(120000); import { FollowCommunity, LemmyError, LemmyHttp } from "lemmy-js-client"; import { alpha, setupLogins, createCommunity, unfollows, registerUser, listCommunityPendingFollows, getCommunity, approveCommunityPendingFollow, randomString, createPost, createComment, beta, resolveCommunity, betaUrl, resolvePost, resolveComment, likeComment, waitUntil, gamma, getPosts, getComments, statusNotFound, jestLemmyError, statusBadRequest, getUnreadCounts, } from "./shared"; beforeAll(setupLogins); afterAll(unfollows); test("Follow a private community", async () => { // create private community const community = await createCommunity(alpha, randomString(10), "private"); expect(community.community_view.community.visibility).toBe("private"); const alphaCommunityId = community.community_view.community.id; // No pending follows yet const pendingFollows0 = await listCommunityPendingFollows(alpha); expect(pendingFollows0.items.length).toBe(0); const pendingFollowsCount0 = await getUnreadCounts(alpha); expect(pendingFollowsCount0.pending_follow_count).toBe(0); // follow as new user const user = await registerUser(beta, betaUrl); const betaCommunity = await resolveCommunity( user, community.community_view.community.ap_id, ); expect(betaCommunity).toBeDefined(); expect(betaCommunity?.community.visibility).toBe("private"); const betaCommunityId = betaCommunity!.community.id; const follow_form: FollowCommunity = { community_id: betaCommunityId, follow: true, }; await user.followCommunity(follow_form); // Follow listed as pending const follow1 = await getCommunity(user, betaCommunityId); expect(follow1.community_view.community_actions?.follow_state).toBe( "approval_required", ); // Wait for follow to federate, shown as pending let pendingFollows1 = await waitUntil( () => listCommunityPendingFollows(alpha), f => f.items.length == 1, ); expect(pendingFollows1.items[0].is_new_instance).toBe(true); const pendingFollowsCount1 = await getUnreadCounts(alpha); expect(pendingFollowsCount1.pending_follow_count).toBe(1); // user still sees approval required at this point const betaCommunity2 = await getCommunity(user, betaCommunityId); expect(betaCommunity2.community_view.community_actions?.follow_state).toBe( "approval_required", ); // Approve the follow const approve = await approveCommunityPendingFollow( alpha, alphaCommunityId, pendingFollows1.items[0].person.id, ); expect(approve.success).toBe(true); // Follow is confirmed await waitUntil( () => getCommunity(user, betaCommunityId), c => c.community_view.community_actions?.follow_state == "accepted", ); const pendingFollows2 = await listCommunityPendingFollows(alpha); expect(pendingFollows2.items.length).toBe(0); const pendingFollowsCount2 = await getUnreadCounts(alpha); expect(pendingFollowsCount2.pending_follow_count).toBe(0); // follow with another user from that instance, is_new_instance should be false now const user2 = await registerUser(beta, betaUrl); await user2.followCommunity(follow_form); let pendingFollows3 = await waitUntil( () => listCommunityPendingFollows(alpha), f => f.items.length == 1, ); expect(pendingFollows3.items[0].is_new_instance).toBe(false); // cleanup pending follow const approve2 = await approveCommunityPendingFollow( alpha, alphaCommunityId, pendingFollows3.items[0].person.id, ); expect(approve2.success).toBe(true); }); test("Only followers can view and interact with private community content", async () => { // create private community const community = await createCommunity(alpha, randomString(10), "private"); expect(community.community_view.community.visibility).toBe("private"); const alphaCommunityId = community.community_view.community.id; // create post and comment const post0 = await createPost(alpha, alphaCommunityId); const post_id = post0.post_view.post.id; expect(post_id).toBeDefined(); const comment = await createComment(alpha, post_id); const comment_id = comment.comment_view.comment.id; expect(comment_id).toBeDefined(); // user is not following the community and cannot view nor create posts const user = await registerUser(beta, betaUrl); const betaCommunity = ( await resolveCommunity(user, community.community_view.community.ap_id) )?.community; await jestLemmyError( () => resolvePost(user, post0.post_view.post), new LemmyError("resolve_object_failed", statusBadRequest), false, ); await jestLemmyError( () => resolveComment(user, comment.comment_view.comment), new LemmyError("resolve_object_failed", statusBadRequest), false, ); await jestLemmyError( () => createPost(user, betaCommunity!.id), new LemmyError("not_found", statusNotFound), ); // follow the community and approve const follow_form: FollowCommunity = { community_id: betaCommunity!.id, follow: true, }; await user.followCommunity(follow_form); approveFollower(alpha, alphaCommunityId); // now user can fetch posts and comments in community (using signed fetch), and create posts await waitUntil( () => resolvePost(user, post0.post_view.post), p => p?.post.id != undefined, ); const resolvedComment = await resolveComment( user, comment.comment_view.comment, ); expect(resolvedComment?.comment.id).toBeDefined(); const post1 = await createPost(user, betaCommunity!.id); expect(post1.post_view).toBeDefined(); const like = await likeComment(user, true, resolvedComment!.comment); expect(like.comment_view.comment_actions?.vote_is_upvote).toBe(true); }); test("Reject follower", async () => { // create private community const community = await createCommunity(alpha, randomString(10), "private"); expect(community.community_view.community.visibility).toBe("private"); const alphaCommunityId = community.community_view.community.id; // user is not following the community and cannot view nor create posts const user = await registerUser(beta, betaUrl); const betaCommunity1 = ( await resolveCommunity(user, community.community_view.community.ap_id) )?.community; // follow the community and reject const follow_form: FollowCommunity = { community_id: betaCommunity1!.id, follow: true, }; const follow = await user.followCommunity(follow_form); expect(follow.community_view.community_actions?.follow_state).toBe( "approval_required", ); const pendingFollows1 = await waitUntil( () => listCommunityPendingFollows(alpha), f => f.items.length == 1, ); const approve = await approveCommunityPendingFollow( alpha, alphaCommunityId, pendingFollows1.items[0].person.id, false, ); expect(approve.success).toBe(true); await waitUntil( () => getCommunity(user, betaCommunity1!.id), c => c.community_view.community_actions?.follow_state === undefined, ); }); test("Follow a private community and receive activities", async () => { // create private community const community = await createCommunity(alpha, randomString(10), "private"); expect(community.community_view.community.visibility).toBe("private"); const alphaCommunityId = community.community_view.community.id; // follow with users from beta and gamma const betaCommunity = await resolveCommunity( beta, community.community_view.community.ap_id, ); expect(betaCommunity).toBeDefined(); const betaCommunityId = betaCommunity!.community.id; const follow_form_beta: FollowCommunity = { community_id: betaCommunityId, follow: true, }; await beta.followCommunity(follow_form_beta); await approveFollower(alpha, alphaCommunityId); const gammaCommunityId = (await resolveCommunity( gamma, community.community_view.community.ap_id, ))!.community.id; const follow_form_gamma: FollowCommunity = { community_id: gammaCommunityId, follow: true, }; await gamma.followCommunity(follow_form_gamma); await approveFollower(alpha, alphaCommunityId); // Follow is confirmed await waitUntil( () => getCommunity(beta, betaCommunityId), c => c.community_view.community_actions?.follow_state == "accepted", ); await waitUntil( () => getCommunity(gamma, gammaCommunityId), c => c.community_view.community_actions?.follow_state == "accepted", ); // create a post and comment from gamma const post = await createPost(gamma, gammaCommunityId); const post_id = post.post_view.post.id; expect(post_id).toBeDefined(); const comment = await createComment(gamma, post_id); const comment_id = comment.comment_view.comment.id; expect(comment_id).toBeDefined(); // post and comment were federated to beta let posts = await waitUntil( () => getPosts(beta, "all", betaCommunityId), c => c.items.length == 1, ); expect(posts.items[0].post.ap_id).toBe(post.post_view.post.ap_id); expect(posts.items[0].post.name).toBe(post.post_view.post.name); let comments = await waitUntil( () => getComments(beta, posts.items[0].post.id), c => c.items.length == 1, ); expect(comments.items[0].comment.ap_id).toBe( comment.comment_view.comment.ap_id, ); expect(comments.items[0].comment.content).toBe( comment.comment_view.comment.content, ); }); test("Fetch remote content in private community", async () => { // create private community const community = await createCommunity(alpha, randomString(10), "private"); expect(community.community_view.community.visibility).toBe("private"); const alphaCommunityId = community.community_view.community.id; const betaCommunityId = (await resolveCommunity( beta, community.community_view.community.ap_id, ))!.community.id; const follow_form_beta: FollowCommunity = { community_id: betaCommunityId, follow: true, }; await beta.followCommunity(follow_form_beta); await approveFollower(alpha, alphaCommunityId); // Follow is confirmed await waitUntil( () => getCommunity(beta, betaCommunityId), c => c.community_view.community_actions?.follow_state == "accepted", ); // beta creates post and comment const post = await createPost(beta, betaCommunityId); const post_id = post.post_view.post.id; expect(post_id).toBeDefined(); const comment = await createComment(beta, post_id); const comment_id = comment.comment_view.comment.id; expect(comment_id).toBeDefined(); // Wait for it to federate await waitUntil( () => resolveComment(alpha, comment.comment_view.comment), p => p?.comment.id != undefined, ); // create gamma user const gammaCommunityId = (await resolveCommunity( gamma, community.community_view.community.ap_id, ))!.community.id; const follow_form: FollowCommunity = { community_id: gammaCommunityId, follow: true, }; // cannot fetch post yet await jestLemmyError( () => resolvePost(gamma, post.post_view.post), new LemmyError("resolve_object_failed", statusBadRequest), false, ); // follow community and approve await gamma.followCommunity(follow_form); await approveFollower(alpha, alphaCommunityId); // now user can fetch posts and comments in community (using signed fetch), and create posts. // for this to work, beta checks with alpha if gamma is really an approved follower. let resolvedPost = await waitUntil( () => resolvePost(gamma, post.post_view.post), p => p?.post.id != undefined, ); expect(resolvedPost?.post.ap_id).toBe(post.post_view.post.ap_id); const resolvedComment = await waitUntil( () => resolveComment(gamma, comment.comment_view.comment), p => p?.comment.id != undefined, ); expect(resolvedComment?.comment.ap_id).toBe( comment.comment_view.comment.ap_id, ); }); async function approveFollower(user: LemmyHttp, community_id: number) { let pendingFollows1 = await waitUntil( () => listCommunityPendingFollows(user), f => f.items.length == 1, ); const approve = await approveCommunityPendingFollow( alpha, community_id, pendingFollows1.items[0].person.id, ); expect(approve.success).toBe(true); } ================================================ FILE: api_tests/src/private_message.spec.ts ================================================ jest.setTimeout(120000); import { LemmyError, PrivateMessageView } from "lemmy-js-client"; import { alpha, beta, setupLogins, createPrivateMessage, editPrivateMessage, deletePrivateMessage, waitUntil, reportPrivateMessage, unfollows, listNotifications, resolvePerson, statusBadRequest, jestLemmyError, } from "./shared"; let recipient_id: number; beforeAll(async () => { await setupLogins(); let betaUser = await beta.getMyUser(); let betaUserOnAlpha = await resolvePerson( alpha, betaUser.local_user_view.person.ap_id, ); recipient_id = betaUserOnAlpha!.person.id; }); afterAll(unfollows); test("Create a private message", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); expect(pmRes.private_message_view.private_message.content).toBeDefined(); expect(pmRes.private_message_view.private_message.local).toBe(true); expect(pmRes.private_message_view.creator.local).toBe(true); expect(pmRes.private_message_view.recipient.local).toBe(false); let betaPms = await waitUntil( () => listNotifications(beta, "private_message"), e => !!e.items[0], ); const firstPm = betaPms.items[0].data as PrivateMessageView; expect(firstPm.private_message.content).toBeDefined(); expect(firstPm.private_message.local).toBe(false); expect(firstPm.creator.local).toBe(false); expect(firstPm.recipient.local).toBe(true); }); test("Update a private message", async () => { let updatedContent = "A jest test federated private message edited"; let pmRes = await createPrivateMessage(alpha, recipient_id); let pmUpdated = await editPrivateMessage( alpha, pmRes.private_message_view.private_message.id, ); expect(pmUpdated.private_message_view.private_message.content).toBe( updatedContent, ); let betaPms = await waitUntil( () => listNotifications(beta, "private_message"), p => p.items[0].data.type_ == "private_message" && p.items[0].data.private_message.content === updatedContent, ); let pm = betaPms.items[0].data as PrivateMessageView; expect(pm.private_message.content).toBe(updatedContent); }); test("Delete a private message", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); let betaPms1 = await waitUntil( () => listNotifications(beta, "private_message"), m => !!m.items.find( e => e.data.type_ == "private_message" && e.data.private_message.ap_id === pmRes.private_message_view.private_message.ap_id, ), ); let deletedPmRes = await deletePrivateMessage( alpha, true, pmRes.private_message_view.private_message.id, ); expect(deletedPmRes.private_message_view.private_message.deleted).toBe(true); // The GetPrivateMessages filters out deleted, // even though they are in the actual database. // no reason to show them let betaPms2 = await waitUntil( () => listNotifications(beta, "private_message"), p => p.items.length === betaPms1.items.length - 1, ); expect(betaPms2.items.length).toBe(betaPms1.items.length - 1); // Undelete let undeletedPmRes = await deletePrivateMessage( alpha, false, pmRes.private_message_view.private_message.id, ); expect(undeletedPmRes.private_message_view.private_message.deleted).toBe( false, ); let betaPms3 = await waitUntil( () => listNotifications(beta, "private_message"), p => p.items.length === betaPms1.items.length, ); expect(betaPms3.items.length).toBe(betaPms1.items.length); }); test("Create a private message report", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); let betaPms1 = await waitUntil( () => listNotifications(beta, "private_message"), m => !!m.items.find( e => e.data.type_ == "private_message" && e.data.private_message.ap_id === pmRes.private_message_view.private_message.ap_id, ), ); let betaPm = betaPms1.items[0].data as PrivateMessageView; expect(betaPm).toBeDefined(); // Make sure that only the recipient can report it, so this should fail await jestLemmyError( () => reportPrivateMessage( alpha, pmRes.private_message_view.private_message.id, "a reason", ), new LemmyError("couldnt_create", statusBadRequest), ); // This one should pass let reason = "another reason"; let report = await reportPrivateMessage( beta, betaPm.private_message.id, reason, ); expect(report.private_message_report_view.private_message.id).toBe( betaPm.private_message.id, ); expect(report.private_message_report_view.private_message_report.reason).toBe( reason, ); }); ================================================ FILE: api_tests/src/shared.ts ================================================ import { ApproveCommunityPendingFollower, BlockCommunity, CommunityId, CommunityVisibility, CreatePrivateMessageReport, EditCommunity, InstanceId, LemmyHttp, ListCommunityPendingFollows, ListReports, MyUserInfo, DeleteImageParams, PersonId, PostView, PrivateMessageReportResponse, SuccessResponse, ListPersonContent, PersonContentType, GetModlog, CommunityView, CommentView, Comment, PersonView, UserBlockInstanceCommunitiesParams, ListNotifications, NotificationTypeFilter, PersonResponse, AdminAllowInstanceParams, BanFromCommunity, BanPerson, CommentReportResponse, CommentResponse, CommunityReportResponse, CommunityResponse, CreateComment, CreateCommentLike, CreateCommentReport, CreateCommunity, CreateCommunityReport, CreatePost, CreatePostLike, CreatePostReport, CreatePrivateMessage, DeleteAccount, DeleteComment, DeleteCommunity, DeletePost, DeletePrivateMessage, EditComment, EditPost, EditPrivateMessage, EditSite, FeaturePost, FollowCommunity, GetComment, GetComments, GetCommunity, GetCommunityResponse, GetPersonDetails, GetPersonDetailsResponse, GetPost, GetPostResponse, GetPosts, GetSiteResponse, ListingType, LockComment, LockPost, Login, LoginResponse, Post, PostReportResponse, PostResponse, PrivateMessageResponse, Register, RemoveComment, RemoveCommunity, RemovePost, ResolveObject, SaveUserSettings, Search, PagedResponse, NotificationView, ReportCombinedView, PendingFollowerView, ModlogView, LemmyError, PostCommentCombinedView, UnreadCountsResponse, } from "lemmy-js-client"; export const fetchFunction = fetch; export const imageFetchLimit = 50; export const statusNotFound = 404; export const statusBadRequest = 400; export const statusUnauthorized = 401; export const sampleImage = "https://i.pinimg.com/originals/df/5f/5b/df5f5b1b174a2b4b6026cc6c8f9395c1.jpg"; export const sampleSite = "https://w3.org"; export const alphaUrl = "http://127.0.0.1:8541"; export const betaUrl = "http://127.0.0.1:8551"; export const gammaUrl = "http://127.0.0.1:8561"; export const deltaUrl = "http://127.0.0.1:8571"; export const epsilonUrl = "http://127.0.0.1:8581"; export const alpha = new LemmyHttp(alphaUrl, { fetchFunction }); export const alphaImage = new LemmyHttp(alphaUrl); export const beta = new LemmyHttp(betaUrl, { fetchFunction }); export const gamma = new LemmyHttp(gammaUrl, { fetchFunction }); export const delta = new LemmyHttp(deltaUrl, { fetchFunction }); export const epsilon = new LemmyHttp(epsilonUrl, { fetchFunction }); export const password = "lemmylemmy"; export async function setupLogins() { let formAlpha: Login = { username_or_email: "lemmy_alpha", password, }; let resAlpha = alpha.login(formAlpha); let formBeta: Login = { username_or_email: "lemmy_beta", password, }; let resBeta = beta.login(formBeta); let formGamma: Login = { username_or_email: "lemmy_gamma", password, }; let resGamma = gamma.login(formGamma); let formDelta: Login = { username_or_email: "lemmy_delta", password, }; let resDelta = delta.login(formDelta); let formEpsilon: Login = { username_or_email: "lemmy_epsilon", password, }; let resEpsilon = epsilon.login(formEpsilon); let res = await Promise.all([ resAlpha, resBeta, resGamma, resDelta, resEpsilon, ]); alpha.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? ""}` }); alphaImage.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? ""}` }); beta.setHeaders({ Authorization: `Bearer ${res[1].jwt ?? ""}` }); gamma.setHeaders({ Authorization: `Bearer ${res[2].jwt ?? ""}` }); delta.setHeaders({ Authorization: `Bearer ${res[3].jwt ?? ""}` }); epsilon.setHeaders({ Authorization: `Bearer ${res[4].jwt ?? ""}` }); // Registration applications are now enabled by default, need to disable them let editSiteForm: EditSite = { registration_mode: "open", rate_limit_message_max_requests: 999, rate_limit_post_max_requests: 999, rate_limit_comment_max_requests: 999, rate_limit_register_max_requests: 999, rate_limit_search_max_requests: 999, rate_limit_image_max_requests: 999, }; await alpha.editSite(editSiteForm); await beta.editSite(editSiteForm); await gamma.editSite(editSiteForm); await delta.editSite(editSiteForm); await epsilon.editSite(editSiteForm); // Alpha and beta use image_mode StoreLinkPreviews let imageModeForm: EditSite = { image_mode: "store_link_previews" }; await alpha.editSite(imageModeForm); await beta.editSite(imageModeForm); // Set the blocks for each await allowInstance(alpha, "lemmy-beta"); await allowInstance(alpha, "lemmy-gamma"); await allowInstance(alpha, "lemmy-delta"); await allowInstance(alpha, "lemmy-epsilon"); await allowInstance(beta, "lemmy-alpha"); await allowInstance(beta, "lemmy-gamma"); await allowInstance(beta, "lemmy-delta"); await allowInstance(beta, "lemmy-epsilon"); await allowInstance(gamma, "lemmy-alpha"); await allowInstance(gamma, "lemmy-beta"); await allowInstance(gamma, "lemmy-delta"); await allowInstance(gamma, "lemmy-epsilon"); await allowInstance(delta, "lemmy-beta"); // Create the main alpha/beta communities // Ignore thrown errors of duplicates try { await createCommunity(alpha, "main"); await createCommunity(beta, "main"); // wait for > INSTANCES_RECHECK_DELAY to ensure federation is initialized // otherwise the first few federated events may be missed // (because last_successful_id is set to current id when federation to an instance is first started) // only needed the first time so do in this try await delay(10_000); } catch { //console.log("Communities already exist"); } } export async function allowInstance(api: LemmyHttp, instance: string) { const params: AdminAllowInstanceParams = { instance, allow: true, reason: "allow", }; // Ignore errors from duplicate allows (because setup gets called for each test file) try { await api.adminAllowInstance(params); } catch { // console.error(error); } } export async function createPost( api: LemmyHttp, community_id: number, url: string = "https://example.com/", body = randomString(10), // use example.com for consistent title and embed description name: string = randomString(5), alt_text = randomString(10), custom_thumbnail: string | undefined = undefined, ): Promise { let form: CreatePost = { name, url, body, alt_text, community_id, custom_thumbnail, }; return api.createPost(form); } export async function editPost( api: LemmyHttp, post: Post, ): Promise { let name = "A jest test federated post, updated"; let form: EditPost = { name, post_id: post.id, }; return api.editPost(form); } export async function createPostWithThumbnail( api: LemmyHttp, community_id: number, url: string, custom_thumbnail: string, ): Promise { let form: CreatePost = { name: randomString(10), url, community_id, custom_thumbnail, }; return api.createPost(form); } export async function deletePost( api: LemmyHttp, deleted: boolean, post: Post, ): Promise { let form: DeletePost = { post_id: post.id, deleted: deleted, }; return api.deletePost(form); } export async function removePost( api: LemmyHttp, removed: boolean, post: Post, ): Promise { let form: RemovePost = { post_id: post.id, removed, reason: "remove", }; return api.removePost(form); } export async function featurePost( api: LemmyHttp, featured: boolean, post: Post, ): Promise { let form: FeaturePost = { post_id: post.id, featured, feature_type: "community", }; return api.featurePost(form); } export async function lockPost( api: LemmyHttp, locked: boolean, post: Post, ): Promise { let form: LockPost = { post_id: post.id, locked, reason: "lock", }; return api.lockPost(form); } export async function resolvePost( api: LemmyHttp, post: Post, ): Promise { let form: ResolveObject = { q: post.ap_id, }; return api .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "post" ? a : undefined)); } export async function searchPostLocal( api: LemmyHttp, post: Post, ): Promise { let form: Search = { q: post.name, type_: "posts", listing_type: "all", }; let res = await api.search(form); let first = res.search.at(0); return first?.type_ == "post" ? first : undefined; } /// wait for a post to appear locally without pulling it export async function waitForPost( api: LemmyHttp, post: Post, checker: (t: PostView | undefined) => boolean = p => !!p, ) { return waitUntil( () => searchPostLocal(api, post), checker, ) as Promise; } export async function getPost( api: LemmyHttp, post_id: number, ): Promise { let form: GetPost = { id: post_id, }; return api.getPost(form); } export async function lockComment( api: LemmyHttp, locked: boolean, comment: Comment, ): Promise { let form: LockComment = { comment_id: comment.id, locked, reason: "lock", }; return api.lockComment(form); } export async function getComment( api: LemmyHttp, comment_id: number, ): Promise { let form: GetComment = { id: comment_id, }; return api.getComment(form); } export async function getComments( api: LemmyHttp, post_id?: number, listingType: ListingType = "all", ): Promise> { let form: GetComments = { post_id: post_id, type_: listingType, sort: "new", limit: 50, }; return api.getComments(form); } export async function getUnreadCounts( api: LemmyHttp, ): Promise { return api.getUnreadCounts(); } export async function listNotifications( api: LemmyHttp, type_?: NotificationTypeFilter, unread_only: boolean = false, ): Promise> { let form: ListNotifications = { unread_only, type_, }; return api.listNotifications(form); } export async function resolveComment( api: LemmyHttp, comment: Comment, ): Promise { let form: ResolveObject = { q: comment.ap_id, }; return api .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "comment" ? a : undefined)); } export async function resolveBetaCommunity( api: LemmyHttp, ): Promise { // Use short-hand search url let form: ResolveObject = { q: "!main@lemmy-beta:8551", }; return api .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "community" ? a : undefined)); } export async function resolveCommunity( api: LemmyHttp, q: string, ): Promise { let form: ResolveObject = { q, }; return api .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "community" ? a : undefined)); } export async function resolvePerson( api: LemmyHttp, apShortname: string, ): Promise { let form: ResolveObject = { q: apShortname, }; return api .resolveObject(form) .then(a => a.resolve) .then(a => (a?.type_ == "person" ? a : undefined)); } export async function banPersonFromSite( api: LemmyHttp, person_id: number, ban: boolean, remove_or_restore_data: boolean, ): Promise { // Make sure lemmy-beta/c/main is cached on lemmy_alpha let form: BanPerson = { person_id, ban, remove_or_restore_data, reason: "ban", }; return api.banPerson(form); } export async function banPersonFromCommunity( api: LemmyHttp, person_id: number, community_id: number, remove_or_restore_data: boolean, ban: boolean, ): Promise { let form: BanFromCommunity = { person_id, community_id, remove_or_restore_data, ban, reason: "ban", }; return api.banFromCommunity(form); } export async function followCommunity( api: LemmyHttp, follow: boolean, community_id: number, ): Promise { let form: FollowCommunity = { community_id, follow, }; const res = await api.followCommunity(form); await waitUntil( () => getCommunity(api, res.community_view.community.id), g => { let followState = g.community_view.community_actions?.follow_state; return follow ? followState === "accepted" : followState === undefined; }, ); // wait FOLLOW_ADDITIONS_RECHECK_DELAY (there's no API to wait for this currently) await delay(2000); return res; } export async function likePost( api: LemmyHttp, is_upvote: boolean | undefined, post: Post, ): Promise { let form: CreatePostLike = { post_id: post.id, is_upvote: is_upvote, }; return api.likePost(form); } export async function createComment( api: LemmyHttp, post_id: number, parent_id?: number, content = "a jest test comment", ): Promise { let form: CreateComment = { content, post_id, parent_id, }; return api.createComment(form); } export async function editComment( api: LemmyHttp, comment_id: number, content = "A jest test federated comment update", ): Promise { let form: EditComment = { content, comment_id, }; return api.editComment(form); } export async function deleteComment( api: LemmyHttp, deleted: boolean, comment_id: number, ): Promise { let form: DeleteComment = { comment_id, deleted, }; return api.deleteComment(form); } export async function removeComment( api: LemmyHttp, removed: boolean, comment_id: number, remove_children?: boolean, ): Promise { let form: RemoveComment = { comment_id, removed, reason: "remove", remove_children, }; return api.removeComment(form); } export async function likeComment( api: LemmyHttp, is_upvote: boolean | undefined, comment: Comment, ): Promise { let form: CreateCommentLike = { comment_id: comment.id, is_upvote, }; return api.likeComment(form); } export async function createCommunity( api: LemmyHttp, name_: string = randomString(10), visibility: CommunityVisibility = "public", ): Promise { let sidebar = "a sample sidebar"; let form: CreateCommunity = { name: name_, title: name_, sidebar, visibility, }; return api.createCommunity(form); } export async function editCommunity( api: LemmyHttp, form: EditCommunity, ): Promise { return api.editCommunity(form); } export async function getCommunity( api: LemmyHttp, id: number, ): Promise { let form: GetCommunity = { id, }; return api.getCommunity(form); } export async function getCommunityByName( api: LemmyHttp, name: string, ): Promise { let form: GetCommunity = { name, }; return api.getCommunity(form); } export async function deleteCommunity( api: LemmyHttp, deleted: boolean, community_id: number, ): Promise { let form: DeleteCommunity = { community_id, deleted, }; return api.deleteCommunity(form); } export async function removeCommunity( api: LemmyHttp, removed: boolean, community_id: number, ): Promise { let form: RemoveCommunity = { community_id, removed, reason: "remove", }; return api.removeCommunity(form); } export async function createPrivateMessage( api: LemmyHttp, recipient_id: number, ): Promise { let content = "A jest test federated private message"; let form: CreatePrivateMessage = { content, recipient_id, }; return api.createPrivateMessage(form); } export async function editPrivateMessage( api: LemmyHttp, private_message_id: number, ): Promise { let updatedContent = "A jest test federated private message edited"; let form: EditPrivateMessage = { content: updatedContent, private_message_id, }; return api.editPrivateMessage(form); } export async function deletePrivateMessage( api: LemmyHttp, deleted: boolean, private_message_id: number, ): Promise { let form: DeletePrivateMessage = { deleted, private_message_id, }; return api.deletePrivateMessage(form); } export async function registerUser( api: LemmyHttp, url: string, username: string = randomString(5), ): Promise { let form: Register = { username, password, password_verify: password, show_nsfw: true, }; let login_response = await api.register(form); expect(login_response.jwt).toBeDefined(); let lemmyHttp = new LemmyHttp(url, { headers: { Authorization: `Bearer ${login_response.jwt ?? ""}` }, }); return lemmyHttp; } export async function loginUser( api: LemmyHttp, username: string, ): Promise { let form: Login = { username_or_email: username, password: password, }; return api.login(form); } export async function saveUserSettingsBio( api: LemmyHttp, ): Promise { let form: SaveUserSettings = { show_nsfw: true, blur_nsfw: false, theme: "darkly", default_post_sort_type: "active", default_listing_type: "all", interface_language: "en", show_avatars: true, send_notifications_to_email: false, bio: "a changed bio", }; return saveUserSettings(api, form); } export async function saveUserSettingsFederated( api: LemmyHttp, ): Promise { let bio = "a changed bio"; let form: SaveUserSettings = { show_nsfw: false, blur_nsfw: true, default_post_sort_type: "hot", default_listing_type: "all", interface_language: "", display_name: "user321", show_avatars: false, send_notifications_to_email: false, bio, }; return await saveUserSettings(api, form); } export async function saveUserSettings( api: LemmyHttp, form: SaveUserSettings, ): Promise { return api.saveUserSettings(form); } export async function getPersonDetails( api: LemmyHttp, person_id: number, ): Promise { let form: GetPersonDetails = { person_id: person_id, }; return api.getPersonDetails(form); } export async function listPersonContent( api: LemmyHttp, person_id: number, type_?: PersonContentType, ): Promise> { let form: ListPersonContent = { person_id, type_, }; return api.listPersonContent(form); } export async function deleteUser( api: LemmyHttp, delete_content: boolean = true, ): Promise { let form: DeleteAccount = { delete_content, password, }; return api.deleteAccount(form); } export async function getSite(api: LemmyHttp): Promise { return api.getSite(); } export async function getMyUser(api: LemmyHttp): Promise { return api.getMyUser(); } export async function unfollowRemotes(api: LemmyHttp): Promise { // Unfollow all remote communities let my_user = await getMyUser(api); let remoteFollowed = my_user.follows.filter(c => c.community.local == false) ?? []; await Promise.allSettled( remoteFollowed.map(cu => followCommunity(api, false, cu.community.id)), ); return await getMyUser(api); } export async function followBeta(api: LemmyHttp): Promise { let betaCommunity = await resolveBetaCommunity(api); if (betaCommunity) { let follow = await followCommunity(api, true, betaCommunity.community.id); return follow; } else { return Promise.reject("no community worked"); } } export async function reportPost( api: LemmyHttp, post_id: number, reason: string, ): Promise { let form: CreatePostReport = { post_id, reason, }; return api.createPostReport(form); } export async function reportCommunity( api: LemmyHttp, community_id: number, reason: string, ): Promise { let form: CreateCommunityReport = { community_id, reason, }; return api.createCommunityReport(form); } export async function listReports( api: LemmyHttp, show_community_rule_violations: boolean = false, ): Promise> { let form: ListReports = { show_community_rule_violations }; return api.listReports(form); } export async function reportComment( api: LemmyHttp, comment_id: number, reason: string, ): Promise { let form: CreateCommentReport = { comment_id, reason, }; return api.createCommentReport(form); } export async function reportPrivateMessage( api: LemmyHttp, private_message_id: number, reason: string, ): Promise { let form: CreatePrivateMessageReport = { private_message_id, reason, }; return api.createPrivateMessageReport(form); } export function getPosts( api: LemmyHttp, listingType?: ListingType, community_id?: number, ): Promise> { let form: GetPosts = { type_: listingType, limit: 50, community_id, }; return api.getPosts(form); } export function userBlockInstanceCommunities( api: LemmyHttp, instance_id: InstanceId, block: boolean, ): Promise { let form: UserBlockInstanceCommunitiesParams = { instance_id, block, }; return api.userBlockInstanceCommunities(form); } export function blockCommunity( api: LemmyHttp, community_id: CommunityId, block: boolean, ): Promise { let form: BlockCommunity = { community_id, block, }; return api.blockCommunity(form); } export function listCommunityPendingFollows( api: LemmyHttp, ): Promise> { let form: ListCommunityPendingFollows = { unread_only: true, all_communities: false, limit: 50, }; return api.listCommunityPendingFollows(form); } export function approveCommunityPendingFollow( api: LemmyHttp, community_id: CommunityId, follower_id: PersonId, approve: boolean = true, ): Promise { let form: ApproveCommunityPendingFollower = { community_id, follower_id, approve, }; return api.approveCommunityPendingFollow(form); } export function getModlog(api: LemmyHttp): Promise> { let form: GetModlog = {}; return api.getModlog(form); } export function wrapper(form: any): string { return JSON.stringify(form); } export function randomString(length: number): string { let result = ""; let characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; let charactersLength = characters.length; for (let i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } export async function deleteAllMedia(api: LemmyHttp) { const imagesRes = await api.listMediaAdmin({ limit: imageFetchLimit, }); Promise.allSettled( imagesRes.items .map(image => { const form: DeleteImageParams = { filename: image.local_image.pictrs_alias, }; return form; }) .map(form => api.deleteMediaAdmin(form)), ); } export async function unfollows() { await Promise.allSettled([ unfollowRemotes(alpha), unfollowRemotes(beta), unfollowRemotes(gamma), unfollowRemotes(delta), unfollowRemotes(epsilon), ]); await Promise.allSettled([ purgeAllPosts(alpha), purgeAllPosts(beta), purgeAllPosts(gamma), purgeAllPosts(delta), purgeAllPosts(epsilon), ]); } export async function purgeAllPosts(api: LemmyHttp) { // The best way to get all federated items, is to find the posts let res = await api.getPosts({ type_: "all", limit: 50 }); await Promise.allSettled( Array.from(new Set(res.items.map(p => p.post.id))) .map(post_id => api.purgePost({ post_id, reason: "purge" })) // Ignore errors .map(p => p.catch(e => e)), ); } export function getCommentParentId(comment: Comment): number | undefined { let split = comment.path.split("."); // remove the 0 split.shift(); if (split.length > 1) { return Number(split[split.length - 2]); } else { console.error(`Failed to extract comment parent id from ${comment.path}`); return undefined; } } export async function waitUntil( fetcher: () => Promise, checker: (t: T) => boolean, retries = 10, delaySeconds = [0.2, 0.5, 1, 2, 3], ) { let retry = 0; let result; while (retry++ < retries) { try { result = await fetcher(); if (checker(result)) return result; } catch (error) { console.error(error); } await delay(delaySeconds[(retry - 1) % delaySeconds.length] * 1000); } console.error("result", result); throw Error( `Failed "${fetcher}": "${checker}" did not return true after ${retries} retries (delayed ${delaySeconds}s each)`, ); } // Do not use this function directly, only use `waitUntil()` function delay(millis = 500) { return new Promise(resolve => setTimeout(resolve, millis)); } export function assertCommunityFederation( communityOne?: CommunityView, communityTwo?: CommunityView, ) { expect(communityOne?.community.ap_id).toBe(communityTwo?.community.ap_id); expect(communityOne?.community.name).toBe(communityTwo?.community.name); expect(communityOne?.community.title).toBe(communityTwo?.community.title); expect(communityOne?.community.sidebar).toBe(communityTwo?.community.sidebar); expect(communityOne?.community.icon).toBe(communityTwo?.community.icon); expect(communityOne?.community.banner).toBe(communityTwo?.community.banner); expect(communityOne?.community.published_at).toBe( communityTwo?.community.published_at, ); expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw); expect(communityOne?.community.removed).toBe(communityTwo?.community.removed); expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted); } /** * Jest officially doesn't support deep checking custom errors, * so we have to check each field manually. * * https://github.com/jestjs/jest/issues/15378 **/ export async function jestLemmyError( fetcher: () => Promise, err: LemmyError, checkMessage = true, ) { try { await fetcher(); } catch (e) { expect(e.name).toBe(err.name); expect(e.status).toBe(err.status); if (checkMessage) { expect(e.message).toBe(err.message); } } } ================================================ FILE: api_tests/src/speed.spec.ts ================================================ // This is meant to be used with an already-filled / production db with lots of history. // Requires env vars: // // LEMMY_SERVER_URL (ex http://localhost:8536) // LEMMY_LOGIN // LEMMY_PASSWORD jest.setTimeout(120000); import { CommentId, CommentSortType, CommunitySortType, LemmyHttp, LikeType, ListingType, Login, ModlogKindFilter, MultiCommunitySortType, NotificationTypeFilter, PersonContentType, PostId, PostSortType, SearchSortType, SearchType, } from "lemmy-js-client"; import { fetchFunction } from "./shared"; import * as fs from "fs"; const defaultServerUrl = "http://localhost:8536"; const defaultLogin = "lemmy"; const defaultPassword = "lemmylemmy"; const postCommentsMaxDepth = 8; const samplePerson = "dessalines"; const sampleCommunity = "memes"; /// A multicommunity with several high-volume communities in it. const sampleMultiCommunity = "test_1"; const searchTerm = "test"; // Post without a url const textPost: PostId = 43615136; // Post with a url const postWithUrl: PostId = 43614333; // A post with ~2.2k comments const postWithLotsOfComments: PostId = 3192572; const sampleComment: CommentId = 24109064; let api: LemmyHttp; let report: string[] = []; beforeAll(async () => { api = new LemmyHttp(process.env.LEMMY_SERVER_URL ?? defaultServerUrl, { fetchFunction, }); const login: Login = { username_or_email: process.env.LEMMY_LOGIN ?? defaultLogin, password: process.env.LEMMY_PASSWORD ?? defaultPassword, }; const res = await api.login(login); api.setHeaders({ Authorization: `Bearer ${res.jwt ?? ""}` }); }); afterAll(() => { const reportMd = report.join("\n"); fs.writeFileSync("speed_test_report.md", reportMd); console.log(reportMd); }); test("List posts with different sorts", async () => { report.push("\n# List posts with different sorts \n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: PostSortType[] = [ "active", "hot", "new", "top", "controversial", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.getPosts({ sort })); report.push(`${sort} | ${formatMs(time)}`); } }); test("List posts for a community with different sorts", async () => { report.push("\n# List posts for a community with different sorts \n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: PostSortType[] = [ "active", "hot", "new", "top", "controversial", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.getPosts({ sort, community_name: sampleCommunity }), ); report.push(`${sort} | ${formatMs(time)}`); } }); test("List posts with different listing types", async () => { report.push("\n# List posts with different listing types \n"); report.push("type | time"); report.push("--- | ---"); const listingTypes: ListingType[] = [ "all", "local", "subscribed", "moderator_view", "suggested", ]; for (let type_ of listingTypes) { const time = await timeApiCalls(() => api.getPosts({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); test("List posts with show hidden", async () => { report.push("\n# List posts with show hidden \n"); const time = await timeApiCalls(() => api.getPosts({ show_hidden: true })); report.push(`show hidden: ${formatMs(time)}`); }); test("List posts with hide read", async () => { report.push("\n# List posts with hide read \n"); const time = await timeApiCalls(() => api.getPosts({ show_read: false })); report.push(`show read : ${formatMs(time)}`); }); test("List posts with higher pages", async () => { report.push("\n# List posts with higher pages\n"); report.push("page # | time"); report.push("--- | ---"); let page_cursor: string | undefined = undefined; let diffs = []; for (let i = 0; i < 10; i++) { const res = await timeApiCall(() => api.getPosts({ sort: "new", page_cursor }), ); page_cursor = res.res.next_page; diffs.push(res.diff); report.push(`${i} | ${formatMs(res.diff)}`); } const avg = average(diffs); report.push(`avg | ${formatMs(avg)}`); }); test("List posts for a multi-community with different sorts", async () => { report.push("\n# List posts for a multi-community with different sorts \n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: PostSortType[] = [ "active", "hot", "new", "top", "controversial", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.getPosts({ sort, multi_community_name: sampleMultiCommunity }), ); report.push(`${sort} | ${formatMs(time)}`); } }); test("List communities with different sorts", async () => { report.push("\n# List communities with different sorts \n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: CommunitySortType[] = [ "active_six_months", "active_monthly", "active_weekly", "active_daily", "hot", "new", "old", "name_asc", "name_desc", "comments", "posts", "subscribers", "subscribers_local", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.listCommunities({ sort })); report.push(`${sort} | ${formatMs(time)}`); } }); test("List communities with different listing types", async () => { report.push("\n# List communities with different listing types \n"); report.push("type | time"); report.push("--- | ---"); const listingTypes: ListingType[] = [ "all", "local", "subscribed", "moderator_view", "suggested", ]; for (let type_ of listingTypes) { const time = await timeApiCalls(() => api.listCommunities({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); test("List multi-communities with different sorts", async () => { report.push("\n# List multi-communities with different sorts \n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: MultiCommunitySortType[] = [ "new", "old", "name_asc", "name_desc", "communities", "subscribers", "subscribers_local", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.listMultiCommunities({ sort })); report.push(`${sort} | ${formatMs(time)}`); } }); test("Get a community", async () => { report.push("\n# Get a community \n"); const time = await timeApiCalls(() => api.getCommunity({ name: sampleCommunity }), ); report.push(`get community: ${formatMs(time)}`); }); test.skip("Get a post", async () => { report.push("\n# Get a post\n"); report.push("type | time"); report.push("--- | ---"); const getUrlPost = await timeApiCalls(() => api.getPost({ id: postWithUrl })); report.push(`url post | ${formatMs(getUrlPost)}`); const getTextPost = await timeApiCalls(() => api.getPost({ id: textPost })); report.push(`text post | ${formatMs(getTextPost)}`); }); test("Get comments for a post with different sorts", async () => { report.push("\n# Get comments for a post with different sorts\n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: CommentSortType[] = [ "hot", "new", "old", "top", "controversial", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.getComments({ post_id: postWithLotsOfComments, sort, max_depth: postCommentsMaxDepth, }), ); report.push(`${sort} | ${formatMs(time)}`); } }); test("Get comments for a post slim", async () => { report.push("\n# Get comments for a post slim\n"); report.push("sort | time"); report.push("--- | ---"); const getCommentsSlim = await timeApiCalls(() => api.getCommentsSlim({ post_id: postWithLotsOfComments, max_depth: postCommentsMaxDepth, }), ); report.push(`getCommentsSlim: ${formatMs(getCommentsSlim)}`); }); test("Get all comments with different sorts", async () => { report.push("\n# Get all comments with different sorts\n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: CommentSortType[] = [ "hot", "new", "old", "top", "controversial", ]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.getComments({ sort })); report.push(`${sort} | ${formatMs(time)}`); } }); test("Get comments with different types", async () => { report.push("\n# Get comments with different types\n"); report.push("type | time"); report.push("--- | ---"); const listingTypes: ListingType[] = [ "all", "local", "subscribed", "moderator_view", "suggested", ]; for (let type_ of listingTypes) { const time = await timeApiCalls(() => api.getComments({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); test("List person content with types", async () => { report.push("\n# List person content with types\n"); report.push("type | time"); report.push("--- | ---"); const contentTypes: PersonContentType[] = ["all", "comments", "posts"]; for (let type_ of contentTypes) { const time = await timeApiCalls(() => api.listPersonContent({ username: samplePerson, type_ }), ); report.push(`${type_} | ${formatMs(time)}`); } }); test("List person saved with types", async () => { report.push("\n# List person saved with types\n"); report.push("type | time"); report.push("--- | ---"); const contentTypes: PersonContentType[] = ["all", "comments", "posts"]; for (let type_ of contentTypes) { const time = await timeApiCalls(() => api.listPersonSaved({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); test("List person liked with types", async () => { report.push("\n# List person liked with types\n"); report.push("type | time"); report.push("--- | ---"); const contentTypes: PersonContentType[] = ["all", "comments", "posts"]; for (let type_ of contentTypes) { const time = await timeApiCalls(() => api.listPersonLiked({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } const likeType: LikeType[] = ["all", "liked_only", "disliked_only"]; for (let like_type of likeType) { const time = await timeApiCalls(() => api.listPersonLiked({ like_type })); report.push(`${like_type} | ${formatMs(time)}`); } }); test("List person read", async () => { report.push("\n# List person read\n"); const time = await timeApiCalls(() => api.listPersonRead({})); report.push(`list person read: ${formatMs(time)}`); }); test("List person hidden", async () => { report.push("\n# List person hidden\n"); const time = await timeApiCalls(() => api.listPersonHidden({})); report.push(`list person hidden: ${formatMs(time)}`); }); test("List registration applications", async () => { report.push("\n# List registration applications\n"); report.push("type | time"); report.push("--- | ---"); const unreadOnly = await timeApiCalls(() => api.listRegistrationApplications({ unread_only: true }), ); const all = await timeApiCalls(() => api.listRegistrationApplications({})); report.push(`unread only | ${formatMs(unreadOnly)}`); report.push(`all | ${formatMs(all)}`); }); test("List reports", async () => { report.push("\n# List reports\n"); report.push("type | time"); report.push("--- | ---"); const unresolvedOnly = await timeApiCalls(() => api.listReports({ unresolved_only: true }), ); const all = await timeApiCalls(() => api.listReports({})); report.push(`unresolved only | ${formatMs(unresolvedOnly)}`); report.push(`all | ${formatMs(all)}`); }); test.skip("Search with types", async () => { report.push("\n# Search with types\n"); report.push("type | time"); report.push("--- | ---"); const searchTypes: SearchType[] = [ "all", "comments", "posts", "communities", "users", "multi_communities", ]; for (let type_ of searchTypes) { const time = await timeApiCalls(() => api.search({ q: searchTerm, type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); test.skip("Search with sorts", async () => { report.push("\n# Search with sorts\n"); report.push("sort | time"); report.push("--- | ---"); const sortTypes: SearchSortType[] = ["new", "old", "top"]; for (let sort of sortTypes) { const time = await timeApiCalls(() => api.search({ q: searchTerm, sort })); report.push(`${sort} | ${formatMs(time)}`); } }); test("Notifications with types", async () => { report.push("\n# Notifications with types\n"); report.push("type | time"); report.push("--- | ---"); const notificationTypes: NotificationTypeFilter[] = [ "all", "mention", "reply", "subscribed", "private_message", "mod_action", ]; for (let type_ of notificationTypes) { const time = await timeApiCalls(() => api.listNotifications({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); test("Notifications with unread only", async () => { report.push("\n# Notifications with unread only\n"); report.push("type | time"); report.push("--- | ---"); const unreadOnly = await timeApiCalls(() => api.listNotifications({ unread_only: true }), ); const all = await timeApiCalls(() => api.listNotifications({})); report.push(`all | ${formatMs(all)}`); report.push(`unread_only | ${formatMs(unreadOnly)}`); }); test("Liking a comment / post", async () => { report.push("\n# Liking a comment / post\n"); report.push("type | time"); report.push("--- | ---"); const commentLike = await timeApiCall(() => api.likeComment({ comment_id: sampleComment }), ); const postLike = await timeApiCall(() => api.likePost({ post_id: postWithUrl }), ); report.push(`comment | ${formatMs(commentLike.diff)}`); report.push(`post | ${formatMs(postLike.diff)}`); }); type Result = { diff: number; res: T; }; test("Get modlog with types", async () => { report.push("\n# Get modlog with types\n"); report.push("type | time"); report.push("--- | ---"); const modlogKinds: ModlogKindFilter[] = [ "all", "admin_add", "admin_ban", "admin_allow_instance", "admin_block_instance", "admin_purge_comment", "admin_purge_community", "admin_purge_person", "admin_purge_post", "mod_add_to_community", "mod_ban_from_community", "admin_feature_post_site", "mod_feature_post_community", "mod_change_community_visibility", "mod_lock_post", "mod_remove_comment", "admin_remove_community", "mod_remove_post", "mod_transfer_community", "mod_lock_comment", ]; for (let type_ of modlogKinds) { const time = await timeApiCalls(() => api.getModlog({ type_ })); report.push(`${type_} | ${formatMs(time)}`); } }); async function timeApiCall(promise: () => Promise): Promise> { const start = performance.now(); const res = await promise(); const end = performance.now(); const diff = timeDiff(start, end); return { diff, res, }; } async function timeApiCalls(promise: () => Promise, times = 10) { let diffs = []; for (let i = 0; i < times; i++) { const diff = (await timeApiCall(promise)).diff; diffs.push(diff); } return average(diffs); } function timeDiff(start: number, end: number) { return end - start; } function average(arr: number[]) { return arr.reduce((p, c) => p + c, 0) / arr.length; } function formatMs(time: number): string { return `${time.toFixed(0)}ms`; } ================================================ FILE: api_tests/src/tags.spec.ts ================================================ jest.setTimeout(120000); import { alpha, beta, setupLogins, createCommunity, unfollows, randomString, followCommunity, resolveCommunity, waitUntil, assertCommunityFederation, waitForPost, gamma, resolvePerson, getCommunity, } from "./shared"; import { CreateCommunityTag } from "lemmy-js-client/dist/types/CreateCommunityTag"; import { DeleteCommunityTag } from "lemmy-js-client/dist/types/DeleteCommunityTag"; import { AddModToCommunity } from "lemmy-js-client"; beforeAll(setupLogins); afterAll(unfollows); test("Create, delete and restore a community tag", async () => { // Create a community first const communityRes = await createCommunity(alpha); let alphaCommunity = communityRes.community_view; let betaCommunity = (await resolveCommunity( beta, alphaCommunity.community.ap_id, ))!; await followCommunity(beta, true, betaCommunity.community.id); await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), g => g?.community_actions!.follow_state == "accepted", ); const communityId = alphaCommunity.community.id; // Create a tag const tagName = randomString(10); let createForm: CreateCommunityTag = { name: tagName, community_id: communityId, }; let createRes = await alpha.createCommunityTag(createForm); expect(createRes.id).toBeDefined(); expect(createRes.name).toBe(tagName); expect(createRes.community_id).toBe(communityId); alphaCommunity = (await alpha.getCommunity({ id: communityId })) .community_view; expect(alphaCommunity.tags.length).toBe(1); // verify tag federated betaCommunity = (await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), g => g!.tags.length === 1, ))!; assertCommunityFederation(alphaCommunity, betaCommunity); // List tags alphaCommunity = (await alpha.getCommunity({ id: communityId })) .community_view; expect(alphaCommunity.tags.length).toBe(1); expect(alphaCommunity.tags.find(t => t.id === createRes.id)?.name).toBe( tagName, ); // Verify tag update federated betaCommunity = (await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), g => g!.tags.find(t => t.ap_id === createRes.ap_id)?.name === tagName, ))!; assertCommunityFederation(alphaCommunity, betaCommunity); // Delete the tag let deleteForm: DeleteCommunityTag = { tag_id: createRes.id, delete: true, }; let deleteRes = await alpha.deleteCommunityTag(deleteForm); expect(deleteRes.id).toBe(createRes.id); // Verify tag is deleted alphaCommunity = (await alpha.getCommunity({ id: communityId })) .community_view; expect( alphaCommunity.tags.find(t => t.id === createRes.id)!.deleted, ).toBeTruthy(); // It should still list one tag expect(alphaCommunity.tags.length).toBe(1); // Verify tag deletion federated betaCommunity = (await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), g => g!.tags.at(0)?.deleted === true, ))!; assertCommunityFederation(alphaCommunity, betaCommunity); // Restore the tag let deleteFormRestoration: DeleteCommunityTag = { tag_id: createRes.id, delete: false, }; let deleteRestorationRes = await alpha.deleteCommunityTag( deleteFormRestoration, ); expect(deleteRestorationRes.id).toBe(createRes.id); // Verify tag is restored alphaCommunity = (await alpha.getCommunity({ id: communityId })) .community_view; expect(alphaCommunity.tags.length).toBe(1); // verify tag federated betaCommunity = (await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), g => g!.tags.length === 1, ))!; assertCommunityFederation(alphaCommunity, betaCommunity); // List tags alphaCommunity = (await alpha.getCommunity({ id: communityId })) .community_view; expect(alphaCommunity.tags.length).toBe(1); expect(alphaCommunity.tags.find(t => t.id === createRes.id)?.name).toBe( tagName, ); }); test("Remote mod creates and updates post tag", async () => { // Create a community let communityRes = await createCommunity(alpha); let alphaCommunity = communityRes.community_view; // add gamma as remote mod let gammaOnAlpha = await resolvePerson(alpha, "lemmy_gamma@lemmy-gamma:8561"); let form: AddModToCommunity = { community_id: communityRes.community_view.community.id, person_id: gammaOnAlpha?.person.id as number, added: true, }; alpha.addModToCommunity(form); let gammaCommunity = await resolveCommunity( gamma, alphaCommunity.community.ap_id, ); // Remote mod gamma creates tag const tag1Name = "news"; let createForm1: CreateCommunityTag = { name: tag1Name, community_id: gammaCommunity!.community.id, }; let tag1Res = await gamma.createCommunityTag(createForm1); expect(tag1Res.id).toBeDefined(); await waitUntil( () => getCommunity(alpha, communityRes.community_view.community.id), c => c.community_view.tags.length == 1, ); let betaCommunity = await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), c => c?.tags.length == 1, ); // follow from beta await followCommunity(beta, true, betaCommunity!.community.id); await waitUntil( () => resolveCommunity(beta, alphaCommunity.community.ap_id), g => g!.community_actions?.follow_state == "accepted", ); // Create a post with tag let postRes = await beta.createPost({ name: randomString(10), community_id: betaCommunity!.community.id, tags: [betaCommunity!.tags[0].id], }); expect(postRes.post_view.post.id).toBeDefined(); expect(postRes.post_view.post.id).toBe(postRes.post_view.post.id); expect(postRes.post_view.tags?.length).toBe(1); expect(postRes.post_view.tags?.map(t => t.id)).toEqual([ betaCommunity!.tags[0].id, ]); // wait post tags federated let alphaPost = await waitForPost( alpha, postRes.post_view.post, p => (p?.tags.length ?? 0) > 0, ); expect(alphaPost?.tags.length).toBe(1); expect(alphaPost?.tags.map(t => t.ap_id)).toEqual([tag1Res.ap_id]); // Mod on alpha updates post to remove one tag communityRes = await getCommunity( alpha, communityRes.community_view.community.id, ); alphaCommunity = communityRes.community_view; let updateRes = await alpha.modEditPost({ post_id: alphaPost.post.id, tags: [alphaCommunity!.tags[0].id], }); expect(updateRes.post_view.post.ap_id).toBe(postRes.post_view.post.ap_id); expect(updateRes.post_view.tags?.length).toBe(1); expect(updateRes.post_view.tags?.[0].id).toBe(alphaCommunity!.tags[0].id); // wait post tags federated let betaPost = await waitForPost(beta, postRes.post_view.post, p => { return (p?.tags.length ?? 0) === 1; }); expect(betaPost?.tags.map(t => t.ap_id)).toEqual([tag1Res.ap_id]); }); ================================================ FILE: api_tests/src/user.spec.ts ================================================ jest.setTimeout(120000); import { PersonView } from "lemmy-js-client/dist/types/PersonView"; import { alpha, beta, registerUser, resolvePerson, getSite, createPost, resolveCommunity, createComment, resolveBetaCommunity, deleteUser, saveUserSettingsFederated, setupLogins, alphaUrl, betaUrl, saveUserSettings, getPost, getComments, fetchFunction, alphaImage, unfollows, getMyUser, getPersonDetails, banPersonFromSite, statusNotFound, statusUnauthorized, listPersonContent, waitUntil, password, jestLemmyError, statusBadRequest, randomString, } from "./shared"; import { EditSite, LemmyError, LemmyHttp, SaveUserSettings, UploadImage, } from "lemmy-js-client"; import { GetPosts } from "lemmy-js-client/dist/types/GetPosts"; beforeAll(setupLogins); afterAll(unfollows); let apShortname: string; function assertUserFederation(userOne?: PersonView, userTwo?: PersonView) { expect(userOne?.person.name).toBe(userTwo?.person.name); expect(userOne?.person.display_name).toBe(userTwo?.person.display_name); expect(userOne?.person.bio).toBe(userTwo?.person.bio); expect(userOne?.person.ap_id).toBe(userTwo?.person.ap_id); expect(userOne?.person.avatar).toBe(userTwo?.person.avatar); expect(userOne?.person.banner).toBe(userTwo?.person.banner); expect(userOne?.person.published_at).toBe(userTwo?.person.published_at); } test("Create user", async () => { let user = await registerUser(alpha, alphaUrl); let myUser = await getMyUser(user); expect(myUser).toBeDefined(); apShortname = `${myUser.local_user_view.person.name}@lemmy-alpha:8541`; }); test("Set some user settings, check that they are federated", async () => { await saveUserSettingsFederated(alpha); let alphaPerson = await resolvePerson(alpha, apShortname); let betaPerson = await resolvePerson(beta, apShortname); assertUserFederation(alphaPerson, betaPerson); // Catches a bug where when only the person or local_user changed let form: SaveUserSettings = { theme: "test", }; await saveUserSettings(beta, form); let my_user = await getMyUser(beta); expect(my_user.local_user_view.local_user.theme).toBe("test"); }); test("Delete user", async () => { let user = await registerUser(alpha, alphaUrl); let user_profile = await getMyUser(user); let person_id = user_profile.local_user_view.person.id; // make a local post and comment let alphaCommunity = await resolveCommunity(user, "main@lemmy-alpha:8541"); if (!alphaCommunity) { throw "Missing alpha community"; } let localPost = (await createPost(user, alphaCommunity.community.id)) .post_view.post; expect(localPost).toBeDefined(); let localComment = (await createComment(user, localPost.id)).comment_view .comment; expect(localComment).toBeDefined(); // make a remote post and comment let betaCommunity = await resolveBetaCommunity(user); if (!betaCommunity) { throw "Missing beta community"; } let remotePost = (await createPost(user, betaCommunity.community.id)) .post_view.post; expect(remotePost).toBeDefined(); let remoteComment = (await createComment(user, remotePost.id)).comment_view .comment; expect(remoteComment).toBeDefined(); await deleteUser(user); // Wait, in order to make sure it federates await jestLemmyError( () => getMyUser(user), new LemmyError("incorrect_login", statusUnauthorized), ); await jestLemmyError( () => getPersonDetails(user, person_id), new LemmyError("not_found", statusNotFound), ); // check that posts and comments are marked as deleted on other instances. // use get methods to avoid refetching from origin instance expect((await getPost(alpha, localPost.id)).post_view.post.deleted).toBe( true, ); // Make sure the remote post is deleted. // TODO this fails occasionally // Probably because it could return a not_found // await waitUntil( // () => getPost(alpha, remotePost.id), // p => p.post_view.post.deleted === true || p.post_view.post === undefined, // ); await waitUntil( () => getComments(alpha, localComment.post_id), c => c.items[0].comment.deleted, ); await waitUntil( () => alpha.getComment({ id: remoteComment.id }), c => c.comment_view.comment.deleted, ); await jestLemmyError( () => getPersonDetails(user, remoteComment.creator_id), new LemmyError("not_found", statusNotFound), ); }); test("Requests with invalid auth should be treated as unauthenticated", async () => { let invalid_auth = new LemmyHttp(alphaUrl, { headers: { Authorization: "Bearer foobar" }, fetchFunction, }); await jestLemmyError( () => getMyUser(invalid_auth), new LemmyError("incorrect_login", statusUnauthorized), ); let site = await getSite(invalid_auth); expect(site.site_view).toBeDefined(); let form: GetPosts = {}; let posts = invalid_auth.getPosts(form); expect((await posts).items).toBeDefined(); }); test("Create user with Arabic name", async () => { // less than actor_name_max_length const name = "تجريب" + Math.random().toString().slice(2, 10); let user = await registerUser(alpha, alphaUrl, name); let my_user = await getMyUser(user); expect(my_user).toBeDefined(); apShortname = `${my_user.local_user_view.person.name}@lemmy-alpha:8541`; let betaPerson1 = await resolvePerson(beta, apShortname); expect(betaPerson1!.person.name).toBe(name); let betaPerson2 = await getPersonDetails(beta, betaPerson1!.person.id); expect(betaPerson2!.person_view.person.name).toBe(name); }); test("Create user with accept-language", async () => { const edit: EditSite = { discussion_languages: [32], }; await alpha.editSite(edit); let lemmy_http = new LemmyHttp(alphaUrl, { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language#syntax headers: { "Accept-Language": "fr-CH, en;q=0.8, *;q=0.5" }, }); let user = await registerUser(lemmy_http, alphaUrl); let my_user = await getMyUser(user); expect(my_user).toBeDefined(); expect(my_user?.local_user_view.local_user.interface_language).toBe("fr"); let site = await getSite(user); let langs = site.all_languages .filter(a => my_user.discussion_languages.includes(a.id)) .map(l => l.code) .sort(); // should have languages from accept header, as well as "undetermined" // which is automatically enabled by backend expect(langs).toStrictEqual(["de", "en", "fr"]); }); test("Set a new avatar, old avatar is deleted", async () => { const listMediaRes = await alphaImage.listMedia(); expect(listMediaRes.items.length).toBe(0); const upload_form1: UploadImage = { image: Buffer.from("test1"), }; await alpha.uploadUserAvatar(upload_form1); const listMediaRes1 = await alphaImage.listMedia(); expect(listMediaRes1.items.length).toBe(1); let my_user1 = await alpha.getMyUser(); expect(my_user1.local_user_view.person.avatar).toBeDefined(); const upload_form2: UploadImage = { image: Buffer.from("test2"), }; await alpha.uploadUserAvatar(upload_form2); // make sure only the new avatar is kept const listMediaRes2 = await alphaImage.listMedia(); expect(listMediaRes2.items.length).toBe(1); // Upload that same form2 avatar, make sure it isn't replaced / deleted await alpha.uploadUserAvatar(upload_form2); // make sure only the new avatar is kept const listMediaRes3 = await alphaImage.listMedia(); expect(listMediaRes3.items.length).toBe(1); // make sure only the new avatar is kept const listMediaRes4 = await alphaImage.listMedia(); expect(listMediaRes4.items.length).toBe(1); // delete the avatar await alpha.deleteUserAvatar(); // make sure only the new avatar is kept const listMediaRes5 = await alphaImage.listMedia(); expect(listMediaRes5.items.length).toBe(0); let my_user2 = await alpha.getMyUser(); expect(my_user2.local_user_view.person.avatar).toBeUndefined(); }); test("Make sure banned user can delete their account", async () => { let user = await registerUser(alpha, alphaUrl); let myUser = await getMyUser(user); // make a local post let alphaCommunity = await resolveCommunity(user, "main@lemmy-alpha:8541"); if (!alphaCommunity) { throw "Missing alpha community"; } let localPost = (await createPost(user, alphaCommunity.community.id)) .post_view.post; let postId = localPost.id; expect(localPost).toBeDefined(); // Ban the user, keep data let banUser = await banPersonFromSite( alpha, myUser.local_user_view.person.id, true, false, ); expect(banUser.person_view.banned).toBe(true); // Make sure post is there let postAfterBan = await getPost(alpha, postId); expect(postAfterBan.post_view.post.deleted).toBe(false); // Delete account let deleteAccount = await deleteUser(user); expect(deleteAccount).toBeDefined(); // Make sure post is gone let postAfterDelete = await getPost(alpha, postId); expect(postAfterDelete.post_view.post.deleted).toBe(true); expect(postAfterDelete.post_view.post.name).toBe("*Permanently Deleted*"); }); test("Admins can view and ban deleted accounts", async () => { let user = await registerUser(beta, betaUrl); let myUser = await getMyUser(user); let apShortname = `${myUser.local_user_view.person.name}@lemmy-beta:8551`; let userOnAlpha = await resolvePerson(alpha, apShortname); let alphaCommunity = await resolveCommunity(user, "main@lemmy-alpha:8541"); if (!alphaCommunity) { throw "Missing alpha community"; } // Make a post and then delete the account let postRes = await createPost(user, alphaCommunity.community.id); let deletedUser = await deleteUser(user, false); expect(deletedUser).toBeDefined(); // Make sure the post is still visible let postAfterDelete = await getPost(beta, postRes.post_view.post.id); expect(postAfterDelete.post_view.post.deleted).toBe(false); // Ensure admins can still resolve the user let getDeletedUser = await getPersonDetails( beta, myUser.local_user_view.person.id, ); expect(getDeletedUser).toBeDefined(); // Make sure the delete federates await waitUntil( () => getPersonDetails(alpha, userOnAlpha!.person.id), p => p.person_view.person.deleted, ); // Ban the user let banUser = await banPersonFromSite( beta, myUser.local_user_view.person.id, true, true, ); expect(banUser.person_view.banned).toBe(true); // Make sure the post is removed let postAfterBan = await getPost(beta, postRes.post_view.post.id); expect(postAfterBan.post_view.post.removed).toBe(true); // Make sure the ban federates properly let getDeletedUserAlpha = await waitUntil( () => getPersonDetails(alpha, userOnAlpha!.person.id), p => p.person_view.banned, ); // Make sure content removal also went through let userContent = await listPersonContent( alpha, getDeletedUserAlpha.person_view.person.id, "posts", ); expect(userContent.items[0].post.removed).toBe(true); }); test("Make sure a denied user is given denial reason", async () => { const username = randomString(10); const appAnswer = "My application answer"; const denyReason = "Bad application given"; // Make registrations approval only await alpha.editSite({ registration_mode: "require_application" }); // Create an account with an answer const login = await alpha.register({ username, password, password_verify: password, show_nsfw: true, answer: appAnswer, }); expect(login.registration_created).toBeTruthy(); expect(login.jwt).toBeUndefined(); // Try to login with a bad password first await jestLemmyError( () => alpha.login({ username_or_email: username, password: "wrong_password" }), new LemmyError("incorrect_login", statusUnauthorized), ); // Try to login without approval yet, should return is pending await jestLemmyError( () => alpha.login({ username_or_email: username, password }), new LemmyError("registration_application_is_pending", statusBadRequest), ); // Fetch the applications const apps = await alpha.listRegistrationApplications({}); const app = apps.items[0]; expect(apps.items.length).toBeGreaterThanOrEqual(1); expect(app.registration_application.answer).toBe(appAnswer); // Deny the application await alpha.approveRegistrationApplication({ id: app.registration_application.id, approve: false, deny_reason: denyReason, }); // Should give the denial reason in the error. await jestLemmyError( () => alpha.login({ username_or_email: username, password }), new LemmyError("registration_denied", statusBadRequest, denyReason), ); // Re-open alpha await alpha.editSite({ registration_mode: "open" }); }); ================================================ FILE: api_tests/tsconfig.json ================================================ { "compilerOptions": { "declaration": true, "declarationDir": "./dist", "module": "CommonJS", "noImplicitAny": true, "lib": ["es2022", "es7", "es6", "dom"], "outDir": "./dist", "target": "ES2020", "strictNullChecks": true, "moduleResolution": "Node" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ================================================ FILE: cliff.toml ================================================ # git-cliff ~ configuration file # https://git-cliff.org/docs/configuration [remote.github] owner = "LemmyNet" repo = "lemmy" # token = "" [changelog] # A Tera template to be rendered for each release in the changelog. # See https://keats.github.io/tera/docs/#introduction body = """ ## What's Changed {%- if version %} in {{ version }}{%- endif -%} {% for commit in commits %} {% if commit.remote.pr_title -%} {%- set commit_message = commit.remote.pr_title -%} {%- else -%} {%- set commit_message = commit.message -%} {%- endif -%} * {{ commit_message | split(pat="\n") | first | trim }}\ {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} {% if commit.remote.pr_number %} in \ [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ {%- endif %} {%- endfor -%} {%- if github -%} {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} {% raw %}\n{% endraw -%} ## New Contributors {%- endif %}\ {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} * @{{ contributor.username }} made their first contribution {%- if contributor.pr_number %} in \ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ {%- endif %} {%- endfor -%} {%- endif -%} {% if version %} {% if previous.version %} **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} {% endif %} {% else -%} {% raw %}\n{% endraw %} {% endif %} {%- macro remote_url() -%} https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} {%- endmacro -%} """ # Remove leading and trailing whitespaces from the changelog's body. trim = true # A Tera template to be rendered as the changelog's footer. # See https://keats.github.io/tera/docs/#introduction footer = """ """ # An array of regex based postprocessors to modify the changelog. # Replace the placeholder `` with a URL. postprocessors = [] [git] # Parse commits according to the conventional commits specification. # See https://www.conventionalcommits.org conventional_commits = false # Exclude commits that do not match the conventional commits specification. filter_unconventional = true # Split commits on newlines, treating each line as an individual commit. split_commits = false # An array of regex based parsers to modify commit messages prior to further processing. commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }] # Exclude commits that are not matched by any commit parser. commit_parsers = [{ field = "author.name", pattern = "renovate", skip = true }] filter_commits = false # Order releases topologically instead of chronologically. topo_order = false # Order of commits in each group/release within the changelog. # Allowed values: newest, oldest sort_commits = "newest" ================================================ FILE: config/config.hjson ================================================ # See the documentation for available config fields and descriptions: # https://join-lemmy.org/docs/en/administration/configuration.html { hostname: lemmy-alpha } ================================================ FILE: config/defaults.hjson ================================================ { # settings related to the postgresql database database: { # Configure the database by specifying URI pointing to a postgres instance. This parameter can # also be set by environment variable `LEMMY_DATABASE_URL`. # # For an explanation of how to use connection URIs, see PostgreSQL's documentation: # https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6 connection: "postgres://lemmy:password@localhost:5432/lemmy" # Maximum number of active sql connections # # A high value here can result in errors "could not resize shared memory segment". In this case # it is necessary to increase shared memory size in Docker: https://stackoverflow.com/a/56754077 pool_size: 30 } # Pictrs image server configuration. pictrs: { # Address where pictrs is available (for image hosting) url: "http://localhost:8080/" # Set a custom pictrs API key. ( Required for deleting images ) api_key: "string" } # Email sending configuration. All options except login/password are mandatory email: { # https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url connection: "smtps://user:pass@hostname:port" # Address to send emails from, eg "noreply@your-instance.com" smtp_from_address: "noreply@example.com" } # Parameters for automatic configuration of new instance (only used at first start) setup: { # Username for the admin user admin_username: "admin" # Password for the admin user. It must be between 10 and 60 characters. admin_password: "tf6HHDS4RolWfFhk4Rq9" # Name of the site, can be changed later. Maximum 20 characters. site_name: "My Lemmy Instance" # Email for the admin user (optional, can be omitted and set later through the website) admin_email: "user@example.com" # On first start Lemmy fetches the 50 most active communities from one of these instances, # to provide some initial data. It tries the first list entry, and if it fails uses subsequent # instances as fallback. # Leave this empty to disable community bootstrap. # TODO: remove voyager.lemmy.ml from defaults once Lemmy 1.0 is deployed to production # instances. bootstrap_instances: [ "string" /* ... */ ] } # the domain name of your instance (mandatory) hostname: "unset" # Address where lemmy should listen for incoming requests bind: "0.0.0.0" # Port where lemmy should listen for incoming requests port: 8536 # Whether the site is available over TLS. Needs to be true for federation to work. tls_enabled: true federation: { # Limit to the number of concurrent outgoing federation requests per target instance. # Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities # per second) and if a receiving instance is not keeping up. concurrent_sends_per_instance: 1 } prometheus: { bind: "127.0.0.1" port: 10002 } # Sets a response Access-Control-Allow-Origin CORS header. Can also be set via environment: # `LEMMY_CORS_ORIGIN=example.org,site.com` # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin cors_origin: [ "lemmy.tld" /* ... */ ] # Print logs in JSON format. You can also disable ANSI colors in logs with env var `NO_COLOR`. json_logging: false # Data for loading Lemmy plugins plugins: [ { # Where to load the .wasm file from, can be a local file path or URL file: "https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.1/go_replace_words.wasm" # SHA256 hash of the .wasm file hash: "37cdc01a3ff26eef578b668c6cc57fc06649deddb3a92cb6bae8e79b4e60fe12" # Which websites the plugin may connect to allowed_hosts: [ "lemmy.ml" /* ... */ ] # Configuration options for the plugin config: { string: "string" /* ... */ } } /* ... */ ] } ================================================ FILE: crates/api/api/Cargo.toml ================================================ [package] name = "lemmy_api" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_api" path = "src/lib.rs" doctest = false [lints] workspace = true [features] full = [] [dependencies] lemmy_db_views_comment = { workspace = true, features = ["full"] } lemmy_db_views_community = { workspace = true, features = ["full"] } lemmy_db_views_community_moderator = { workspace = true, features = ["full"] } lemmy_db_views_community_follower_approval = { workspace = true, features = [ "full", ] } lemmy_db_views_community_follower = { workspace = true, features = ["full"] } lemmy_apub_objects = { workspace = true, features = ["full"] } lemmy_db_views_post = { workspace = true, features = ["full"] } lemmy_db_views_vote = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_search_combined = { workspace = true, features = ["full"] } lemmy_db_views_person = { workspace = true, features = ["full"] } lemmy_db_views_local_image = { workspace = true, features = ["full"] } lemmy_db_views_notification = { workspace = true, features = ["full"] } lemmy_db_views_modlog = { workspace = true, features = ["full"] } lemmy_db_views_person_saved_combined = { workspace = true, features = ["full"] } lemmy_db_views_person_liked_combined = { workspace = true, features = ["full"] } lemmy_db_views_post_comment_combined = { workspace = true, features = ["full"] } lemmy_db_views_person_content_combined = { workspace = true, features = [ "full", ] } lemmy_db_views_report_combined = { workspace = true, features = ["full"] } lemmy_db_views_site = { workspace = true, features = ["full"] } lemmy_db_views_registration_applications = { workspace = true, features = [ "full", ] } lemmy_utils = { workspace = true } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true } lemmy_db_schema_file = { workspace = true } lemmy_email = { workspace = true } activitypub_federation = { workspace = true } tracing = { workspace = true } bcrypt = { workspace = true } actix-web = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } url = { workspace = true } regex = { workspace = true } sitemap-rs = "0.4.0" totp-rs = { version = "5.7.0", features = ["gen_secret", "otpauth"] } diesel-async = { workspace = true, features = ["deadpool", "postgres"] } either = { workspace = true } futures = { workspace = true } serde = { workspace = true } itertools = { workspace = true } serde_json = { workspace = true } diesel = { workspace = true } lemmy_diesel_utils = { workspace = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } elementtree = "1.2.3" pretty_assertions = { workspace = true } lemmy_api_crud = { workspace = true } ================================================ FILE: crates/api/api/src/comment/distinguish.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_mod_action, check_community_user_action}, }; use lemmy_db_schema::source::comment::{Comment, CommentUpdateForm}; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, DistinguishComment}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn distinguish_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_instance_id = local_user_view.person.instance_id; let orig_comment = CommentView::read( &mut context.pool(), data.comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; check_community_user_action( &local_user_view, &orig_comment.community, &mut context.pool(), ) .await?; // Verify that only the creator can distinguish if local_user_view.person.id != orig_comment.creator.id { return Err(LemmyErrorType::NoCommentEditAllowed.into()); } // Verify that only a mod or admin can distinguish a comment check_community_mod_action( &local_user_view, &orig_comment.community, false, &mut context.pool(), ) .await?; // Update the Comment let form = CommentUpdateForm { distinguished: Some(data.distinguished), ..Default::default() }; let comment = Comment::update(&mut context.pool(), data.comment_id, &form).await?; ActivityChannel::submit_activity(SendActivityData::UpdateComment(comment), &context)?; let comment_view = CommentView::read( &mut context.pool(), data.comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; Ok(Json(CommentResponse { comment_view })) } ================================================ FILE: crates/api/api/src/comment/like.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_bot_account, check_community_user_action, check_local_user_valid, check_local_vote_mode, }, }; use lemmy_db_schema::{ newtypes::PostOrCommentId, source::{ comment::{CommentActions, CommentLikeForm}, notification::Notification, person::PersonActions, }, traits::Likeable, }; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, CreateCommentLike}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; use std::ops::Deref; pub async fn like_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let local_instance_id = local_user_view.person.instance_id; let comment_id = data.comment_id; let my_person_id = local_user_view.person.id; check_local_vote_mode( data.is_upvote, PostOrCommentId::Comment(comment_id), &local_site, my_person_id, &mut context.pool(), ) .await?; check_bot_account(&local_user_view.person)?; let orig_comment = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; let previous_is_upvote = orig_comment.comment_actions.and_then(|p| p.vote_is_upvote); check_community_user_action( &local_user_view, &orig_comment.community, &mut context.pool(), ) .await?; let mut like_form = CommentLikeForm::new(data.comment_id, my_person_id, data.is_upvote); like_form = plugin_hook_before("comment_before_vote", like_form).await?; let like = CommentActions::like(&mut context.pool(), &like_form).await?; PersonActions::like( &mut context.pool(), my_person_id, orig_comment.creator.id, previous_is_upvote, data.is_upvote, ) .await?; plugin_hook_after("comment_after_vote", &like); // Mark any notification as read Notification::mark_read_by_comment_and_recipient( &mut context.pool(), comment_id, my_person_id, true, ) .await .ok(); ActivityChannel::submit_activity( SendActivityData::LikePostOrComment { object_id: orig_comment.comment.ap_id, actor: local_user_view.person.clone(), community: orig_comment.community, previous_is_upvote, new_is_upvote: data.is_upvote, }, &context, )?; Ok(Json( build_comment_response( context.deref(), comment_id, Some(local_user_view), local_instance_id, ) .await?, )) } ================================================ FILE: crates/api/api/src/comment/list_comment_likes.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::is_mod_or_admin}; use lemmy_db_views_comment::{CommentView, api::ListCommentLikes}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_vote::VoteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; /// Lists likes for a comment pub async fn list_comment_likes( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let local_instance_id = local_user_view.person.instance_id; let comment_view = CommentView::read( &mut context.pool(), data.comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; is_mod_or_admin( &mut context.pool(), &local_user_view, comment_view.community.id, ) .await?; let comment_likes = VoteView::list_for_comment( &mut context.pool(), data.comment_id, data.page_cursor, data.limit, local_instance_id, ) .await?; Ok(Json(comment_likes)) } ================================================ FILE: crates/api/api/src/comment/lock.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::source::{ comment::Comment, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, LockComment}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn lock_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let comment_id = data.comment_id; let local_instance_id = local_user_view.person.instance_id; let locked = data.locked; let orig_comment = CommentView::read(&mut context.pool(), comment_id, None, local_instance_id).await?; check_community_mod_action( &local_user_view, &orig_comment.community, false, &mut context.pool(), ) .await?; let comments = Comment::update_locked_for_comment_and_children( &mut context.pool(), &orig_comment.comment.path, locked, ) .await?; let comment = comments.first().ok_or(LemmyErrorType::NotFound)?; let form = ModlogInsertForm::mod_lock_comment( local_user_view.person.id, comment, orig_comment.community.id, locked, &data.reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), &context); ActivityChannel::submit_activity( SendActivityData::LockComment( comment.clone(), local_user_view.person.clone(), data.locked, data.reason.clone(), ), &context, )?; build_comment_response( &context, comment_id, local_user_view.into(), local_instance_id, ) .await .map(Json) } ================================================ FILE: crates/api/api/src/comment/mod.rs ================================================ pub mod distinguish; pub mod like; pub mod list_comment_likes; pub mod lock; pub mod save; pub mod warning; ================================================ FILE: crates/api/api/src/comment/save.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::{ source::comment::{CommentActions, CommentSavedForm}, traits::Saveable, }; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, SaveComment}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::error::LemmyResult; pub async fn save_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let comment_saved_form = CommentSavedForm::new(local_user_view.person.id, data.comment_id); if data.save { CommentActions::save(&mut context.pool(), &comment_saved_form).await?; } else { CommentActions::unsave(&mut context.pool(), &comment_saved_form).await?; } let comment_id = data.comment_id; let local_instance_id = local_user_view.person.instance_id; let comment_view = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; Ok(Json(CommentResponse { comment_view })) } ================================================ FILE: crates/api/api/src/comment/warning.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, utils::{check_comment_deleted_or_removed, check_community_mod_action}, }; use lemmy_db_schema::source::modlog::{Modlog, ModlogInsertForm}; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, CreateCommentWarning}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::error::LemmyResult; /// Creates a warning against a comment and notifies the user pub async fn create_comment_warning( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_instance_id = local_user_view.person.instance_id; let comment_id = data.comment_id; let orig_comment = CommentView::read(&mut context.pool(), comment_id, None, local_instance_id).await?; check_community_mod_action( &local_user_view, &orig_comment.community, false, &mut context.pool(), ) .await?; // Don't allow creating warnings for removed / deleted comments check_comment_deleted_or_removed(&orig_comment.comment)?; let form = ModlogInsertForm::mod_create_comment_warning( local_user_view.person.id, &orig_comment.comment, orig_comment.community.id, &data.reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, &context); // TODO federate activity Ok(Json(CommentResponse { comment_view: orig_comment, })) } ================================================ FILE: crates/api/api/src/community/add_mod.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use diesel_async::scoped_futures::ScopedFutureExt; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::source::{ community::{Community, CommunityActions, CommunityModeratorForm}, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_community::api::{AddModToCommunity, AddModToCommunityResponse}; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{connection::get_conn, traits::Crud}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn add_mod_to_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let community = Community::read(&mut context.pool(), data.community_id).await?; // Verify that only mods or admins can add mod check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?; // If it's a mod removal, also check that you're a higher mod. if !data.added { LocalUser::is_higher_mod_or_admin_check( &mut context.pool(), community.id, local_user_view.person.id, vec![data.person_id], ) .await?; // Dont allow the last community mod to remove himself let mods = CommunityModeratorView::for_community(&mut context.pool(), community.id).await?; if !local_user_view.local_user.admin && mods.len() == 1 { return Err(LemmyErrorType::CannotLeaveMod.into()); } } // If user is admin and community is remote, explicitly check that he is a // moderator. This is necessary because otherwise the action would be rejected // by the community's home instance. if local_user_view.local_user.admin && !community.local { CommunityModeratorView::check_is_community_moderator( &mut context.pool(), community.id, local_user_view.person.id, ) .await?; } let pool = &mut context.pool(); let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); let action = conn .run_transaction(|conn| { async move { // Update in local database let community_moderator_form = CommunityModeratorForm::new(tx_data.community_id, tx_data.person_id); if tx_data.added { CommunityActions::join(&mut conn.into(), &community_moderator_form).await?; } else { CommunityActions::leave(&mut conn.into(), &community_moderator_form).await?; } // Mod tables let form = ModlogInsertForm::mod_add_to_community( local_user_view.person.id, tx_data.community_id, tx_data.person_id, !tx_data.added, ); Modlog::create(&mut conn.into(), &[form]).await } .scope_boxed() }) .await?; notify_mod_action(action.clone(), &context); // Note: in case a remote mod is added, this returns the old moderators list, it will only get // updated once we receive an activity from the community (like `Announce/Add/Moderator`) let community_id = data.community_id; let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?; ActivityChannel::submit_activity( SendActivityData::AddModToCommunity { moderator: local_user_view.person, community_id: data.community_id, target: data.person_id, added: data.added, }, &context, )?; Ok(Json(AddModToCommunityResponse { moderators })) } ================================================ FILE: crates/api/api/src/community/ban.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use diesel_async::scoped_futures::ScopedFutureExt; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_community_mod_action, check_expire_time, remove_or_restore_user_data_in_community, }, }; use lemmy_db_schema::{ source::{ community::{Community, CommunityActions, CommunityPersonBanForm}, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, }, traits::{Bannable, Followable}, }; use lemmy_db_views_community::api::BanFromCommunity; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::{PersonView, api::PersonResponse}; use lemmy_diesel_utils::{connection::get_conn, traits::Crud}; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::is_valid_body_field, }; pub async fn ban_from_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let banned_person_id = data.person_id; let my_person_id = local_user_view.person.id; let expires_at = check_expire_time(data.expires_at)?; let local_instance_id = local_user_view.person.instance_id; let community = Community::read(&mut context.pool(), data.community_id).await?; // Verify that only mods or admins can ban check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?; LocalUser::is_higher_mod_or_admin_check( &mut context.pool(), data.community_id, my_person_id, vec![data.person_id], ) .await?; is_valid_body_field(&data.reason, false)?; let community_user_ban_form = CommunityPersonBanForm { ban_expires_at: Some(expires_at), ..CommunityPersonBanForm::new(data.community_id, data.person_id) }; let pool = &mut context.pool(); let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); let action = conn .run_transaction(|conn| { async move { if tx_data.ban { CommunityActions::ban(&mut conn.into(), &community_user_ban_form).await?; // Also unsubscribe them from the community, if they are subscribed CommunityActions::unfollow(&mut conn.into(), banned_person_id, tx_data.community_id) .await .ok(); } else { CommunityActions::unban(&mut conn.into(), &community_user_ban_form).await?; } // Mod tables - create ban entry first so bulk actions can reference it as parent let form = ModlogInsertForm::mod_ban_from_community( my_person_id, tx_data.community_id, tx_data.person_id, tx_data.ban, expires_at, &tx_data.reason, ); let action = Modlog::create(&mut conn.into(), &[form]).await?; // Remove/Restore their data if that's desired let ban_id = action.first().ok_or(LemmyErrorType::NotFound)?.id; if tx_data.remove_or_restore_data.unwrap_or(false) { let remove_data = tx_data.ban; remove_or_restore_user_data_in_community( tx_data.community_id, my_person_id, banned_person_id, remove_data, &tx_data.reason, ban_id, &mut conn.into(), ) .await?; }; Ok(action) } .scope_boxed() }) .await?; notify_mod_action(action.clone(), &context); let person_view = PersonView::read( &mut context.pool(), data.person_id, Some(my_person_id), local_instance_id, true, ) .await?; ActivityChannel::submit_activity( SendActivityData::BanFromCommunity { moderator: local_user_view.person, community_id: data.community_id, target: person_view.person.clone(), data: data.clone(), }, &context, )?; Ok(Json(PersonResponse { person_view })) } ================================================ FILE: crates/api/api/src/community/block.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use diesel_async::scoped_futures::ScopedFutureExt; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_local_user_valid, }; use lemmy_db_schema::{ source::{ actor_language::CommunityLanguage, community::{CommunityActions, CommunityBlockForm}, }, traits::{Blockable, Followable}, }; use lemmy_db_views_community::{ CommunityView, api::{BlockCommunity, CommunityResponse}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::connection::get_conn; use lemmy_utils::error::LemmyResult; pub async fn user_block_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let community_id = data.community_id; let person_id = local_user_view.person.id; let community_block_form = CommunityBlockForm::new(community_id, person_id); let pool = &mut context.pool(); let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); conn .run_transaction(|conn| { async move { if tx_data.block { CommunityActions::block(&mut conn.into(), &community_block_form).await?; // Also, unfollow the community, and send a federated unfollow CommunityActions::unfollow(&mut conn.into(), person_id, tx_data.community_id) .await .ok(); } else { CommunityActions::unblock(&mut conn.into(), &community_block_form).await?; } Ok(()) } .scope_boxed() }) .await?; let community_view = CommunityView::read( &mut context.pool(), community_id, Some(&local_user_view.local_user), false, ) .await?; ActivityChannel::submit_activity( SendActivityData::FollowCommunity( community_view.community.clone(), local_user_view.person.clone(), false, ), &context, )?; let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?; Ok(Json(CommunityResponse { community_view, discussion_languages, })) } ================================================ FILE: crates/api/api/src/community/follow.rs ================================================ use crate::community::do_follow_community; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::source::{actor_language::CommunityLanguage, community::Community}; use lemmy_db_views_community::{ CommunityView, api::{CommunityResponse, FollowCommunity}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn follow_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let community_id = data.community_id; let community = Community::read(&mut context.pool(), community_id).await?; do_follow_community(community, &local_user_view.person, data.follow, &context).await?; let community_view = CommunityView::read( &mut context.pool(), community_id, Some(&local_user_view.local_user), false, ) .await?; let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?; Ok(Json(CommunityResponse { community_view, discussion_languages, })) } ================================================ FILE: crates/api/api/src/community/mod.rs ================================================ use activitypub_federation::config::Data; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_deleted_removed, }; use lemmy_db_schema::{ source::{ community::{Community, CommunityActions, CommunityFollowerForm}, person::Person, }, traits::Followable, }; use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility}; use lemmy_db_views_community_moderator::CommunityPersonBanView; use lemmy_utils::error::LemmyResult; pub mod add_mod; pub mod ban; pub mod block; pub mod follow; pub mod multi_community_follow; pub mod pending_follows; pub mod random; pub mod tag; pub mod transfer; pub mod update_notifications; pub(super) async fn do_follow_community( community: Community, person: &Person, follow: bool, context: &Data, ) -> LemmyResult<()> { if follow { // Only run these checks for local community, in case of remote community the local // state may be outdated. Can't use check_community_user_action() here as it only allows // actions from existing followers for private community (so following would be impossible). if community.local { check_community_deleted_removed(&community)?; CommunityPersonBanView::check(&mut context.pool(), person.id, community.id).await?; } let follow_state = if community.visibility == CommunityVisibility::Private { // Private communities require manual approval CommunityFollowerState::ApprovalRequired } else if community.local { // Local follow is accepted immediately CommunityFollowerState::Accepted } else { // remote follow needs to be federated first CommunityFollowerState::Pending }; let form = CommunityFollowerForm::new(community.id, person.id, follow_state); // Write to db CommunityActions::follow(&mut context.pool(), &form).await?; } else { CommunityActions::unfollow(&mut context.pool(), person.id, community.id).await?; } // Send the federated follow if !community.local { ActivityChannel::submit_activity( SendActivityData::FollowCommunity(community, person.clone(), follow), context, )?; } Ok(()) } ================================================ FILE: crates/api/api/src/community/multi_community_follow.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_local_user_valid, }; use lemmy_db_schema::source::multi_community::{MultiCommunity, MultiCommunityFollowForm}; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_community::{ MultiCommunityView, api::{FollowMultiCommunity, MultiCommunityResponse}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn follow_multi_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let multi_community_id = data.multi_community_id; let my_person_id = local_user_view.person.id; let multi = MultiCommunity::read(&mut context.pool(), multi_community_id).await?; let follow_state = if multi.local { CommunityFollowerState::Accepted } else { CommunityFollowerState::Pending }; let form = MultiCommunityFollowForm { multi_community_id, person_id: my_person_id, follow_state, }; if data.follow { MultiCommunity::follow(&mut context.pool(), &form).await?; } else { MultiCommunity::unfollow(&mut context.pool(), my_person_id, multi_community_id).await?; } if !multi.local { ActivityChannel::submit_activity( SendActivityData::FollowMultiCommunity(multi, local_user_view.person.clone(), data.follow), &context, )?; } let multi_community_view = MultiCommunityView::read(&mut context.pool(), multi_community_id, Some(my_person_id)).await?; Ok(Json(MultiCommunityResponse { multi_community_view, })) } ================================================ FILE: crates/api/api/src/community/pending_follows/approve.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::is_mod_or_admin, }; use lemmy_db_schema::source::community::CommunityActions; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_community::api::ApproveCommunityPendingFollower; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::LemmyResult; pub async fn post_pending_follows_approve( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { is_mod_or_admin(&mut context.pool(), &local_user_view, data.community_id).await?; let (state, activity_data) = if data.approve { ( CommunityFollowerState::Accepted, SendActivityData::AcceptFollower(data.community_id, data.follower_id), ) } else { ( CommunityFollowerState::Denied, SendActivityData::RejectFollower(data.community_id, data.follower_id), ) }; CommunityActions::approve_private_community_follower( &mut context.pool(), data.community_id, data.follower_id, local_user_view.person.id, state, ) .await?; ActivityChannel::submit_activity(activity_data, &context)?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/community/pending_follows/list.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_community_mod_of_any_or_admin_action}; use lemmy_db_views_community_follower_approval::{ PendingFollowerView, api::ListCommunityPendingFollows, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn get_pending_follows_list( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?; let all_communities = data.all_communities.unwrap_or_default() && local_user_view.local_user.admin; let items = PendingFollowerView::list_approval_required( &mut context.pool(), local_user_view.person.id, all_communities, data.unread_only.unwrap_or_default(), data.page_cursor, data.limit, ) .await?; Ok(Json(items)) } ================================================ FILE: crates/api/api/src/community/pending_follows/mod.rs ================================================ pub mod approve; pub mod list; ================================================ FILE: crates/api/api/src/community/random.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_private_instance, is_mod_or_admin_opt}, }; use lemmy_db_schema::source::{actor_language::CommunityLanguage, community::Community}; use lemmy_db_views_community::{ CommunityView, api::{CommunityResponse, GetRandomCommunity}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; pub async fn get_random_community( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; check_private_instance(&local_user_view, &local_site)?; let local_user = local_user_view.as_ref().map(|u| &u.local_user); let random_community_id = Community::get_random_community_id(&mut context.pool(), &data.type_, data.show_nsfw).await?; let is_mod_or_admin = is_mod_or_admin_opt( &mut context.pool(), local_user_view.as_ref(), Some(random_community_id), ) .await .is_ok(); let community_view = CommunityView::read( &mut context.pool(), random_community_id, local_user, is_mod_or_admin, ) .await?; let discussion_languages = CommunityLanguage::read(&mut context.pool(), random_community_id).await?; Ok(Json(CommunityResponse { community_view, discussion_languages, })) } ================================================ FILE: crates/api/api/src/community/tag.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_mod_action, slur_regex}, }; use lemmy_db_schema::source::{ community::Community, community_tag::{CommunityTag, CommunityTagInsertForm, CommunityTagUpdateForm}, }; use lemmy_db_views_community::{ CommunityView, api::{CreateCommunityTag, DeleteCommunityTag, EditCommunityTag}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{traits::Crud, utils::diesel_string_update}; use lemmy_utils::{ error::LemmyResult, utils::{ slurs::check_slurs, validation::{check_api_elements_count, is_valid_actor_name, summary_length_check}, }, }; use url::Url; pub async fn create_community_tag( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { is_valid_actor_name(&data.name)?; let community_view = CommunityView::read(&mut context.pool(), data.community_id, None, false).await?; let community = community_view.community; // Verify that only mods can create tags check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?; check_api_elements_count(community_view.tags.0.len())?; if let Some(summary) = &data.summary { summary_length_check(summary)?; check_slurs(summary, &slur_regex(&context).await?)?; } let ap_id = Url::parse(&format!("{}/tag/{}", community.ap_id, &data.name))?; // Create the tag let tag_form = CommunityTagInsertForm { name: data.name.clone(), display_name: data.display_name.clone(), summary: data.summary.clone(), community_id: data.community_id, ap_id: ap_id.into(), deleted: Some(false), color: data.color, }; let tag = CommunityTag::create(&mut context.pool(), &tag_form).await?; ActivityChannel::submit_activity( SendActivityData::UpdateCommunity(local_user_view.person.clone(), community), &context, )?; Ok(Json(tag)) } pub async fn edit_community_tag( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let tag = CommunityTag::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?; if let Some(summary) = &data.summary { summary_length_check(summary)?; check_slurs(summary, &slur_regex(&context).await?)?; } // Update the tag let tag_form = CommunityTagUpdateForm { display_name: diesel_string_update(data.display_name.as_deref()), summary: diesel_string_update(data.summary.as_deref()), updated_at: Some(Some(Utc::now())), color: data.color, ..Default::default() }; let tag = CommunityTag::update(&mut context.pool(), data.tag_id, &tag_form).await?; Ok(Json(tag)) } pub async fn delete_community_tag( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let tag = CommunityTag::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 = CommunityTagUpdateForm { updated_at: Some(Some(Utc::now())), deleted: Some(data.delete), ..Default::default() }; let tag = CommunityTag::update(&mut context.pool(), data.tag_id, &tag_form).await?; ActivityChannel::submit_activity( SendActivityData::UpdateCommunity(local_user_view.person.clone(), community), &context, )?; Ok(Json(tag)) } ================================================ FILE: crates/api/api/src/community/transfer.rs ================================================ use actix_web::web::{Data, Json}; use anyhow::Context; use diesel_async::scoped_futures::ScopedFutureExt; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, utils::{check_community_user_action, is_admin, is_top_mod}, }; use lemmy_db_schema::source::{ community::{Community, CommunityActions, CommunityModeratorForm}, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_community::{ CommunityView, api::{GetCommunityResponse, TransferCommunity}, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{connection::get_conn, traits::Crud}; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, location_info, }; // TODO: we don't do anything for federation here, it should be updated the next time the community // gets fetched. i hope we can get rid of the community creator role soon. pub async fn transfer_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let community = Community::read(&mut context.pool(), data.community_id).await?; let mut community_mods = CommunityModeratorView::for_community(&mut context.pool(), community.id).await?; check_community_user_action(&local_user_view, &community, &mut context.pool()).await?; // Make sure transferrer is either the top community mod, or an admin if !(is_top_mod(&local_user_view, &community_mods).is_ok() || is_admin(&local_user_view).is_ok()) { return Err(LemmyErrorType::NotAnAdmin.into()); } // You have to re-do the community_moderator table, reordering it. // Add the transferee to the top let creator_index = community_mods .iter() .position(|r| r.moderator.id == data.person_id) .context(location_info!())?; let creator_person = community_mods.remove(creator_index); community_mods.insert(0, creator_person); // Delete all the mods let community_id = data.community_id; let pool = &mut context.pool(); let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); let action = conn .run_transaction(|conn| { async move { CommunityActions::delete_mods_for_community(&mut conn.into(), community_id).await?; // TODO: this should probably be a bulk operation // Re-add the mods, in the new order for cmod in &community_mods { let community_moderator_form = CommunityModeratorForm::new(cmod.community.id, cmod.moderator.id); CommunityActions::join(&mut conn.into(), &community_moderator_form).await?; } // Mod tables let form = ModlogInsertForm::mod_transfer_community( local_user_view.person.id, tx_data.community_id, tx_data.person_id, ); Modlog::create(&mut conn.into(), &[form]).await } .scope_boxed() }) .await?; notify_mod_action(action.clone(), &context); let community_id = data.community_id; let community_view = CommunityView::read( &mut context.pool(), community_id, Some(&local_user_view.local_user), false, ) .await?; let community_id = data.community_id; let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?; // Return the jwt Ok(Json(GetCommunityResponse { community_view, site: None, moderators, discussion_languages: vec![], })) } ================================================ FILE: crates/api/api/src/community/update_notifications.rs ================================================ use crate::community::do_follow_community; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::community::{Community, CommunityActions}; use lemmy_db_schema_file::enums::CommunityNotificationsMode; use lemmy_db_views_community::api::EditCommunityNotifications; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn edit_community_notifications( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { CommunityActions::update_notification_state( data.community_id, local_user_view.person.id, data.mode, &mut context.pool(), ) .await?; // To get notifications for a remote community, the user needs to follow it over federation. // Do this automatically here to avoid confusion. if data.mode == CommunityNotificationsMode::AllPostsAndComments || data.mode == CommunityNotificationsMode::AllPosts { let community = Community::read(&mut context.pool(), data.community_id).await?; if !community.local { do_follow_community(community, &local_user_view.person, true, &context).await?; } } Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/federation/fetcher.rs ================================================ use crate::federation::ApubPerson; use activitypub_federation::{ config::Data, fetch::webfinger::webfinger_resolve_actor, traits::{Actor, Object}, }; use diesel::NotFound; use itertools::Itertools; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::objects::{community::ApubCommunity, multi_community::ApubMultiCommunity}; use lemmy_db_schema::{ newtypes::{CommunityId, MultiCommunityId}, source::{community::Community, multi_community::MultiCommunity, person::Person}, traits::ApubActor, }; use lemmy_db_schema_file::PersonId; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult}; /// Resolve actor identifier like `!news@example.com` to user or community object. /// /// In case the requesting user is logged in and the object was not found locally, it is attempted /// to fetch via webfinger from the original instance. async fn resolve_ap_identifier( identifier: &str, context: &Data, local_user_view: &Option, include_deleted: bool, ) -> LemmyResult where ActorType: Object + Object + Actor + From + Send + Sync + 'static, for<'de2> ::Kind: serde::Deserialize<'de2>, DbActor: ApubActor + Send + 'static, { // remote actor if identifier.contains('@') { let (name, domain) = identifier .splitn(2, '@') .collect_tuple() .ok_or(LemmyErrorType::InvalidUrl)?; let actor = DbActor::read_from_name(&mut context.pool(), name, Some(domain), false) .await .ok() .flatten(); if let Some(actor) = actor { Ok(actor.into()) } else if local_user_view.is_some() { // Fetch the actor from its home instance using webfinger let actor: ActorType = webfinger_resolve_actor(&identifier.to_lowercase(), context).await?; Ok(actor) } else { Err(NotFound.into()) } } // local actor else { let identifier = identifier.to_string(); Ok( DbActor::read_from_name(&mut context.pool(), &identifier, None, include_deleted) .await? .ok_or(NotFound)? .into(), ) } } pub(crate) async fn resolve_community_identifier( name: &Option, id: Option, context: &Data, local_user_view: &Option, ) -> LemmyResult> { Ok(if let Some(name) = name { Some( resolve_ap_identifier::(name, context, local_user_view, true) .await? .id, ) } else { id }) } pub(crate) async fn resolve_person_identifier( id: Option, username: &Option, context: &Data, local_user_view: &Option, ) -> LemmyResult { Ok( if let Some(name) = username { Some( resolve_ap_identifier::(name, context, local_user_view, true) .await? .id, ) } else { id } .ok_or(LemmyErrorType::NoIdGiven)?, ) } pub(crate) async fn resolve_multi_community_identifier( name: &Option, id: Option, context: &Data, local_user_view: &Option, ) -> LemmyResult> { Ok(if let Some(name) = name { Some( resolve_ap_identifier::( name, context, local_user_view, true, ) .await? .id, ) } else { id }) } ================================================ FILE: crates/api/api/src/federation/list_comments.rs ================================================ use crate::federation::{ comment_sort_type_with_default, fetch_limit_with_default, fetcher::resolve_community_identifier, listing_type_with_default, post_time_range_seconds_with_default, }; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_schema::source::comment::Comment; use lemmy_db_views_comment::{CommentSlimView, CommentView, api::GetComments, impls::CommentQuery}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{pagination::PagedResponse, traits::Crud}; use lemmy_utils::error::LemmyResult; /// A common fetcher for both the CommentView, and CommentSlimView. async fn list_comments_common( data: GetComments, context: Data, local_user_view: Option, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = &site_view.local_site; check_private_instance(&local_user_view, local_site)?; let community_id = resolve_community_identifier( &data.community_name, data.community_id, &context, &local_user_view, ) .await?; let local_user = local_user_view.as_ref().map(|u| &u.local_user); let sort = Some(comment_sort_type_with_default( data.sort, local_user, local_site, )); let time_range_seconds = post_time_range_seconds_with_default(data.time_range_seconds, local_user, local_site); let limit = Some(fetch_limit_with_default(data.limit, local_user, local_site)); let max_depth = data.max_depth; let parent_id = data.parent_id; let listing_type = Some(listing_type_with_default( data.type_, local_user_view.as_ref().map(|u| &u.local_user), local_site, community_id, )); // If a parent_id is given, fetch the comment to get the path let parent_path_ = if let Some(parent_id) = parent_id { Some(Comment::read(&mut context.pool(), parent_id).await?.path) } else { None }; let parent_path = parent_path_.clone(); let post_id = data.post_id; let local_user = local_user_view.as_ref().map(|l| &l.local_user); CommentQuery { listing_type, sort, time_range_seconds, max_depth, community_id, parent_path, post_id, local_user, page_cursor: data.page_cursor, limit, } .list(&site_view.site, &mut context.pool()) .await } pub async fn list_comments( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let common = list_comments_common(data, context, local_user_view).await?; Ok(Json(common)) } pub async fn list_comments_slim( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let common = list_comments_common(data, context, local_user_view).await?; let data = common .items .into_iter() .map(CommentView::map_to_slim) .collect(); let res = PagedResponse { items: data, next_page: common.next_page, prev_page: common.prev_page, }; Ok(Json(res)) } ================================================ FILE: crates/api/api/src/federation/list_person_content.rs ================================================ use crate::federation::fetcher::resolve_person_identifier; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person_content_combined::{ ListPersonContent, impls::PersonContentCombinedQuery, }; use lemmy_db_views_post_comment_combined::PostCommentCombinedView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_person_content( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = site_view.local_site; let local_instance_id = site_view.site.instance_id; check_private_instance(&local_user_view, &local_site)?; let person_details_id = resolve_person_identifier(data.person_id, &data.username, &context, &local_user_view).await?; let res = PersonContentCombinedQuery { creator_id: person_details_id, type_: data.type_, page_cursor: data.page_cursor, limit: data.limit, no_limit: None, } .list( &mut context.pool(), local_user_view.as_ref(), local_instance_id, ) .await?; Ok(Json(res)) } ================================================ FILE: crates/api/api/src/federation/list_posts.rs ================================================ use crate::federation::{ fetch_limit_with_default, fetcher::{resolve_community_identifier, resolve_multi_community_identifier}, listing_type_with_default, post_sort_type_with_default, post_time_range_seconds_with_default, }; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_schema::{ newtypes::PostId, source::{keyword_block::LocalUserKeywordBlock, post::PostActions}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{PostView, api::GetPosts, impls::PostQuery}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; use std::cmp::min; pub async fn list_posts( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = &site_view.local_site; check_private_instance(&local_user_view, &site_view.local_site)?; let community_id = resolve_community_identifier( &data.community_name, data.community_id, &context, &local_user_view, ) .await?; let multi_community_id = resolve_multi_community_identifier( &data.multi_community_name, data.multi_community_id, &context, &local_user_view, ) .await?; let show_hidden = data.show_hidden; let show_read = data.show_read; // Show nsfw content if param is true, or if content_warning exists let show_nsfw = data.show_nsfw; let hide_media = data.hide_media; let no_comments_only = data.no_comments_only; let page_cursor = data.page_cursor; let local_user = local_user_view.as_ref().map(|u| &u.local_user); let listing_type = Some(listing_type_with_default( data.type_, local_user, local_site, community_id, )); let sort = Some(post_sort_type_with_default( data.sort, local_user, local_site, )); let time_range_seconds = post_time_range_seconds_with_default(data.time_range_seconds, local_user, local_site); let limit = Some(fetch_limit_with_default(data.limit, local_user, local_site)); let keyword_blocks = if let Some(local_user) = local_user { Some(LocalUserKeywordBlock::read(&mut context.pool(), local_user.id).await?) } else { None }; // dont allow more than page 10 for performance reasons let page = data.page.map(|p| min(p, 10)); let posts = PostQuery { local_user, listing_type, sort, time_range_seconds, community_id, multi_community_id, page, limit, show_hidden, show_read, show_nsfw, hide_media, no_comments_only, keyword_blocks, page_cursor, } .list(&site_view.site, &mut context.pool()) .await?; // If in their user settings (or as part of the API request), auto-mark fetched posts as read if let Some(local_user) = local_user && data .mark_as_read .unwrap_or(local_user.auto_mark_fetched_posts_as_read) { let post_ids = posts.iter().map(|p| p.post.id).collect::>(); PostActions::mark_as_read(&mut context.pool(), local_user.person_id, &post_ids).await?; } Ok(Json(posts)) } ================================================ FILE: crates/api/api/src/federation/mod.rs ================================================ use lemmy_apub_objects::objects::person::ApubPerson; use lemmy_db_schema::{ newtypes::CommunityId, source::{local_site::LocalSite, local_user::LocalUser}, }; use lemmy_db_schema_file::enums::{CommentSortType, ListingType, PostSortType}; mod fetcher; pub mod list_comments; pub mod list_person_content; pub mod list_posts; pub mod read_community; pub mod read_multi_community; pub mod read_person; pub mod resolve_object; pub mod search; pub mod user_settings_backup; /// Returns default listing type, depending if the query is for frontpage or community. fn listing_type_with_default( type_: Option, local_user: Option<&LocalUser>, local_site: &LocalSite, community_id: Option, ) -> ListingType { // On frontpage use listing type from param or admin configured default if community_id.is_none() { type_.unwrap_or( local_user .map(|u| u.default_listing_type) .unwrap_or(local_site.default_post_listing_type), ) } else { // inside of community show everything ListingType::All } } /// Returns a default instance-level post sort type, if none is given by the user. /// Order is type, local user default, then site default. fn post_sort_type_with_default( type_: Option, local_user: Option<&LocalUser>, local_site: &LocalSite, ) -> PostSortType { type_.unwrap_or( local_user .map(|u| u.default_post_sort_type) .unwrap_or(local_site.default_post_sort_type), ) } /// Returns a default post_time_range. /// Order is the given, then local user default, then site default. /// If zero is given, then the output is None. fn post_time_range_seconds_with_default( secs: Option, local_user: Option<&LocalUser>, local_site: &LocalSite, ) -> Option { let out = secs .or(local_user.and_then(|u| u.default_post_time_range_seconds)) .or(local_site.default_post_time_range_seconds); // A zero is an override to None if out.is_some_and(|o| o == 0) { None } else { out } } /// Returns a default instance-level comment sort type, if none is given by the user. /// Order is type, local user default, then site default. fn comment_sort_type_with_default( type_: Option, local_user: Option<&LocalUser>, local_site: &LocalSite, ) -> CommentSortType { type_.unwrap_or( local_user .map(|u| u.default_comment_sort_type) .unwrap_or(local_site.default_comment_sort_type), ) } /// Returns a default page fetch limit. /// Order is the given, then local user default, then site default. fn fetch_limit_with_default( limit: Option, local_user: Option<&LocalUser>, local_site: &LocalSite, ) -> i64 { limit.unwrap_or( local_user .map(|u| i64::from(u.default_items_per_page)) .unwrap_or(i64::from(local_site.default_items_per_page)), ) } ================================================ FILE: crates/api/api/src/federation/read_community.rs ================================================ use crate::federation::fetcher::resolve_community_identifier; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_private_instance, is_mod_or_admin_opt, read_site_for_actor}, }; use lemmy_db_schema::source::actor_language::CommunityLanguage; use lemmy_db_views_community::{ CommunityView, api::{GetCommunity, GetCommunityResponse}, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn get_community( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; if data.name.is_none() && data.id.is_none() { return Err(LemmyErrorType::NoIdGiven.into()); } check_private_instance(&local_user_view, &local_site)?; let local_user = local_user_view.as_ref().map(|u| &u.local_user); let community_id = resolve_community_identifier(&data.name, data.id, &context, &local_user_view) .await? .ok_or(LemmyErrorType::NoIdGiven)?; let is_mod_or_admin = is_mod_or_admin_opt( &mut context.pool(), local_user_view.as_ref(), Some(community_id), ) .await .is_ok(); let community_view = CommunityView::read( &mut context.pool(), community_id, local_user, is_mod_or_admin, ) .await?; let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?; let site = read_site_for_actor(community_view.community.ap_id.clone(), &context).await?; let community_id = community_view.community.id; let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?; Ok(Json(GetCommunityResponse { community_view, site, moderators, discussion_languages, })) } ================================================ FILE: crates/api/api/src/federation/read_multi_community.rs ================================================ use crate::federation::fetcher::resolve_multi_community_identifier; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_community::{ MultiCommunityView, api::{GetMultiCommunity, GetMultiCommunityResponse}, impls::CommunityQuery, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn read_multi_community( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let my_person_id = local_user_view.as_ref().map(|l| l.person.id); let id = resolve_multi_community_identifier(&data.name, data.id, &context, &local_user_view) .await? .ok_or(LemmyErrorType::NoIdGiven)?; let multi_community_view = MultiCommunityView::read(&mut context.pool(), id, my_person_id).await?; let local_site = SiteView::read_local(&mut context.pool()).await?; let communities = CommunityQuery { multi_community_id: Some(id), ..Default::default() } .list(&local_site.site, &mut context.pool()) .await? .items; Ok(Json(GetMultiCommunityResponse { multi_community_view, communities, })) } ================================================ FILE: crates/api/api/src/federation/read_person.rs ================================================ use crate::federation::fetcher::resolve_person_identifier; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_private_instance, is_admin, read_site_for_actor}, }; use lemmy_db_schema::MultiCommunitySortType; use lemmy_db_views_community::impls::MultiCommunityQuery; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::{ PersonView, api::{GetPersonDetails, GetPersonDetailsResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; pub async fn read_person( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = site_view.local_site; let local_instance_id = site_view.site.instance_id; let my_person_id = local_user_view.as_ref().map(|l| l.person.id); check_private_instance(&local_user_view, &local_site)?; let person_details_id = resolve_person_identifier(data.person_id, &data.username, &context, &local_user_view).await?; // You don't need to return settings for the user, since this comes back with GetSite // `my_user` let is_admin = local_user_view .as_ref() .map(|l| is_admin(l).is_ok()) .unwrap_or_default(); let person_view = PersonView::read( &mut context.pool(), person_details_id, my_person_id, local_instance_id, is_admin, ) .await?; let moderates = CommunityModeratorView::for_person( &mut context.pool(), person_details_id, local_user_view.map(|l| l.local_user).as_ref(), ) .await?; let multi_communities_created = MultiCommunityQuery { creator_id: Some(person_details_id), my_person_id, sort: Some(MultiCommunitySortType::NameAsc), no_limit: Some(true), ..Default::default() } .list(&mut context.pool()) .await? .items; let site = read_site_for_actor(person_view.person.ap_id.clone(), &context).await?; Ok(Json(GetPersonDetailsResponse { person_view, site, moderates, multi_communities_created, })) } ================================================ FILE: crates/api/api/src/federation/resolve_object.rs ================================================ use activitypub_federation::{ config::Data, fetch::{object_id::ObjectId, webfinger::webfinger_resolve_actor}, }; use actix_web::web::{Json, Query}; use either::Either::*; use lemmy_api_utils::{ context::LemmyContext, utils::{check_is_mod_or_admin, check_private_instance}, }; use lemmy_apub_objects::objects::{SearchableObjects, UserOrCommunity}; use lemmy_db_schema_file::PersonId; use lemmy_db_views_comment::CommentView; use lemmy_db_views_community::{CommunityView, MultiCommunityView}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::PersonView; use lemmy_db_views_post::PostView; use lemmy_db_views_search_combined::{SearchCombinedView, SearchResponse}; use lemmy_db_views_site::{SiteView, api::ResolveObject}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use url::Url; pub async fn resolve_object( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; check_private_instance(&local_user_view, &local_site)?; let resolve = Some(resolve_object_internal(&data.q, &local_user_view, &context).await?); Ok(Json(SearchResponse { resolve, ..Default::default() })) } pub(super) async fn resolve_object_internal( query: &str, local_user_view: &Option, context: &Data, ) -> LemmyResult { use SearchCombinedView::*; let is_authenticated = local_user_view.as_ref().is_some_and(|l| !l.banned); let object = if is_authenticated || cfg!(debug_assertions) { // user is fully authenticated; allow remote lookups as well. search_query_to_object_id(query.to_string(), context).await } else { // user isn't authenticated only allow a local search. search_query_to_object_id_local(query, context).await } .map_err(|e| LemmyErrorType::ResolveObjectFailed(e.cause.to_string()))?; let my_person_id_opt = local_user_view.as_ref().map(|l| l.person.id); let my_person_id = my_person_id_opt.unwrap_or(PersonId(-1)); let local_user = local_user_view.as_ref().map(|l| l.local_user.clone()); let is_admin = local_user.as_ref().map(|l| l.admin).unwrap_or_default(); let pool = &mut context.pool(); let local_instance_id = SiteView::read_local(pool).await?.site.instance_id; Ok(match object { Left(Left(Left(p))) => { let is_mod = check_is_mod_or_admin(pool, my_person_id, p.community_id) .await .is_ok(); Post(PostView::read(pool, p.id, local_user.as_ref(), local_instance_id, is_mod).await?) } Left(Left(Right(c))) => { Comment(CommentView::read(pool, c.id, local_user.as_ref(), local_instance_id).await?) } Left(Right(Left(u))) => { Person(PersonView::read(pool, u.id, my_person_id_opt, local_instance_id, is_admin).await?) } Left(Right(Right(c))) => { Community(CommunityView::read(pool, c.id, local_user.as_ref(), is_admin).await?) } Right(multi) => { MultiCommunity(MultiCommunityView::read(pool, multi.id, my_person_id_opt).await?) } }) } /// Converts search query to object id. The query can either be an URL, which will be treated as /// ObjectId directly, or a webfinger identifier (@user@example.com or !community@example.com) /// which gets resolved to an URL. async fn search_query_to_object_id( mut query: String, context: &Data, ) -> LemmyResult { Ok(match Url::parse(&query) { Ok(url) => { // its already an url, just go with it ObjectId::from(url).dereference(context).await? } Err(_) => { // not an url, try to resolve via webfinger if query.starts_with('!') || query.starts_with('@') { query.remove(0); } Left(Right( webfinger_resolve_actor::(&query, context).await?, )) } }) } /// Converts a search query to an object id. The query MUST bbe a URL which will bbe treated /// as the ObjectId directly. If the query is a webfinger identifier (@user@example.com or /// !community@example.com) this method will return an error. async fn search_query_to_object_id_local( query: &str, context: &Data, ) -> LemmyResult { let url = Url::parse(query)?; ObjectId::from(url).dereference_local(context).await } #[cfg(test)] mod tests { use super::*; use lemmy_db_schema::{ source::{ community::{Community, CommunityInsertForm}, local_site::LocalSite, post::{Post, PostInsertForm, PostUpdateForm}, }, test_data::TestData, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyErrorType; use serial_test::serial; #[tokio::test] #[serial] async fn test_object_visibility() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = TestData::create(pool).await?; let bio = "test_local_user_bio"; let creator = LocalUserView::create_test_user(pool, "test_local_user_name_1", bio, false).await?; let regular_user = LocalUserView::create_test_user(pool, "test_local_user_name_2", bio, false).await?; let admin_user = LocalUserView::create_test_user(pool, "test_local_user_name_3", bio, true).await?; let community = Community::create( pool, &CommunityInsertForm::new( data.instance.id, "test".to_string(), "test".to_string(), "pubkey".to_string(), ), ) .await?; let post_insert_form = PostInsertForm::new("Test".to_string(), creator.person.id, community.id); let post = Post::create(pool, &post_insert_form).await?; let query = post.ap_id.to_string(); // Objects should be resolvable without authentication let res = resolve_object_internal(&query, &None, &context).await?; assert_response(res, &post); // Objects should be resolvable by regular users let res = resolve_object_internal(&query, &Some(regular_user.clone()), &context).await?; assert_response(res, &post); // Objects should be resolvable by admins let res = resolve_object_internal(&query, &Some(admin_user.clone()), &context).await?; assert_response(res, &post); Post::update( pool, post.id, &PostUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; // Deleted objects should not be resolvable without authentication let res = resolve_object_internal(&query, &None, &context).await; assert!(res.is_err_and(|e| e.error_type == LemmyErrorType::NotFound)); // Deleted objects should not be resolvable by regular users let res = resolve_object_internal(&query, &Some(regular_user.clone()), &context).await; assert!(res.is_err_and(|e| e.error_type == LemmyErrorType::NotFound)); // Deleted objects should be resolvable by admins let res = resolve_object_internal(&query, &Some(admin_user.clone()), &context).await?; assert_response(res, &post); LocalSite::delete(pool).await?; data.delete(pool).await?; Ok(()) } fn assert_response(res: SearchCombinedView, expected_post: &Post) { if let SearchCombinedView::Post(v) = res { assert_eq!(expected_post.ap_id, v.post.ap_id); } else { panic!("invalid resolve object response"); } } } ================================================ FILE: crates/api/api/src/federation/search.rs ================================================ use crate::federation::{ fetcher::resolve_community_identifier, resolve_object::resolve_object_internal, }; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use futures::future::join; use lemmy_api_utils::{ context::LemmyContext, utils::{check_conflicting_like_filters, check_private_instance}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_search_combined::{Search, SearchResponse, impls::SearchCombinedQuery}; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; pub async fn search( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = site_view.local_site; check_private_instance(&local_user_view, &local_site)?; check_conflicting_like_filters(data.liked_only, data.disliked_only)?; let community_id = resolve_community_identifier( &data.community_name, data.community_id, &context, &local_user_view, ) .await?; let pool = &mut context.pool(); let search_fut = SearchCombinedQuery { search_term: Some(data.q.clone()), community_id, creator_id: data.creator_id, type_: data.type_, sort: data.sort, time_range_seconds: data.time_range_seconds, listing_type: data.listing_type, title_only: data.title_only, post_url_only: data.post_url_only, liked_only: data.liked_only, disliked_only: data.disliked_only, show_nsfw: data.show_nsfw, page_cursor: data.page_cursor, limit: data.limit, } .list(pool, &local_user_view, &site_view.site); let resolve_fut = resolve_object_internal(&data.q, &local_user_view, &context); let (search, resolve) = join(search_fut, resolve_fut).await; let search = search?; Ok(Json(SearchResponse { search: search.items, // ignore errors as this may not be an apub url resolve: resolve.ok(), next_page: search.next_page, prev_page: search.prev_page, })) } ================================================ FILE: crates/api/api/src/federation/user_settings_backup.rs ================================================ use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Object}; use actix_web::web::Json; use futures::{StreamExt, future::try_join_all}; use itertools::Itertools; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_apub_objects::objects::{ comment::ApubComment, community::ApubCommunity, person::ApubPerson, post::ApubPost, }; use lemmy_db_schema::{ source::{ actor_language::LocalUserLanguage, comment::{CommentActions, CommentSavedForm}, community::{CommunityActions, CommunityBlockForm, CommunityFollowerForm}, instance::{Instance, InstanceActions, InstanceCommunitiesBlockForm, InstancePersonsBlockForm}, keyword_block::LocalUserKeywordBlock, language::Language, local_user::{LocalUser, LocalUserUpdateForm}, person::{Person, PersonActions, PersonBlockForm, PersonUpdateForm}, post::{PostActions, PostSavedForm}, }, traits::{Blockable, Followable, Saveable}, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ api::{SuccessResponse, UserSettingsBackup}, impls::user_backup_list_to_user_settings_backup, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::LemmyResult, spawn_try_task, utils::validation::{check_api_elements_count, check_blocking_keywords_are_valid}, }; use serde::Deserialize; use std::{collections::HashMap, future::Future}; use tracing::info; const PARALLELISM: usize = 10; pub async fn export_settings( local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let settings = user_backup_list_to_user_settings_backup(local_user_view, &mut context.pool()).await?; Ok(Json(settings)) } pub async fn import_settings( Json(data): Json, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let person_form = PersonUpdateForm { display_name: data.display_name.clone().map(Some), bio: data.bio.clone().map(Some), matrix_user_id: data.matrix_id.clone().map(Some), bot_account: data.bot_account, ..Default::default() }; // ignore error in case form is empty Person::update(&mut context.pool(), local_user_view.person.id, &person_form) .await .ok(); let local_user_form = LocalUserUpdateForm { show_nsfw: data.settings.as_ref().map(|s| s.show_nsfw), theme: data.settings.clone().map(|s| s.theme.clone()), default_post_sort_type: data.settings.as_ref().map(|s| s.default_post_sort_type), default_comment_sort_type: data.settings.as_ref().map(|s| s.default_comment_sort_type), default_listing_type: data.settings.as_ref().map(|s| s.default_listing_type), interface_language: data.settings.clone().map(|s| s.interface_language), show_avatars: data.settings.as_ref().map(|s| s.show_avatars), send_notifications_to_email: data .settings .as_ref() .map(|s| s.send_notifications_to_email), show_bot_accounts: data.settings.as_ref().map(|s| s.show_bot_accounts), show_read_posts: data.settings.as_ref().map(|s| s.show_read_posts), open_links_in_new_tab: data.settings.as_ref().map(|s| s.open_links_in_new_tab), blur_nsfw: data.settings.as_ref().map(|s| s.blur_nsfw), infinite_scroll_enabled: data.settings.as_ref().map(|s| s.infinite_scroll_enabled), post_listing_mode: data.settings.as_ref().map(|s| s.post_listing_mode), show_score: data.settings.as_ref().map(|s| s.show_score), show_upvotes: data.settings.as_ref().map(|s| s.show_upvotes), show_downvotes: data.settings.as_ref().map(|s| s.show_downvotes), show_upvote_percentage: data.settings.as_ref().map(|s| s.show_upvote_percentage), ..Default::default() }; let local_user_id = local_user_view.local_user.id; LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?; if !data.discussion_languages.is_empty() { let all_languages: HashMap<_, _> = Language::read_all(&mut context.pool()) .await? .into_iter() .map(|l| (l.code, l.id)) .collect(); let discussion_languages = data .discussion_languages .iter() .flat_map(|d| all_languages.get(d).copied()) .collect(); LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?; } if !data.blocking_keywords.is_empty() { let trimmed_blocking_keywords = data .blocking_keywords .iter() .map(|blocking_keyword| blocking_keyword.trim().to_string()) .collect(); check_blocking_keywords_are_valid(&trimmed_blocking_keywords)?; LocalUserKeywordBlock::update( &mut context.pool(), trimmed_blocking_keywords, local_user_id, ) .await?; } let url_count = data.followed_communities.len() + data.blocked_communities.len() + data.blocked_users.len() + data.blocked_instances_communities.len() + data.blocked_instances_persons.len() + data.saved_posts.len() + data.saved_comments.len(); check_api_elements_count(url_count)?; spawn_try_task(async move { let person_id = local_user_view.person.id; info!( "Starting settings import for {}", local_user_view.person.name ); let failed_followed_communities = fetch_and_import( data .followed_communities .clone() .into_iter() .map(Into::into) .collect::>>(), &context, |(followed, context)| async move { let community = followed.dereference(&context).await?; let form = CommunityFollowerForm::new(community.id, person_id, CommunityFollowerState::Pending); CommunityActions::follow(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, ) .await?; let failed_saved_posts = fetch_and_import( data .saved_posts .clone() .into_iter() .map(Into::into) .collect::>>(), &context, |(saved, context)| async move { let post = saved.dereference(&context).await?; let form = PostSavedForm::new(post.id, person_id); PostActions::save(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, ) .await?; let failed_saved_comments = fetch_and_import( data .saved_comments .clone() .into_iter() .map(Into::into) .collect::>>(), &context, |(saved, context)| async move { let comment = saved.dereference(&context).await?; let form = CommentSavedForm::new(person_id, comment.id); CommentActions::save(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, ) .await?; let failed_community_blocks = fetch_and_import( data .blocked_communities .clone() .into_iter() .map(Into::into) .collect::>>(), &context, |(blocked, context)| async move { let community = blocked.dereference(&context).await?; let form = CommunityBlockForm::new(community.id, person_id); CommunityActions::block(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, ) .await?; let failed_user_blocks = fetch_and_import( data .blocked_users .clone() .into_iter() .map(Into::into) .collect::>>(), &context, |(blocked, context)| async move { let target = blocked.dereference(&context).await?; let form = PersonBlockForm::new(person_id, target.id); PersonActions::block(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, ) .await?; try_join_all( data .blocked_instances_communities .iter() .map(|domain| async { let instance = Instance::read_or_create(&mut context.pool(), domain).await?; let form = InstanceCommunitiesBlockForm::new(person_id, instance.id); InstanceActions::block_communities(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }), ) .await?; try_join_all(data.blocked_instances_persons.iter().map(|domain| async { let instance = Instance::read_or_create(&mut context.pool(), domain).await?; let form = InstancePersonsBlockForm::new(person_id, instance.id); InstanceActions::block_persons(&mut context.pool(), &form).await?; LemmyResult::Ok(()) })) .await?; info!( "Settings import completed for {}, the following items failed: {failed_followed_communities}, {failed_saved_posts}, {failed_saved_comments}, {failed_community_blocks}, {failed_user_blocks}", local_user_view.person.name ); Ok(()) }); Ok(Json(Default::default())) } async fn fetch_and_import( objects: Vec>, context: &Data, import_fn: impl FnMut((ObjectId, Data)) -> Fut, ) -> LemmyResult where Kind: Object + Send + Sync + 'static, for<'de2> ::Kind: Deserialize<'de2>, Fut: Future>, { let mut failed_items = vec![]; futures::stream::iter( objects .clone() .into_iter() // need to reset outgoing request count to avoid running into limit .map(|s| (s, context.reset_request_count())) .map(import_fn), ) .buffer_unordered(PARALLELISM) .collect::>() .await .into_iter() .enumerate() .for_each(|(i, r): (usize, LemmyResult<()>)| { if r.is_err() && let Some(object) = objects.get(i) { failed_items.push(object.inner().clone()); } }); Ok(failed_items.into_iter().join(",")) } #[cfg(test)] #[expect(clippy::indexing_slicing)] pub(crate) mod tests { use super::*; use crate::federation::user_settings_backup::{export_settings, import_settings}; use actix_web::web::Json; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ newtypes::LanguageId, source::{ community::{Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm}, person::Person, }, test_data::TestData, traits::Followable, }; use lemmy_db_views_community_follower::CommunityFollowerView; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serial_test::serial; use std::time::Duration; use tokio::time::sleep; #[tokio::test] #[serial] async fn test_settings_export_import() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = TestData::create(pool).await?; let export_user = LocalUserView::create_test_user(pool, "hanna", "my bio", false).await?; let community_form = CommunityInsertForm::new( export_user.person.instance_id, "testcom".to_string(), "testcom".to_string(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let follower_form = CommunityFollowerForm::new( community.id, export_user.person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &follower_form).await?; let discussion_langs_before = vec![LanguageId(1), LanguageId(2), LanguageId(3)]; LocalUserLanguage::update( &mut context.pool(), discussion_langs_before.clone(), export_user.local_user.id, ) .await?; let keyword_blocks_before = vec!["blocking_1".to_string(), "blocking_2".to_string()]; LocalUserKeywordBlock::update( &mut context.pool(), keyword_blocks_before.clone(), export_user.local_user.id, ) .await?; let backup = export_settings(export_user.clone(), context.clone()).await?; let import_user = LocalUserView::create_test_user(pool, "charles", "charles bio", false).await?; import_settings(backup, import_user.clone(), context.clone()).await?; // wait for background task to finish sleep(Duration::from_millis(1000)).await; let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?; assert_eq!( export_user.person.display_name, import_user_updated.person.display_name ); assert_eq!(export_user.person.bio, import_user_updated.person.bio); let follows = CommunityFollowerView::for_person(pool, import_user.person.id).await?; assert_eq!(follows.len(), 1); assert_eq!(follows[0].community.ap_id, community.ap_id); let discussion_langs_after = LocalUserLanguage::read(&mut context.pool(), export_user.local_user.id).await?; assert_eq!(discussion_langs_before, discussion_langs_after); let keyword_blocks_after = LocalUserKeywordBlock::read(&mut context.pool(), export_user.local_user.id).await?; assert_eq!(keyword_blocks_before, keyword_blocks_after); Person::delete(pool, export_user.person.id).await?; Person::delete(pool, import_user.person.id).await?; data.delete(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn disallow_large_backup() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = TestData::create(pool).await?; let export_user = LocalUserView::create_test_user(pool, "harry", "harry bio", false).await?; let mut backup = export_settings(export_user.clone(), context.clone()).await?; for _ in 0..2501 { backup .followed_communities .push("http://example.com".parse()?); backup .blocked_communities .push("http://example2.com".parse()?); backup.saved_posts.push("http://example3.com".parse()?); backup.saved_comments.push("http://example4.com".parse()?); } let import_user = LocalUserView::create_test_user(pool, "sally", "sally bio", false).await?; let imported = import_settings(backup, import_user.clone(), context.clone()).await; assert_eq!( imported.err().map(|e| e.error_type), Some(LemmyErrorType::TooManyItems) ); Person::delete(pool, export_user.person.id).await?; Person::delete(pool, import_user.person.id).await?; data.delete(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn import_partial_backup() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = TestData::create(pool).await?; let import_user = LocalUserView::create_test_user(pool, "larry", "larry bio", false).await?; let backup = serde_json::from_str("{\"bot_account\": true, \"settings\": {\"theme\": \"my_theme\"}}")?; import_settings(Json(backup), import_user.clone(), context.clone()).await?; let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?; // mark as bot account assert!(import_user_updated.person.bot_account); // dont remove existing bio assert_eq!(import_user.person.bio, import_user_updated.person.bio); // local_user can be deserialized without id/person_id fields assert_eq!("my_theme", import_user_updated.local_user.theme); data.delete(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/api/api/src/lib.rs ================================================ use lemmy_api_utils::{context::LemmyContext, utils::is_mod_or_admin_opt}; use lemmy_db_schema::newtypes::CommunityId; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, utils::slurs::check_slurs, }; use regex::Regex; use totp_rs::{Secret, TOTP}; pub mod comment; pub mod community; pub mod federation; pub mod local_user; pub mod post; pub mod reports; pub mod site; pub mod sitemap; /// Check size of report pub(crate) fn check_report_reason(reason: &str, slur_regex: &Regex) -> LemmyResult<()> { check_slurs(reason, slur_regex)?; if reason.is_empty() { Err(LemmyErrorType::ReportReasonRequired.into()) } else if reason.chars().count() > 1000 { Err(LemmyErrorType::ReportTooLong.into()) } else { Ok(()) } } pub(crate) fn check_totp_2fa_valid( local_user_view: &LocalUserView, totp_token: &Option, site_name: &str, ) -> LemmyResult<()> { // Throw an error if their token is missing let token = totp_token .as_deref() .ok_or(LemmyErrorType::MissingTotpToken)?; let secret = local_user_view .local_user .totp_2fa_secret .as_deref() .ok_or(LemmyErrorType::MissingTotpSecret)?; let totp = build_totp_2fa(site_name, &local_user_view.person.name, secret)?; let check_passed = totp.check_current(token)?; if !check_passed { return Err(LemmyErrorType::IncorrectTotpToken.into()); } Ok(()) } pub(crate) fn generate_totp_2fa_secret() -> String { Secret::generate_secret().to_string() } fn build_totp_2fa(hostname: &str, username: &str, secret: &str) -> LemmyResult { let sec = Secret::Raw(secret.as_bytes().to_vec()); let sec_bytes = sec .to_bytes() .with_lemmy_type(LemmyErrorType::CouldntParseTotpSecret)?; TOTP::new( totp_rs::Algorithm::SHA1, 6, 1, 30, sec_bytes, Some(hostname.to_string()), username.to_string(), ) .with_lemmy_type(LemmyErrorType::CouldntGenerateTotp) } /// Only show the modlog names if: /// You're an admin or /// You're fetching the modlog for a single community, and you're a mod /// (Alternatively !admin/mod) async fn hide_modlog_names( local_user_view: Option<&LocalUserView>, community_id: Option, context: &LemmyContext, ) -> bool { if let Some(community_id) = community_id { is_mod_or_admin_opt(&mut context.pool(), local_user_view, Some(community_id)) .await .is_err() } else { !local_user_view .map(|l| l.local_user.admin) .unwrap_or_default() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_build_totp() { let generated_secret = generate_totp_2fa_secret(); let totp = build_totp_2fa("lemmy.ml", "my_name", &generated_secret); assert!(totp.is_ok()); } } ================================================ FILE: crates/api/api/src/local_user/add_admin.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action, utils::is_admin}; use lemmy_db_schema::source::{ local_user::{LocalUser, LocalUserUpdateForm}, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::{ PersonView, api::{AddAdmin, AddAdminResponse}, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn add_admin( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let my_person_id = local_user_view.person.id; // Make sure user is an admin is_admin(&local_user_view)?; // If its an admin removal, also check that you're a higher admin if !data.added { LocalUser::is_higher_admin_check(&mut context.pool(), my_person_id, vec![data.person_id]) .await?; // Dont allow removing the last admin let admins = PersonView::list_admins( None, local_user_view.person.instance_id, &mut context.pool(), ) .await?; if admins.len() == 1 { return Err(LemmyErrorType::CannotLeaveAdmin.into()); } } // Make sure that the person_id added is local let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id).await?; LocalUser::update( &mut context.pool(), added_local_user.local_user.id, &LocalUserUpdateForm { admin: Some(data.added), ..Default::default() }, ) .await?; // Mod tables let form = ModlogInsertForm::admin_add( &local_user_view.person, added_local_user.person.id, !data.added, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), &context); let admins = PersonView::list_admins( Some(my_person_id), local_user_view.person.instance_id, &mut context.pool(), ) .await?; Ok(Json(AddAdminResponse { admins })) } ================================================ FILE: crates/api/api/src/local_user/ban_person.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::{check_expire_time, is_admin, remove_or_restore_user_data}, }; use lemmy_db_schema::{ source::{ instance::{InstanceActions, InstanceBanForm}, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, }, traits::Bannable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::{ PersonView, api::{BanPerson, PersonResponse}, }; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::is_valid_body_field, }; pub async fn ban_from_site( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_instance_id = local_user_view.person.instance_id; let my_person_id = local_user_view.person.id; // Make sure user is an admin is_admin(&local_user_view)?; // Also make sure you're a higher admin than the target LocalUser::is_higher_admin_check(&mut context.pool(), my_person_id, vec![data.person_id]).await?; is_valid_body_field(&data.reason, false)?; let expires_at = check_expire_time(data.expires_at)?; let form = InstanceBanForm::new( data.person_id, local_user_view.person.instance_id, expires_at, ); if data.ban { InstanceActions::ban(&mut context.pool(), &form).await?; } else { InstanceActions::unban(&mut context.pool(), &form).await?; } // Mod tables - create ban entry first so bulk actions can reference it as parent let form = ModlogInsertForm::admin_ban( &local_user_view.person, data.person_id, data.ban, expires_at, &data.reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), &context); // Remove their data if that's desired if data.remove_or_restore_data.unwrap_or(false) { let removed = data.ban; remove_or_restore_user_data( my_person_id, data.person_id, removed, &data.reason, action.first().ok_or(LemmyErrorType::NotFound)?.id, &context, ) .await?; }; let person_view = PersonView::read( &mut context.pool(), data.person_id, Some(my_person_id), local_instance_id, true, ) .await?; ActivityChannel::submit_activity( SendActivityData::BanFromSite { moderator: local_user_view.person, banned_user: person_view.person.clone(), reason: data.reason.clone(), remove_or_restore_data: data.remove_or_restore_data, ban: data.ban, expires_at: data.expires_at, }, &context, )?; Ok(Json(PersonResponse { person_view })) } ================================================ FILE: crates/api/api/src/local_user/block.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::{ source::person::{PersonActions, PersonBlockForm}, traits::Blockable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::{ PersonView, api::{BlockPerson, PersonResponse}, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn user_block_person( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let target_id = data.person_id; let my_person_id = local_user_view.person.id; let local_instance_id = local_user_view.person.instance_id; // Don't let a person block themselves if target_id == my_person_id { return Err(LemmyErrorType::CantBlockYourself.into()); } let person_block_form = PersonBlockForm::new(my_person_id, target_id); let target_user = LocalUserView::read_person(&mut context.pool(), target_id) .await .ok(); if target_user.is_some_and(|t| t.local_user.admin) { return Err(LemmyErrorType::CantBlockAdmin.into()); } if data.block { PersonActions::block(&mut context.pool(), &person_block_form).await?; } else { PersonActions::unblock(&mut context.pool(), &person_block_form).await?; } let person_view = PersonView::read( &mut context.pool(), target_id, Some(my_person_id), local_instance_id, false, ) .await?; Ok(Json(PersonResponse { person_view })) } ================================================ FILE: crates/api/api/src/local_user/change_password.rs ================================================ use actix_web::{ HttpRequest, web::{Data, Json}, }; use bcrypt::verify; use lemmy_api_utils::{ claims::Claims, context::LemmyContext, utils::{check_local_user_valid, password_length_check}, }; use lemmy_db_schema::source::{local_user::LocalUser, login_token::LoginToken}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::{ChangePassword, LoginResponse}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn change_password( Json(data): Json, req: HttpRequest, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; password_length_check(&data.new_password)?; // Make sure passwords match if data.new_password != data.new_password_verify { return Err(LemmyErrorType::PasswordsDoNotMatch.into()); } // Check the old password let valid: bool = if let Some(password_encrypted) = &local_user_view.local_user.password_encrypted { verify(&data.old_password, password_encrypted).unwrap_or(false) } else { data.old_password.is_empty() }; if !valid { return Err(LemmyErrorType::IncorrectLogin.into()); } let local_user_id = local_user_view.local_user.id; let new_password = data.new_password.clone(); let updated_local_user = LocalUser::update_password(&mut context.pool(), local_user_id, &new_password).await?; LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?; // Return the jwt Ok(Json(LoginResponse { jwt: Some(Claims::generate(updated_local_user.id, data.stay_logged_in, req, &context).await?), verify_email_sent: false, registration_created: false, })) } ================================================ FILE: crates/api/api/src/local_user/change_password_after_reset.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::password_length_check}; use lemmy_db_schema::source::{ local_user::LocalUser, login_token::LoginToken, password_reset_request::PasswordResetRequest, }; use lemmy_db_views_site::api::{PasswordChangeAfterReset, SuccessResponse}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn change_password_after_reset( Json(data): Json, context: Data, ) -> LemmyResult> { // Fetch the user_id from the token let token = data.token.clone(); let local_user_id = PasswordResetRequest::read_and_delete(&mut context.pool(), &token) .await? .local_user_id; password_length_check(&data.password)?; // Make sure passwords match if data.password != data.password_verify { return Err(LemmyErrorType::PasswordsDoNotMatch.into()); } // Update the user with the new password let password = data.password.clone(); LocalUser::update_password(&mut context.pool(), local_user_id, &password).await?; LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/donation_dialog_shown.rs ================================================ use actix_web::web::{Data, Json}; use chrono::Utc; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::LemmyResult; pub async fn donation_dialog_shown( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let form = LocalUserUpdateForm { last_donation_notification_at: Some(Utc::now()), ..Default::default() }; LocalUser::update(&mut context.pool(), local_user_view.local_user.id, &form).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/export_data.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_notification::{NotificationData, impls::NotificationQuery}; use lemmy_db_views_person_content_combined::impls::PersonContentCombinedQuery; use lemmy_db_views_person_liked_combined::impls::PersonLikedCombinedQuery; use lemmy_db_views_post::PostView; use lemmy_db_views_post_comment_combined::PostCommentCombinedView; use lemmy_db_views_site::{ api::{ExportDataResponse, PostOrCommentOrPrivateMessage}, impls::user_backup_list_to_user_settings_backup, }; use lemmy_utils::{self, error::LemmyResult}; pub async fn export_data( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { use PostOrCommentOrPrivateMessage::*; let local_instance_id = local_user_view.person.instance_id; let my_person_id = local_user_view.person.id; let my_person = &local_user_view.person; let local_user = &local_user_view.local_user; let pool = &mut context.pool(); let content = PersonContentCombinedQuery { no_limit: Some(true), ..PersonContentCombinedQuery::new(my_person_id) } .list(pool, Some(&local_user_view), local_instance_id) .await? .into_iter() .map(|u| match u { PostCommentCombinedView::Post(pv) => Post(pv.post), PostCommentCombinedView::Comment(cv) => Comment(cv.comment), }) .collect(); let notifications = NotificationQuery { no_limit: Some(true), show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts), ..NotificationQuery::default() } .list(pool, &local_user_view.person) .await? .into_iter() .flat_map(|u| match u.data { NotificationData::Post(p) => Some(Post(p.post)), NotificationData::Comment(c) => Some(Comment(c.comment)), NotificationData::PrivateMessage(pm) => Some(PrivateMessage(pm.private_message)), // skip modlog items NotificationData::ModAction(_) => None, }) .collect(); let liked = PersonLikedCombinedQuery { no_limit: Some(true), ..PersonLikedCombinedQuery::default() } .list(pool, &local_user_view) .await? .into_iter() .map(|u| { match u { PostCommentCombinedView::Post(pv) => pv.post.ap_id, PostCommentCombinedView::Comment(cv) => cv.comment.ap_id, } .into() }) .collect(); let read_posts = PostView::list_read(pool, my_person, None, None, Some(true)) .await? .into_iter() .map(|pv| pv.post.ap_id.into()) .collect(); let moderates = CommunityModeratorView::for_person(pool, my_person_id, Some(local_user)) .await? .into_iter() .map(|cv| cv.community.ap_id.into()) .collect(); let settings = user_backup_list_to_user_settings_backup(local_user_view, &mut context.pool()).await?; Ok(Json(ExportDataResponse { notifications, content, liked, read_posts, moderates, settings, })) } ================================================ FILE: crates/api/api/src/local_user/generate_totp_secret.rs ================================================ use crate::{build_totp_2fa, generate_totp_2fa_secret}; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{SiteView, api::GenerateTotpSecretResponse}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; /// Generate a new secret for two-factor-authentication. Afterwards you need to call [toggle_totp] /// to enable it. This can only be called if 2FA is currently disabled. pub async fn generate_totp_secret( local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let site = SiteView::read_local(&mut context.pool()).await?.site; if local_user_view.local_user.totp_2fa_enabled { return Err(LemmyErrorType::TotpAlreadyEnabled.into()); } let secret = generate_totp_2fa_secret(); let secret_url = build_totp_2fa(&site.name, &local_user_view.person.name, &secret)?.get_url(); let local_user_form = LocalUserUpdateForm { totp_2fa_secret: Some(Some(secret)), ..Default::default() }; LocalUser::update( &mut context.pool(), local_user_view.local_user.id, &local_user_form, ) .await?; Ok(Json(GenerateTotpSecretResponse { totp_secret_url: secret_url.into(), })) } ================================================ FILE: crates/api/api/src/local_user/get_captcha.rs ================================================ use actix_web::{ HttpResponse, HttpResponseBuilder, http::{ StatusCode, header::{CacheControl, CacheDirective}, }, web::Json, }; use lemmy_api_utils::plugins::{is_captcha_plugin_loaded, plugin_get_captcha}; use lemmy_db_views_site::api::GetCaptchaResponse; use lemmy_utils::error::LemmyResult; pub async fn get_captcha() -> LemmyResult { let mut res = HttpResponseBuilder::new(StatusCode::OK); res.insert_header(CacheControl(vec![CacheDirective::NoStore])); if !is_captcha_plugin_loaded() { return Ok(res.json(Json(GetCaptchaResponse { ok: None }))); } let captcha = GetCaptchaResponse { ok: Some(plugin_get_captcha().await?), }; Ok(res.json(Json(captcha))) } ================================================ FILE: crates/api/api/src/local_user/list_hidden.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person_content_combined::api::ListPersonHidden; use lemmy_db_views_post::PostView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_person_hidden( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let hidden = PostView::list_hidden( &mut context.pool(), &local_user_view.person, data.page_cursor, data.limit, None, ) .await?; Ok(Json(hidden)) } ================================================ FILE: crates/api/api/src/local_user/list_liked.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person_liked_combined::{ListPersonLiked, impls::PersonLikedCombinedQuery}; use lemmy_db_views_post_comment_combined::PostCommentCombinedView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_person_liked( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let liked = PersonLikedCombinedQuery { type_: data.type_, like_type: data.like_type, page_cursor: data.page_cursor, limit: data.limit, no_limit: None, } .list(&mut context.pool(), &local_user_view) .await?; Ok(Json(liked)) } ================================================ FILE: crates/api/api/src/local_user/list_logins.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::login_token::LoginToken; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::ListLoginsResponse; use lemmy_utils::error::LemmyResult; pub async fn list_logins( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let logins = LoginToken::list(&mut context.pool(), local_user_view.local_user.id).await?; Ok(Json(ListLoginsResponse { logins })) } ================================================ FILE: crates/api/api/src/local_user/list_media.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_local_image::{LocalImageView, api::ListMedia}; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_media( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let images = LocalImageView::get_all_paged_by_person_id( &mut context.pool(), local_user_view.person.id, data.page_cursor, data.limit, ) .await?; Ok(Json(images)) } ================================================ FILE: crates/api/api/src/local_user/list_read.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person_content_combined::api::ListPersonRead; use lemmy_db_views_post::PostView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_person_read( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let read = PostView::list_read( &mut context.pool(), &local_user_view.person, data.page_cursor, data.limit, None, ) .await?; Ok(Json(read)) } ================================================ FILE: crates/api/api/src/local_user/list_saved.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person_saved_combined::{ListPersonSaved, impls::PersonSavedCombinedQuery}; use lemmy_db_views_post_comment_combined::PostCommentCombinedView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_person_saved( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let local_site = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&Some(local_user_view.clone()), &local_site.local_site)?; let saved = PersonSavedCombinedQuery { type_: data.type_, page_cursor: data.page_cursor, limit: data.limit, no_limit: None, } .list(&mut context.pool(), &local_user_view) .await?; Ok(Json(saved)) } ================================================ FILE: crates/api/api/src/local_user/login.rs ================================================ use crate::check_totp_2fa_valid; use actix_web::{ HttpRequest, web::{Data, Json}, }; use bcrypt::verify; use lemmy_api_utils::{ claims::Claims, context::LemmyContext, utils::{check_email_verified, check_local_user_deleted, check_registration_application}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{Login, LoginResponse}, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn login( Json(data): Json, req: HttpRequest, context: Data, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; // Fetch that username / email let username_or_email = data.username_or_email.clone(); let local_user_view = LocalUserView::find_by_email_or_name(&mut context.pool(), &username_or_email).await?; // Verify the password let valid: bool = local_user_view .local_user .password_encrypted .as_ref() .and_then(|password_encrypted| verify(&data.password, password_encrypted).ok()) .unwrap_or(false); if !valid { return Err(LemmyErrorType::IncorrectLogin.into()); } check_local_user_deleted(&local_user_view)?; check_email_verified(&local_user_view, &site_view)?; check_registration_application(&local_user_view, &site_view.local_site, &mut context.pool()) .await?; // Check the totp if enabled if local_user_view.local_user.totp_2fa_enabled { check_totp_2fa_valid( &local_user_view, &data.totp_2fa_token, &context.settings().hostname, )?; } let jwt = Claims::generate( local_user_view.local_user.id, data.stay_logged_in, req, &context, ) .await?; Ok(Json(LoginResponse { jwt: Some(jwt.clone()), verify_email_sent: false, registration_created: false, })) } ================================================ FILE: crates/api/api/src/local_user/logout.rs ================================================ use activitypub_federation::config::Data; use actix_web::{HttpRequest, HttpResponse, cookie::Cookie}; use lemmy_api_utils::{ context::LemmyContext, utils::{AUTH_COOKIE_NAME, read_auth_token}, }; use lemmy_db_schema::source::login_token::LoginToken; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn logout( req: HttpRequest, // require login _local_user_view: LocalUserView, context: Data, ) -> LemmyResult { let jwt = read_auth_token(&req)?.ok_or(LemmyErrorType::NotLoggedIn)?; LoginToken::invalidate(&mut context.pool(), &jwt).await?; let mut res = HttpResponse::Ok().json(SuccessResponse::default()); let cookie = Cookie::new(AUTH_COOKIE_NAME, ""); res.add_removal_cookie(&cookie)?; Ok(res) } ================================================ FILE: crates/api/api/src/local_user/mod.rs ================================================ pub mod add_admin; pub mod ban_person; pub mod block; pub mod change_password; pub mod change_password_after_reset; pub mod donation_dialog_shown; pub mod export_data; pub mod generate_totp_secret; pub mod get_captcha; pub mod list_hidden; pub mod list_liked; pub mod list_logins; pub mod list_media; pub mod list_read; pub mod list_saved; pub mod login; pub mod logout; pub mod note_person; pub mod notifications; pub mod resend_verification_email; pub mod reset_password; pub mod save_settings; pub mod unread_counts; pub mod update_totp; pub mod user_block_instance; pub mod validate_auth; pub mod verify_email; ================================================ FILE: crates/api/api/src/local_user/note_person.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_local_user_valid, get_url_blocklist, process_markdown, slur_regex}, }; use lemmy_db_schema::source::person::{PersonActions, PersonNoteForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::{ PersonView, api::{NotePerson, PersonResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::{slurs::check_slurs, validation::is_valid_body_field}, }; pub async fn user_note_person( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let target_id = data.person_id; let my_person_id = local_user_view.person.id; let local_instance_id = local_user_view.person.instance_id; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; // Don't let a person note themselves if target_id == my_person_id { return Err(LemmyErrorType::CantNoteYourself.into()); } // If the note is empty, delete it if data.note.is_empty() { PersonActions::delete_note(&mut context.pool(), my_person_id, target_id).await?; } else { check_slurs(&data.note, &slur_regex)?; is_valid_body_field(&data.note, false)?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let note = process_markdown( &data.note, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; let note_form = PersonNoteForm::new(my_person_id, target_id, note); PersonActions::note(&mut context.pool(), ¬e_form).await?; } let person_view = PersonView::read( &mut context.pool(), target_id, Some(my_person_id), local_instance_id, false, ) .await?; Ok(Json(PersonResponse { person_view })) } ================================================ FILE: crates/api/api/src/local_user/notifications/list.rs ================================================ use crate::hide_modlog_names; use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_notification::{ListNotifications, NotificationView, impls::NotificationQuery}; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_notifications( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let hide_modlog_names = hide_modlog_names(Some(&local_user_view), None, &context).await; let notifications = NotificationQuery { type_: data.type_, unread_only: data.unread_only, show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts), page_cursor: data.page_cursor, hide_modlog_names: Some(hide_modlog_names), creator_id: data.creator_id, limit: data.limit, no_limit: None, } .list(&mut context.pool(), &local_user_view.person) .await?; Ok(Json(notifications)) } ================================================ FILE: crates/api/api/src/local_user/notifications/mark_all_read.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::notification::Notification; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::LemmyResult; pub async fn mark_all_notifications_read( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { Notification::mark_all_as_read(&mut context.pool(), local_user_view.person.id).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/notifications/mark_notification_read.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::notification::Notification; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_notification::api::MarkNotificationAsRead; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::LemmyResult; pub async fn mark_notification_as_read( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { Notification::mark_read_by_id_and_person( &mut context.pool(), data.notification_id, local_user_view.person.id, data.read, ) .await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/notifications/mod.rs ================================================ pub mod list; pub mod mark_all_read; pub mod mark_notification_read; ================================================ FILE: crates/api/api/src/local_user/resend_verification_email.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{ResendVerificationEmail, SuccessResponse}, }; use lemmy_email::account::send_verification_email_if_required; use lemmy_utils::error::LemmyResult; pub async fn resend_verification_email( Json(data): Json, context: Data, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let email = data.email.to_string(); // Fetch that email let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email).await?; check_local_user_valid(&local_user_view)?; send_verification_email_if_required( &site_view.local_site, &local_user_view, &mut context.pool(), context.settings(), ) .await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/reset_password.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_email_verified, check_local_user_valid}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{PasswordReset, SuccessResponse}, }; use lemmy_email::account::send_password_reset_email; use lemmy_utils::error::LemmyResult; use tracing::error; pub async fn reset_password( Json(data): Json, context: Data, ) -> LemmyResult> { let email = data.email.to_lowercase(); // For security, errors are not returned. // https://github.com/LemmyNet/lemmy/issues/5277 let _ = try_reset_password(&email, &context).await; Ok(Json(SuccessResponse::default())) } async fn try_reset_password(email: &str, context: &LemmyContext) -> LemmyResult<()> { let local_user_view = LocalUserView::find_by_email(&mut context.pool(), email).await?; check_local_user_valid(&local_user_view)?; let site_view = SiteView::read_local(&mut context.pool()).await?; check_email_verified(&local_user_view, &site_view)?; if let Err(e) = send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await { error!("Failed to send password reset email: {}", e); } Ok(()) } ================================================ FILE: crates/api/api/src/local_user/save_settings.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, utils::{check_local_user_valid, get_url_blocklist, process_markdown_opt, slur_regex}, }; use lemmy_db_schema::{ source::{ actor_language::LocalUserLanguage, keyword_block::LocalUserKeywordBlock, local_user::{LocalUser, LocalUserUpdateForm}, person::{Person, PersonUpdateForm}, }, utils::limit_fetch_check, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{SaveUserSettings, SuccessResponse}, }; use lemmy_diesel_utils::{ traits::Crud, utils::{diesel_opt_number_update, diesel_string_update}, }; use lemmy_email::account::send_verification_email; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::{ check_blocking_keywords_are_valid, is_valid_bio_field, is_valid_display_name, is_valid_matrix_id, }, }; use std::ops::Deref; pub async fn save_user_settings( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let bio = diesel_string_update( process_markdown_opt( &data.bio, &slur_regex, &url_blocklist, &local_site, &context, ) .await? .as_deref(), ); let display_name = diesel_string_update(data.display_name.as_deref().map(str::trim)); let matrix_user_id = diesel_string_update(data.matrix_user_id.as_deref()); let email_deref = data.email.as_deref().map(str::to_lowercase); let email = diesel_string_update(email_deref.as_deref()); if let Some(Some(email)) = email.clone() { let previous_email = local_user_view.local_user.email.clone().unwrap_or_default(); // if email was changed, check that it is not taken and send verification mail if previous_email.deref() != email { LocalUser::check_is_email_taken(&mut context.pool(), &email).await?; send_verification_email( &local_site, &local_user_view, email.into(), &mut context.pool(), context.settings(), ) .await?; } } // When the site requires email, make sure email is not Some(None). IE, an overwrite to a None // value if let Some(email) = &email && email.is_none() && local_site.require_email_verification { return Err(LemmyErrorType::EmailRequired.into()); } if let Some(Some(bio)) = &bio { is_valid_bio_field(bio)?; } if let Some(Some(display_name)) = &display_name { is_valid_display_name(display_name)?; } if let Some(Some(matrix_user_id)) = &matrix_user_id { is_valid_matrix_id(matrix_user_id)?; } if let Some(send_notifications_to_email) = data.send_notifications_to_email && local_site.disable_email_notifications && send_notifications_to_email { return Err(LemmyErrorType::EmailNotificationsDisabled.into()); } let local_user_id = local_user_view.local_user.id; let person_id = local_user_view.person.id; let default_listing_type = data.default_listing_type; let default_post_sort_type = data.default_post_sort_type; let default_post_time_range_seconds = diesel_opt_number_update(data.default_post_time_range_seconds); let default_items_per_page = data.default_items_per_page; if let Some(default_items_per_page) = default_items_per_page { limit_fetch_check(default_items_per_page.into())?; }; let default_comment_sort_type = data.default_comment_sort_type; let person_form = PersonUpdateForm { display_name, bio, matrix_user_id, bot_account: data.bot_account, ..Default::default() }; // Ignore errors, because 'no fields updated' will return an error. // https://github.com/LemmyNet/lemmy/issues/4076 Person::update(&mut context.pool(), person_id, &person_form) .await .ok(); if let Some(discussion_languages) = data.discussion_languages.clone() { LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?; } if let Some(blocking_keywords) = data.blocking_keywords.clone() { let trimmed_blocking_keywords = blocking_keywords .iter() .map(|blocking_keyword| blocking_keyword.trim().to_string()) .collect(); check_blocking_keywords_are_valid(&trimmed_blocking_keywords)?; LocalUserKeywordBlock::update( &mut context.pool(), trimmed_blocking_keywords, local_user_id, ) .await?; } let local_user_form = LocalUserUpdateForm { email, show_avatars: data.show_avatars, show_read_posts: data.show_read_posts, send_notifications_to_email: data.send_notifications_to_email, show_nsfw: data.show_nsfw, blur_nsfw: data.blur_nsfw, show_bot_accounts: data.show_bot_accounts, default_post_sort_type, default_post_time_range_seconds, default_comment_sort_type, default_listing_type, default_items_per_page, theme: data.theme.clone(), interface_language: data.interface_language.clone(), open_links_in_new_tab: data.open_links_in_new_tab, infinite_scroll_enabled: data.infinite_scroll_enabled, post_listing_mode: data.post_listing_mode, enable_animated_images: data.enable_animated_images, enable_private_messages: data.enable_private_messages, collapse_bot_comments: data.collapse_bot_comments, auto_mark_fetched_posts_as_read: data.auto_mark_fetched_posts_as_read, hide_media: data.hide_media, // Update the vote display modes show_score: data.show_score, show_upvotes: data.show_upvotes, show_downvotes: data.show_downvotes, show_upvote_percentage: data.show_upvote_percentage, show_person_votes: data.show_person_votes, ..Default::default() }; LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/unread_counts.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, utils::{check_community_mod_of_any_or_admin_action, is_admin}, }; use lemmy_db_views_community_follower_approval::PendingFollowerView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_notification::NotificationView; use lemmy_db_views_registration_applications::RegistrationApplicationView; use lemmy_db_views_report_combined::ReportCombinedViewInternal; use lemmy_db_views_site::{SiteView, api::UnreadCountsResponse}; use lemmy_utils::error::LemmyResult; pub async fn get_unread_counts( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let person = &local_user_view.person; let show_bot_accounts = local_user_view.local_user.show_bot_accounts; let notification_count = NotificationView::get_unread_count(&mut context.pool(), person, show_bot_accounts).await?; // Community mods get additional counts for reports and pending follows for private communities. let (report_count, pending_follow_count) = if check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()) .await .is_ok() { ( Some( ReportCombinedViewInternal::get_report_count(&mut context.pool(), &local_user_view) .await?, ), Some(PendingFollowerView::count_approval_required(&mut context.pool(), person.id).await?), ) } else { (None, None) }; // Admins also get the number of unread registration applications. let registration_application_count = if is_admin(&local_user_view).is_ok() { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let verified_email_only = local_site.require_email_verification; Some( RegistrationApplicationView::get_unread_count(&mut context.pool(), verified_email_only) .await?, ) } else { None }; Ok(Json(UnreadCountsResponse { notification_count, report_count, pending_follow_count, registration_application_count, })) } ================================================ FILE: crates/api/api/src/local_user/update_totp.rs ================================================ use crate::check_totp_2fa_valid; use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::{EditTotp, EditTotpResponse}; use lemmy_utils::error::LemmyResult; /// Enable or disable two-factor-authentication. The current setting is determined from /// [LocalUser.totp_2fa_enabled]. /// /// To enable, you need to first call [generate_totp_secret] and then pass a valid token to this /// function. /// /// Disabling is only possible if 2FA was previously enabled. Again it is necessary to pass a valid /// token. pub async fn edit_totp( Json(data): Json, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; check_totp_2fa_valid( &local_user_view, &Some(data.totp_token.clone()), &context.settings().hostname, )?; // toggle the 2fa setting let local_user_form = LocalUserUpdateForm { totp_2fa_enabled: Some(data.enabled), // if totp is enabled, leave unchanged. otherwise clear secret totp_2fa_secret: if data.enabled { None } else { Some(None) }, ..Default::default() }; LocalUser::update( &mut context.pool(), local_user_view.local_user.id, &local_user_form, ) .await?; Ok(Json(EditTotpResponse { enabled: data.enabled, })) } ================================================ FILE: crates/api/api/src/local_user/user_block_instance.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::source::instance::{ InstanceActions, InstanceCommunitiesBlockForm, InstancePersonsBlockForm, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::{ SuccessResponse, UserBlockInstanceCommunitiesParams, UserBlockInstancePersonsParams, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn user_block_instance_communities( Json(data): Json, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let instance_id = data.instance_id; let person_id = local_user_view.person.id; if local_user_view.person.instance_id == instance_id { return Err(LemmyErrorType::CantBlockLocalInstance.into()); } let block_form = InstanceCommunitiesBlockForm::new(person_id, instance_id); if data.block { InstanceActions::block_communities(&mut context.pool(), &block_form).await?; } else { InstanceActions::unblock_communities(&mut context.pool(), &block_form).await?; } Ok(Json(SuccessResponse::default())) } pub async fn user_block_instance_persons( Json(data): Json, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let instance_id = data.instance_id; let person_id = local_user_view.person.id; if local_user_view.person.instance_id == instance_id { return Err(LemmyErrorType::CantBlockLocalInstance.into()); } let block_form = InstancePersonsBlockForm::new(person_id, instance_id); if data.block { InstanceActions::block_persons(&mut context.pool(), &block_form).await?; } else { InstanceActions::unblock_persons(&mut context.pool(), &block_form).await?; } Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/validate_auth.rs ================================================ use actix_web::{ HttpRequest, web::{Data, Json}, }; use lemmy_api_utils::{ context::LemmyContext, utils::{local_user_view_from_jwt, read_auth_token}, }; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; /// Returns an error message if the auth token is invalid for any reason. Necessary because other /// endpoints silently treat any call with invalid auth as unauthenticated. pub async fn validate_auth( req: HttpRequest, context: Data, ) -> LemmyResult> { let jwt = read_auth_token(&req)?; if let Some(jwt) = jwt { local_user_view_from_jwt(&jwt, &context).await?; } else { return Err(LemmyErrorType::NotLoggedIn.into()); } Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/local_user/verify_email.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::source::{ email_verification::EmailVerification, local_user::{LocalUser, LocalUserUpdateForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{SuccessResponse, VerifyEmail}, }; use lemmy_email::{account::send_email_verified_email, admin::send_new_applicant_email_to_admins}; use lemmy_utils::error::LemmyResult; pub async fn verify_email( Json(data): Json, context: Data, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let token = data.token.clone(); let verification = EmailVerification::read_for_token(&mut context.pool(), &token).await?; let local_user_id = verification.local_user_id; let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?; check_local_user_valid(&local_user_view)?; // Check if their email has already been verified once, before this let email_already_verified = local_user_view.local_user.email_verified; let form = LocalUserUpdateForm { // necessary in case this is a new signup email_verified: Some(true), // necessary in case email of an existing user was changed email: Some(Some(verification.email)), ..Default::default() }; LocalUser::update(&mut context.pool(), local_user_id, &form).await?; EmailVerification::delete_old_tokens_for_local_user(&mut context.pool(), local_user_id).await?; // Send out notification about registration application to admins if enabled, and the user hasn't // already been verified. if site_view.local_site.application_email_admins && !email_already_verified { send_new_applicant_email_to_admins( &local_user_view.person.name, &mut context.pool(), context.settings(), ) .await?; } send_email_verified_email(&local_user_view, context.settings())?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/post/feature.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_mod_action, is_admin}, }; use lemmy_db_schema::{ PostFeatureType, source::{ community::Community, modlog::{Modlog, ModlogInsertForm}, post::{Post, PostUpdateForm}, }, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::{FeaturePost, PostResponse}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn feature_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let post_id = data.post_id; let orig_post = Post::read(&mut context.pool(), post_id).await?; let community = Community::read(&mut context.pool(), orig_post.community_id).await?; check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?; if data.feature_type == PostFeatureType::Local { is_admin(&local_user_view)?; } // Update the post let post_id = data.post_id; let (post_form, modlog_form) = if data.feature_type == PostFeatureType::Community { ( PostUpdateForm { featured_community: Some(data.featured), ..Default::default() }, ModlogInsertForm::mod_feature_post_community( local_user_view.person.id, &orig_post, data.featured, ), ) } else { ( PostUpdateForm { featured_local: Some(data.featured), ..Default::default() }, ModlogInsertForm::admin_feature_post_site(&local_user_view.person, &orig_post, data.featured), ) }; let post = Post::update(&mut context.pool(), post_id, &post_form).await?; // Mod tables Modlog::create(&mut context.pool(), &[modlog_form]).await?; ActivityChannel::submit_activity( SendActivityData::FeaturePost(post, local_user_view.person.clone(), data.featured), &context, )?; build_post_response(&context, orig_post.community_id, local_user_view, post_id).await } ================================================ FILE: crates/api/api/src/post/get_link_metadata.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, request::fetch_link_metadata}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::{GetSiteMetadata, GetSiteMetadataResponse}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use url::Url; pub async fn get_link_metadata( Query(data): Query, context: Data, // Require an account for this API _local_user_view: LocalUserView, ) -> LemmyResult> { let url = Url::parse(&data.url).with_lemmy_type(LemmyErrorType::InvalidUrl)?; let metadata = fetch_link_metadata(&url, &context, false).await?; Ok(Json(GetSiteMetadataResponse { metadata })) } ================================================ FILE: crates/api/api/src/post/hide.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::source::post::{PostActions, PostHideForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{HidePost, PostResponse}, }; use lemmy_utils::error::LemmyResult; pub async fn hide_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let person_id = local_user_view.person.id; let local_instance_id = local_user_view.person.instance_id; let post_id = data.post_id; let hide_form = PostHideForm::new(post_id, person_id); // Mark the post as hidden / unhidden if data.hide { PostActions::hide(&mut context.pool(), &hide_form).await?; } else { PostActions::unhide(&mut context.pool(), &hide_form).await?; } let post_view = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; Ok(Json(PostResponse { post_view })) } ================================================ FILE: crates/api/api/src/post/like.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_bot_account, check_community_user_action, check_local_user_valid, check_local_vote_mode, }, }; use lemmy_db_schema::{ newtypes::PostOrCommentId, source::{ notification::Notification, person::PersonActions, post::{PostActions, PostLikeForm}, }, traits::Likeable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{CreatePostLike, PostResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; use std::ops::Deref; pub async fn like_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let local_instance_id = local_user_view.person.instance_id; let post_id = data.post_id; let my_person_id = local_user_view.person.id; check_local_vote_mode( data.is_upvote, PostOrCommentId::Post(post_id), &local_site, my_person_id, &mut context.pool(), ) .await?; check_bot_account(&local_user_view.person)?; // Check for a community ban let orig_post = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; let previous_is_upvote = orig_post.post_actions.and_then(|p| p.vote_is_upvote); check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?; let mut like_form = PostLikeForm::new(data.post_id, my_person_id, data.is_upvote); like_form = plugin_hook_before("post_before_vote", like_form).await?; let like = PostActions::like(&mut context.pool(), &like_form).await?; PersonActions::like( &mut context.pool(), my_person_id, orig_post.creator.id, previous_is_upvote, data.is_upvote, ) .await?; plugin_hook_after("post_after_vote", &like); // Mark Post Read PostActions::mark_as_read(&mut context.pool(), my_person_id, &[post_id]).await?; // Mark any notifications as read Notification::mark_read_by_post_and_recipient(&mut context.pool(), post_id, my_person_id, true) .await .ok(); ActivityChannel::submit_activity( SendActivityData::LikePostOrComment { object_id: orig_post.post.ap_id, actor: local_user_view.person.clone(), community: orig_post.community.clone(), previous_is_upvote, new_is_upvote: data.is_upvote, }, &context, )?; build_post_response( context.deref(), orig_post.community.id, local_user_view, post_id, ) .await } ================================================ FILE: crates/api/api/src/post/list_post_likes.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::is_mod_or_admin}; use lemmy_db_schema::source::post::Post; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::ListPostLikes; use lemmy_db_views_vote::VoteView; use lemmy_diesel_utils::{pagination::PagedResponse, traits::Crud}; use lemmy_utils::error::LemmyResult; /// Lists likes for a post pub async fn list_post_likes( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let post = Post::read(&mut context.pool(), data.post_id).await?; is_mod_or_admin(&mut context.pool(), &local_user_view, post.community_id).await?; let post_likes = VoteView::list_for_post( &mut context.pool(), data.post_id, data.page_cursor, data.limit, local_user_view.person.instance_id, ) .await?; Ok(Json(post_likes)) } ================================================ FILE: crates/api/api/src/post/lock.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::source::{ modlog::{Modlog, ModlogInsertForm}, post::{Post, PostUpdateForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{LockPost, PostResponse}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn lock_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let post_id = data.post_id; let local_instance_id = local_user_view.person.instance_id; let orig_post = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; check_community_mod_action( &local_user_view, &orig_post.community, false, &mut context.pool(), ) .await?; // Update the post let post_id = data.post_id; let locked = data.locked; let post = Post::update( &mut context.pool(), post_id, &PostUpdateForm { locked: Some(locked), ..Default::default() }, ) .await?; // Mod tables let form = ModlogInsertForm::mod_lock_post( local_user_view.person.id, &orig_post.post, locked, &data.reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), &context); ActivityChannel::submit_activity( SendActivityData::LockPost( post, local_user_view.person.clone(), data.locked, data.reason.clone(), ), &context, )?; build_post_response(&context, orig_post.community.id, local_user_view, post_id).await } ================================================ FILE: crates/api/api/src/post/mark_many_read.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::post::PostActions; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::MarkManyPostsAsRead; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::{error::LemmyResult, utils::validation::check_api_elements_count}; pub async fn mark_posts_as_read( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let post_ids = &data.post_ids; check_api_elements_count(post_ids.len())?; let person_id = local_user_view.person.id; // Mark the posts as read / unread if data.read { PostActions::mark_as_read(&mut context.pool(), person_id, post_ids).await?; } else { PostActions::mark_as_unread(&mut context.pool(), person_id, post_ids).await?; } Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/post/mark_read.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::post::PostActions; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{MarkPostAsRead, PostResponse}, }; use lemmy_utils::error::LemmyResult; pub async fn mark_post_as_read( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let person_id = local_user_view.person.id; let local_instance_id = local_user_view.person.instance_id; let post_id = data.post_id; // Mark the post as read / unread if data.read { PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?; } else { PostActions::mark_as_unread(&mut context.pool(), person_id, &[post_id]).await?; } let post_view = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; Ok(Json(PostResponse { post_view })) } ================================================ FILE: crates/api/api/src/post/mod.rs ================================================ pub mod feature; pub mod get_link_metadata; pub mod hide; pub mod like; pub mod list_post_likes; pub mod lock; pub mod mark_many_read; pub mod mark_read; pub mod mod_update; pub mod save; pub mod update_notifications; pub mod warning; ================================================ FILE: crates/api/api/src/post/mod_update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_community_user_action, check_is_mod_or_admin, check_nsfw_allowed, update_post_tags, }, }; use lemmy_db_schema::source::post::{Post, PostUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{ModEditPost, PostResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use std::ops::Deref; pub async fn mod_edit_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let local_instance_id = local_user_view.person.instance_id; check_nsfw_allowed(data.nsfw, Some(&local_site))?; let post_id = data.post_id; let orig_post = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; let community = orig_post.community; check_community_user_action(&local_user_view, &community, &mut context.pool()).await?; check_is_mod_or_admin(&mut context.pool(), local_user_view.person.id, community.id).await?; let mut post_form = PostUpdateForm { nsfw: data.nsfw, updated_at: Some(Some(Utc::now())), ..Default::default() }; post_form = plugin_hook_before("local_post_before_vote", post_form).await?; let post_id = data.post_id; let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?; plugin_hook_after("local_post_after_vote", &post_form); if let Some(tags) = &data.tags { update_post_tags(&updated_post, tags, &context).await?; } ActivityChannel::submit_activity(SendActivityData::UpdatePost(updated_post.clone()), &context)?; build_post_response(context.deref(), community.id, local_user_view, post_id).await } ================================================ FILE: crates/api/api/src/post/save.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_valid}; use lemmy_db_schema::{ source::post::{PostActions, PostSavedForm}, traits::Saveable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{PostResponse, SavePost}, }; use lemmy_utils::error::LemmyResult; pub async fn save_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let post_saved_form = PostSavedForm::new(data.post_id, local_user_view.person.id); if data.save { PostActions::save(&mut context.pool(), &post_saved_form).await?; } else { PostActions::unsave(&mut context.pool(), &post_saved_form).await?; } let post_id = data.post_id; let person_id = local_user_view.person.id; let local_instance_id = local_user_view.person.instance_id; let post_view = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?; Ok(Json(PostResponse { post_view })) } ================================================ FILE: crates/api/api/src/post/update_notifications.rs ================================================ use crate::community::do_follow_community; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::{ community::Community, post::{Post, PostActions}, }; use lemmy_db_schema_file::enums::PostNotificationsMode; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::EditPostNotifications; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn edit_post_notifications( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { PostActions::update_notification_state( data.post_id, local_user_view.person.id, data.mode, &mut context.pool(), ) .await?; let post = Post::read(&mut context.pool(), data.post_id).await?; // To get notifications for a remote community, the user needs to follow it over federation. // Do this automatically here to avoid confusion. if data.mode == PostNotificationsMode::AllComments { let community = Community::read(&mut context.pool(), post.community_id).await?; if !community.local { do_follow_community(community, &local_user_view.person, true, &context).await?; } } Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/post/warning.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, notify::notify_mod_action, utils::check_community_mod_action, }; use lemmy_db_schema::source::modlog::{Modlog, ModlogInsertForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{CreatePostWarning, PostResponse}, }; use lemmy_utils::error::LemmyResult; /// Creates a warning against a post and notifies the user pub async fn create_post_warning( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let post_id = data.post_id; let local_instance_id = local_user_view.person.instance_id; let orig_post = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; check_community_mod_action( &local_user_view, &orig_post.community, false, &mut context.pool(), ) .await?; // Mod tables let form = ModlogInsertForm::mod_create_post_warning( local_user_view.person.id, &orig_post.post, &data.reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, &context); // TODO federate activity build_post_response(&context, orig_post.community.id, local_user_view, post_id).await } ================================================ FILE: crates/api/api/src/reports/comment_report/create.rs ================================================ use crate::check_report_reason; use activitypub_federation::config::Data; use actix_web::web::Json; use either::Either; use lemmy_api_utils::{ context::LemmyContext, plugins::plugin_hook_after, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_comment_deleted_or_removed, check_community_user_action, check_local_user_valid, slur_regex, }, }; use lemmy_db_schema::{ source::comment_report::{CommentReport, CommentReportForm}, traits::Reportable, }; use lemmy_db_views_comment::CommentView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{CommentReportResponse, CreateCommentReport}, }; use lemmy_db_views_site::SiteView; use lemmy_email::admin::send_new_report_email_to_admins; use lemmy_utils::error::LemmyResult; /// Creates a comment report and notifies the moderators of the community pub async fn create_comment_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let reason = data.reason.trim().to_string(); let slur_regex = slur_regex(&context).await?; check_report_reason(&reason, &slur_regex)?; let person = &local_user_view.person; let local_instance_id = local_user_view.person.instance_id; let comment_id = data.comment_id; let comment_view = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; check_community_user_action( &local_user_view, &comment_view.community, &mut context.pool(), ) .await?; // Don't allow creating reports for removed / deleted comments check_comment_deleted_or_removed(&comment_view.comment)?; let report_form = CommentReportForm { creator_id: person.id, comment_id, original_comment_text: comment_view.comment.content, reason, violates_instance_rules: data.violates_instance_rules.unwrap_or_default(), }; let report = CommentReport::report(&mut context.pool(), &report_form).await?; let comment_report_view = ReportCombinedViewInternal::read_comment_report(&mut context.pool(), report.id, person).await?; plugin_hook_after("comment_report_after_create", &comment_report_view); // Email the admins let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; if local_site.reports_email_admins { send_new_report_email_to_admins( &comment_report_view.creator.name, &comment_report_view.comment_creator.name, &mut context.pool(), context.settings(), ) .await?; } if !report.violates_instance_rules { ActivityChannel::submit_activity( SendActivityData::CreateReport { object_id: comment_view.comment.ap_id.inner().clone(), actor: local_user_view.person, receiver: Either::Right(comment_view.community), reason: data.reason.clone(), }, &context, )?; } Ok(Json(CommentReportResponse { comment_report_view, })) } ================================================ FILE: crates/api/api/src/reports/comment_report/mod.rs ================================================ pub mod create; pub mod resolve; ================================================ FILE: crates/api/api/src/reports/comment_report/resolve.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use either::Either; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::{source::comment_report::CommentReport, traits::Reportable}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{CommentReportResponse, ResolveCommentReport}, }; use lemmy_utils::error::LemmyResult; /// Resolves or unresolves a comment report and notifies the moderators of the community pub async fn resolve_comment_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let report_id = data.report_id; let person = &local_user_view.person; let report = ReportCombinedViewInternal::read_comment_report(&mut context.pool(), report_id, person).await?; let person_id = local_user_view.person.id; check_community_mod_action( &local_user_view, &report.community, true, &mut context.pool(), ) .await?; CommentReport::update_resolved(&mut context.pool(), report_id, person_id, data.resolved).await?; let report_id = data.report_id; let comment_report_view = ReportCombinedViewInternal::read_comment_report(&mut context.pool(), report_id, person).await?; ActivityChannel::submit_activity( SendActivityData::SendResolveReport { object_id: comment_report_view.comment.ap_id.inner().clone(), actor: local_user_view.person, report_creator: report.creator, receiver: Either::Right(comment_report_view.community.clone()), }, &context, )?; Ok(Json(CommentReportResponse { comment_report_view, })) } ================================================ FILE: crates/api/api/src/reports/community_report/create.rs ================================================ use crate::check_report_reason; use activitypub_federation::config::Data; use actix_web::web::Json; use either::Either; use lemmy_api_utils::{ context::LemmyContext, plugins::plugin_hook_after, send_activity::{ActivityChannel, SendActivityData}, utils::{check_local_user_valid, slur_regex}, }; use lemmy_db_schema::{ source::{ community::Community, community_report::{CommunityReport, CommunityReportForm}, site::Site, }, traits::Reportable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{CommunityReportResponse, CreateCommunityReport}, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_email::admin::send_new_report_email_to_admins; use lemmy_utils::error::LemmyResult; pub async fn create_community_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let reason = data.reason.trim().to_string(); let slur_regex = slur_regex(&context).await?; check_report_reason(&reason, &slur_regex)?; let person = &local_user_view.person; let community_id = data.community_id; let community = Community::read(&mut context.pool(), community_id).await?; let site = Site::read_from_instance_id(&mut context.pool(), community.instance_id).await?; let report_form = CommunityReportForm { creator_id: person.id, community_id, original_community_banner: community.banner, original_community_summary: community.summary, original_community_icon: community.icon, original_community_name: community.name, original_community_sidebar: community.sidebar, original_community_title: community.title, reason, }; let report = CommunityReport::report(&mut context.pool(), &report_form).await?; let community_report_view = ReportCombinedViewInternal::read_community_report(&mut context.pool(), report.id, person) .await?; plugin_hook_after("community_report_after_create", &community_report_view); // Email the admins let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; if local_site.reports_email_admins { send_new_report_email_to_admins( &community_report_view.creator.name, // The argument here is normally the reported content's creator, but a community doesn't have // a single person to be considered the creator or the person responsible for the bad thing, // so the community name is used instead &community_report_view.community.name, &mut context.pool(), context.settings(), ) .await?; } ActivityChannel::submit_activity( SendActivityData::CreateReport { object_id: community.ap_id.inner().clone(), actor: local_user_view.person, receiver: Either::Left(site), reason: data.reason.clone(), }, &context, )?; Ok(Json(CommunityReportResponse { community_report_view, })) } ================================================ FILE: crates/api/api/src/reports/community_report/mod.rs ================================================ pub mod create; pub mod resolve; ================================================ FILE: crates/api/api/src/reports/community_report/resolve.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use either::Either; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::is_admin, }; use lemmy_db_schema::{ source::{community_report::CommunityReport, site::Site}, traits::Reportable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{CommunityReportResponse, ResolveCommunityReport}, }; use lemmy_utils::error::LemmyResult; pub async fn resolve_community_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { is_admin(&local_user_view)?; let report_id = data.report_id; let person = &local_user_view.person; CommunityReport::update_resolved(&mut context.pool(), report_id, person.id, data.resolved) .await?; let community_report_view = ReportCombinedViewInternal::read_community_report(&mut context.pool(), report_id, person) .await?; let site = Site::read_from_instance_id( &mut context.pool(), community_report_view.community.instance_id, ) .await?; ActivityChannel::submit_activity( SendActivityData::SendResolveReport { object_id: community_report_view.community.ap_id.inner().clone(), actor: local_user_view.person, report_creator: community_report_view.creator.clone(), receiver: Either::Left(site), }, &context, )?; Ok(Json(CommunityReportResponse { community_report_view, })) } ================================================ FILE: crates/api/api/src/reports/mod.rs ================================================ pub mod comment_report; pub mod community_report; pub mod post_report; pub mod private_message_report; pub mod report_combined; ================================================ FILE: crates/api/api/src/reports/post_report/create.rs ================================================ use crate::check_report_reason; use activitypub_federation::config::Data; use actix_web::web::Json; use either::Either; use lemmy_api_utils::{ context::LemmyContext, plugins::plugin_hook_after, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_community_user_action, check_local_user_valid, check_post_deleted_or_removed, slur_regex, }, }; use lemmy_db_schema::{ source::post_report::{PostReport, PostReportForm}, traits::Reportable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::PostView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{CreatePostReport, PostReportResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_email::admin::send_new_report_email_to_admins; use lemmy_utils::error::LemmyResult; /// Creates a post report and notifies the moderators of the community pub async fn create_post_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let reason = data.reason.trim().to_string(); let slur_regex = slur_regex(&context).await?; check_report_reason(&reason, &slur_regex)?; let person = &local_user_view.person; let post_id = data.post_id; let local_instance_id = local_user_view.person.instance_id; let orig_post = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?; check_post_deleted_or_removed(&orig_post.post)?; let report_form = PostReportForm { creator_id: person.id, post_id, original_post_name: orig_post.post.name, original_post_url: orig_post.post.url, original_post_body: orig_post.post.body, reason, violates_instance_rules: data.violates_instance_rules.unwrap_or_default(), }; let report = PostReport::report(&mut context.pool(), &report_form).await?; let post_report_view = ReportCombinedViewInternal::read_post_report(&mut context.pool(), report.id, person).await?; plugin_hook_after("post_report_after_create", &post_report_view); // Email the admins let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; if local_site.reports_email_admins { send_new_report_email_to_admins( &post_report_view.creator.name, &post_report_view.post_creator.name, &mut context.pool(), context.settings(), ) .await?; } if !report.violates_instance_rules { ActivityChannel::submit_activity( SendActivityData::CreateReport { object_id: orig_post.post.ap_id.inner().clone(), actor: local_user_view.person, receiver: Either::Right(orig_post.community), reason: data.reason.clone(), }, &context, )?; } Ok(Json(PostReportResponse { post_report_view })) } ================================================ FILE: crates/api/api/src/reports/post_report/mod.rs ================================================ pub mod create; pub mod resolve; ================================================ FILE: crates/api/api/src/reports/post_report/resolve.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use either::Either; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::{source::post_report::PostReport, traits::Reportable}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{PostReportResponse, ResolvePostReport}, }; use lemmy_utils::error::LemmyResult; /// Resolves or unresolves a post report and notifies the moderators of the community pub async fn resolve_post_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let report_id = data.report_id; let person = &local_user_view.person; let report = ReportCombinedViewInternal::read_post_report(&mut context.pool(), report_id, person).await?; let person = &local_user_view.person; check_community_mod_action( &local_user_view, &report.community, true, &mut context.pool(), ) .await?; PostReport::update_resolved(&mut context.pool(), report_id, person.id, data.resolved).await?; let post_report_view = ReportCombinedViewInternal::read_post_report(&mut context.pool(), report_id, person).await?; ActivityChannel::submit_activity( SendActivityData::SendResolveReport { object_id: post_report_view.post.ap_id.inner().clone(), actor: local_user_view.person, report_creator: report.creator, receiver: Either::Right(post_report_view.community.clone()), }, &context, )?; Ok(Json(PostReportResponse { post_report_view })) } ================================================ FILE: crates/api/api/src/reports/private_message_report/create.rs ================================================ use crate::check_report_reason; use actix_web::web::{Data, Json}; use lemmy_api_utils::{ context::LemmyContext, plugins::plugin_hook_after, utils::{check_local_user_valid, slur_regex}, }; use lemmy_db_schema::{ source::{ private_message::PrivateMessage, private_message_report::{PrivateMessageReport, PrivateMessageReportForm}, }, traits::Reportable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{CreatePrivateMessageReport, PrivateMessageReportResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_email::admin::send_new_report_email_to_admins; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn create_pm_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let reason = data.reason.trim().to_string(); let slur_regex = slur_regex(&context).await?; check_report_reason(&reason, &slur_regex)?; let person = &local_user_view.person; let private_message_id = data.private_message_id; let private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?; // Make sure that only the recipient of the private message can create a report if person.id != private_message.recipient_id { return Err(LemmyErrorType::CouldntCreate.into()); } let report_form = PrivateMessageReportForm { creator_id: person.id, private_message_id, original_pm_text: private_message.content, reason, }; let report = PrivateMessageReport::report(&mut context.pool(), &report_form).await?; let private_message_report_view = ReportCombinedViewInternal::read_private_message_report(&mut context.pool(), report.id, person) .await?; plugin_hook_after( "private_message_report_after_create", &private_message_report_view, ); // Email the admins let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; if local_site.reports_email_admins { send_new_report_email_to_admins( &private_message_report_view.creator.name, &private_message_report_view.private_message_creator.name, &mut context.pool(), context.settings(), ) .await?; } // TODO: consider federating this Ok(Json(PrivateMessageReportResponse { private_message_report_view, })) } ================================================ FILE: crates/api/api/src/reports/private_message_report/mod.rs ================================================ pub mod create; pub mod resolve; ================================================ FILE: crates/api/api/src/reports/private_message_report/resolve.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::{source::private_message_report::PrivateMessageReport, traits::Reportable}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedViewInternal, api::{PrivateMessageReportResponse, ResolvePrivateMessageReport}, }; use lemmy_utils::error::LemmyResult; pub async fn resolve_pm_report( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { is_admin(&local_user_view)?; let report_id = data.report_id; let person = &local_user_view.person; PrivateMessageReport::update_resolved(&mut context.pool(), report_id, person.id, data.resolved) .await?; let private_message_report_view = ReportCombinedViewInternal::read_private_message_report(&mut context.pool(), report_id, person) .await?; Ok(Json(PrivateMessageReportResponse { private_message_report_view, })) } ================================================ FILE: crates/api/api/src/reports/report_combined/list.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_community_mod_of_any_or_admin_action}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_report_combined::{ ReportCombinedView, api::ListReports, impls::ReportCombinedQuery, }; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; /// Lists reports for a community if an id is supplied /// or returns all reports for communities a user moderates pub async fn list_reports( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let my_reports_only = data.my_reports_only; // Only check mod or admin status when not viewing my reports if !my_reports_only.unwrap_or_default() { check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?; } let reports = ReportCombinedQuery { community_id: data.community_id, post_id: data.post_id, type_: data.type_, unresolved_only: data.unresolved_only, show_community_rule_violations: data.show_community_rule_violations, my_reports_only, page_cursor: data.page_cursor, limit: data.limit, } .list(&mut context.pool(), &local_user_view) .await?; Ok(Json(reports)) } ================================================ FILE: crates/api/api/src/reports/report_combined/mod.rs ================================================ pub mod list; ================================================ FILE: crates/api/api/src/site/admin_allow_instance.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::{ federation_allowlist::{FederationAllowList, FederationAllowListForm}, instance::Instance, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{FederatedInstanceView, api::AdminAllowInstanceParams}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn admin_allow_instance( Json(data): Json, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { is_admin(&local_user_view)?; let blocklist = Instance::blocklist(&mut context.pool()).await?; if !blocklist.is_empty() { return Err(LemmyErrorType::CannotCombineFederationBlocklistAndAllowlist.into()); } let instance_id = Instance::read_or_create(&mut context.pool(), &data.instance) .await? .id; let form = FederationAllowListForm::new(instance_id); if data.allow { FederationAllowList::allow(&mut context.pool(), &form).await?; } else { FederationAllowList::unallow(&mut context.pool(), instance_id).await?; } let form = ModlogInsertForm::admin_allow_instance( local_user_view.person.id, instance_id, data.allow, &data.reason, ); Modlog::create(&mut context.pool(), &[form]).await?; Ok(Json( FederatedInstanceView::read(&mut context.pool(), instance_id).await?, )) } ================================================ FILE: crates/api/api/src/site/admin_block_instance.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, utils::{check_expire_time, is_admin}, }; use lemmy_db_schema::source::{ federation_blocklist::{FederationBlockList, FederationBlockListForm}, instance::Instance, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{FederatedInstanceView, api::AdminBlockInstanceParams}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn admin_block_instance( Json(data): Json, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { is_admin(&local_user_view)?; let expires_at = check_expire_time(data.expires_at)?; let allowlist = Instance::allowlist(&mut context.pool()).await?; if !allowlist.is_empty() { return Err(LemmyErrorType::CannotCombineFederationBlocklistAndAllowlist.into()); } let instance_id = Instance::read_or_create(&mut context.pool(), &data.instance) .await? .id; let form = FederationBlockListForm::new(instance_id, expires_at); if data.block { FederationBlockList::block(&mut context.pool(), &form).await?; } else { FederationBlockList::unblock(&mut context.pool(), instance_id).await?; } let form = ModlogInsertForm::admin_block_instance( local_user_view.person.id, instance_id, data.block, &data.reason, ); Modlog::create(&mut context.pool(), &[form]).await?; Ok(Json( FederatedInstanceView::read(&mut context.pool(), instance_id).await?, )) } ================================================ FILE: crates/api/api/src/site/admin_list_users.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_views_local_user::{LocalUserView, api::AdminListUsers, impls::LocalUserQuery}; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn admin_list_users( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { // Make sure user is an admin is_admin(&local_user_view)?; let users = LocalUserQuery { banned_only: data.banned_only, page_cursor: data.page_cursor, limit: data.limit, sort: data.sort, } .list(&mut context.pool()) .await?; Ok(Json(users)) } ================================================ FILE: crates/api/api/src/site/federated_instances.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_site::{FederatedInstanceView, api::GetFederatedInstances}; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn get_federated_instances( Query(data): Query, context: Data, ) -> LemmyResult>> { let federated_instances = FederatedInstanceView::list(&mut context.pool(), data).await?; // Return the jwt Ok(Json(federated_instances)) } ================================================ FILE: crates/api/api/src/site/list_all_media.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_views_local_image::{LocalImageView, api::ListMedia}; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_all_media( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { // Only let admins view all media is_admin(&local_user_view)?; let images = LocalImageView::get_all_paged(&mut context.pool(), data.page_cursor, data.limit).await?; Ok(Json(images)) } ================================================ FILE: crates/api/api/src/site/mod.rs ================================================ pub mod admin_allow_instance; pub mod admin_block_instance; pub mod admin_list_users; pub mod federated_instances; pub mod list_all_media; pub mod mod_log; pub mod purge; pub mod registration_applications; ================================================ FILE: crates/api/api/src/site/mod_log.rs ================================================ use crate::hide_modlog_names; use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_modlog::{ModlogView, api::GetModlog, impls::ModlogQuery}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn get_mod_log( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; check_private_instance(&local_user_view, &local_site)?; let hide_modlog_names = hide_modlog_names(local_user_view.as_ref(), data.community_id, &context).await; // Only allow mod person id filters if its not hidden let mod_person_id = if hide_modlog_names { None } else { data.mod_person_id }; let modlog = ModlogQuery { type_: data.type_, listing_type: data.listing_type, community_id: data.community_id, mod_person_id, target_person_id: data.other_person_id, local_user: local_user_view.as_ref().map(|u| &u.local_user), post_id: data.post_id, comment_id: data.comment_id, hide_modlog_names: Some(hide_modlog_names), show_bulk: data.show_bulk, bulk_action_parent_id: data.bulk_action_parent_id, page_cursor: data.page_cursor, limit: data.limit, } .list(&mut context.pool()) .await?; Ok(Json(modlog)) } #[cfg(test)] mod tests { use super::*; use lemmy_api_utils::utils::remove_or_restore_user_data; use lemmy_db_schema::{ ModlogKindFilter, source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm}, community::{Community, CommunityInsertForm}, instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, modlog::{Modlog, ModlogInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm}, }, traits::Likeable, }; use lemmy_db_schema_file::enums::ModlogKind; use lemmy_db_views_comment::CommentView; use lemmy_db_views_modlog::ModlogView; use lemmy_db_views_post::PostView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyErrorType; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_mod_remove_or_restore_data() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let instance = Instance::read_or_create(pool, "my_domain.tld").await?; // John is the mod let john = PersonInsertForm::test_form(instance.id, "john the modder"); let john = Person::create(pool, &john).await?; let sara_form = PersonInsertForm::test_form(instance.id, "sara"); let sara = Person::create(pool, &sara_form).await?; let sara_local_user_form = LocalUserInsertForm::test_form(sara.id); let sara_local_user = LocalUser::create(pool, &sara_local_user_form, Vec::new()).await?; let community_form = CommunityInsertForm::new( instance.id, "mod_community crepes".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let post_form_1 = PostInsertForm::new("A test post tubular".into(), sara.id, community.id); let post_1 = Post::create(pool, &post_form_1).await?; let post_like_form_1 = PostLikeForm::new(post_1.id, sara.id, Some(true)); PostActions::like(pool, &post_like_form_1).await?; let post_form_2 = PostInsertForm::new("A test post radical".into(), sara.id, community.id); let post_2 = Post::create(pool, &post_form_2).await?; let comment_form_1 = CommentInsertForm::new(sara.id, post_1.id, "A test comment tubular".into()); let comment_1 = Comment::create(pool, &comment_form_1, None).await?; let comment_like_form_1 = CommentLikeForm::new(comment_1.id, sara.id, Some(true)); CommentActions::like(pool, &comment_like_form_1).await?; let comment_form_2 = CommentInsertForm::new(sara.id, post_2.id, "A test comment radical".into()); Comment::create(pool, &comment_form_2, None).await?; // Read saras post to make sure it has a like let post_view_1 = PostView::read(pool, post_1.id, Some(&sara_local_user), instance.id, false).await?; assert_eq!(1, post_view_1.post.score); assert_eq!( Some(true), post_view_1.post_actions.and_then(|pa| pa.vote_is_upvote) ); // Read saras comment to make sure it has a like let comment_view_1 = CommentView::read(pool, comment_1.id, Some(&sara_local_user), instance.id).await?; assert_eq!(1, comment_view_1.post.score); assert_eq!( Some(true), comment_view_1 .comment_actions .and_then(|ca| ca.vote_is_upvote) ); // Remove the user data let ban_form = ModlogInsertForm::admin_ban(&john, sara.id, true, None, "a remove reason"); let ban_action = Modlog::create(pool, &[ban_form]).await?; let ban_id = ban_action.first().ok_or(LemmyErrorType::NotFound)?.id; remove_or_restore_user_data(john.id, sara.id, true, "a remove reason", ban_id, &context) .await?; // Verify that their posts and comments are removed. // Posts let post_modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(2, post_modlog.len()); assert!(matches!( &post_modlog[..], [ ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemovePost, .. }, target_post: Some(Post { removed: true, .. }), .. }, ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemovePost, .. }, target_post: Some(Post { removed: true, .. }), .. }, ], )); // Comments let comment_modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(2, comment_modlog.len()); assert!(matches!( &comment_modlog[..], [ ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemoveComment, .. }, target_comment: Some(Comment { removed: true, .. }), .. }, ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemoveComment, .. }, target_comment: Some(Comment { removed: true, .. }), .. }, ], )); // Verify that the likes got removed // post let post_view_1 = PostView::read(pool, post_1.id, Some(&sara_local_user), instance.id, false).await?; assert_eq!(0, post_view_1.post.score); assert_eq!( None, post_view_1.post_actions.and_then(|pa| pa.vote_is_upvote) ); // comment let comment_view_1 = CommentView::read(pool, comment_1.id, Some(&sara_local_user), instance.id).await?; assert_eq!(0, comment_view_1.post.score); assert_eq!( None, comment_view_1 .comment_actions .and_then(|ca| ca.vote_is_upvote) ); // Now restore the content, and make sure it got appended let unban_form = ModlogInsertForm::admin_ban(&john, sara.id, false, None, "a restore reason"); let unban_action = Modlog::create(pool, &[unban_form]).await?; let unban_id = unban_action.first().ok_or(LemmyErrorType::NotFound)?.id; remove_or_restore_user_data( john.id, sara.id, false, "a restore reason", unban_id, &context, ) .await?; // Posts let post_modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(4, post_modlog.len()); assert!(matches!( &post_modlog[..], [ ModlogView { modlog: Modlog { is_revert: true, kind: ModlogKind::ModRemovePost, .. }, target_post: Some(Post { removed: false, .. }), .. }, ModlogView { modlog: Modlog { is_revert: true, kind: ModlogKind::ModRemovePost, .. }, target_post: Some(Post { removed: false, .. }), .. }, ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemovePost, .. }, target_post: Some(Post { removed: false, .. }), .. }, ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemovePost, .. }, target_post: Some(Post { removed: false, .. }), .. }, ], )); // Comments let comment_modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(4, comment_modlog.len()); assert!(matches!( &comment_modlog[..], [ ModlogView { modlog: Modlog { is_revert: true, kind: ModlogKind::ModRemoveComment, .. }, target_comment: Some(Comment { removed: false, .. }), .. }, ModlogView { modlog: Modlog { is_revert: true, kind: ModlogKind::ModRemoveComment, .. }, target_comment: Some(Comment { removed: false, .. }), .. }, ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemoveComment, .. }, target_comment: Some(Comment { removed: false, .. }), .. }, ModlogView { modlog: Modlog { is_revert: false, kind: ModlogKind::ModRemoveComment, .. }, target_comment: Some(Comment { removed: false, .. }), .. }, ], )); Instance::delete(pool, instance.id).await?; Ok(()) } /// Verifies that remove_or_restore_user_data sets bulk_action_parent_id on all child entries /// when a real parent ModlogId is provided #[tokio::test] #[serial] async fn test_bulk_parent_id_propagated() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let person_a_form = PersonInsertForm::test_form(instance.id, "person_a_bulk_test"); let person_a = Person::create(pool, &person_a_form).await?; let person_b_form = PersonInsertForm::test_form(instance.id, "person_b_bulk_test"); let person_b = Person::create(pool, &person_b_form).await?; let community_form = CommunityInsertForm::new( instance.id, "bulk_parent_community".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let post_form_1 = PostInsertForm::new("Bulk test post 1".into(), person_b.id, community.id); Post::create(pool, &post_form_1).await?; let post_form_2 = PostInsertForm::new("Bulk test post 2".into(), person_b.id, community.id); let post_2 = Post::create(pool, &post_form_2).await?; let comment_form = CommentInsertForm::new(person_b.id, post_2.id, "Bulk test comment".into()); Comment::create(pool, &comment_form, None).await?; // Create the ban entry first and capture its ID as the expected parent let ban_form = ModlogInsertForm::admin_ban(&person_a, person_b.id, true, None, "banning for bulk test"); let ban_action = Modlog::create(pool, &[ban_form]).await?; let ban_id = ban_action.first().ok_or(LemmyErrorType::NotFound)?.id; // Remove person_b's content as a bulk action triggered by the ban remove_or_restore_user_data( person_a.id, person_b.id, true, "bulk remove reason", ban_id, &context, ) .await?; let post_modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(2, post_modlog.len()); assert!( post_modlog .iter() .all(|e| e.modlog.bulk_action_parent_id == Some(ban_id)), "all post removal entries should reference the ban as their parent" ); let comment_modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(1, comment_modlog.len()); let first_comment = comment_modlog.first().ok_or(LemmyErrorType::NotFound)?; assert_eq!( Some(ban_id), first_comment.modlog.bulk_action_parent_id, "comment removal entry should reference the ban as its parent" ); Instance::delete(pool, instance.id).await?; Ok(()) } } ================================================ FILE: crates/api/api/src/site/purge/comment.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::is_admin, }; use lemmy_db_schema::source::{ comment::Comment, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_comment::{CommentView, api::PurgeComment}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn purge_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Only let admin purge an item is_admin(&local_user_view)?; let comment_id = data.comment_id; let local_instance_id = local_user_view.person.instance_id; // Read the comment to get the post_id and community let comment_view = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; // Also check that you're a higher admin LocalUser::is_higher_admin_check( &mut context.pool(), local_user_view.person.id, vec![comment_view.creator.id], ) .await?; // TODO read comments for pictrs images and purge them Comment::delete(&mut context.pool(), comment_id).await?; // Mod tables let form = ModlogInsertForm::admin_purge_comment( local_user_view.person.id, &comment_view.comment, comment_view.community.id, &data.reason, ); Modlog::create(&mut context.pool(), &[form]).await?; ActivityChannel::submit_activity( SendActivityData::RemoveComment { comment: comment_view.comment, moderator: local_user_view.person.clone(), community: comment_view.community, reason: data.reason.clone(), with_replies: false, }, &context, )?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/site/purge/community.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::is_admin, }; use lemmy_db_schema::source::{ community::Community, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_schema_file::PersonId; use lemmy_db_views_community::api::PurgeCommunity; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn purge_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Only let admin purge an item is_admin(&local_user_view)?; // Read the community to get its images let community = Community::read(&mut context.pool(), data.community_id).await?; // Also check that you're a higher admin than all the mods let community_mod_person_ids = CommunityModeratorView::for_community(&mut context.pool(), community.id) .await? .iter() .map(|cmv| cmv.moderator.id) .collect::>(); LocalUser::is_higher_admin_check( &mut context.pool(), local_user_view.person.id, community_mod_person_ids, ) .await?; Community::delete(&mut context.pool(), data.community_id).await?; // Mod tables let form = ModlogInsertForm::admin_purge_community(local_user_view.person.id, &data.reason); Modlog::create(&mut context.pool(), &[form]).await?; ActivityChannel::submit_activity( SendActivityData::RemoveCommunity { moderator: local_user_view.person.clone(), community, reason: data.reason.clone(), removed: true, }, &context, )?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/site/purge/mod.rs ================================================ pub mod comment; pub mod community; pub mod person; pub mod post; ================================================ FILE: crates/api/api/src/site/purge/person.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{is_admin, purge_user_account}, }; use lemmy_db_schema::{ source::{ instance::{InstanceActions, InstanceBanForm}, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, person::Person, }, traits::Bannable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::api::PurgePerson; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn purge_person( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_instance_id = local_user_view.person.instance_id; // Only let admin purge an item is_admin(&local_user_view)?; // Also check that you're a higher admin LocalUser::is_higher_admin_check( &mut context.pool(), local_user_view.person.id, vec![data.person_id], ) .await?; let person = Person::read(&mut context.pool(), data.person_id).await?; ActivityChannel::submit_activity( SendActivityData::BanFromSite { moderator: local_user_view.person.clone(), banned_user: person, reason: data.reason.clone(), remove_or_restore_data: Some(true), ban: true, expires_at: None, }, &context, )?; // Clear profile data. purge_user_account(data.person_id, local_instance_id, &context).await?; // Keep person record, but mark as banned to prevent login or refetching from home instance. InstanceActions::ban( &mut context.pool(), &InstanceBanForm::new(data.person_id, local_instance_id, None), ) .await?; // Mod tables let form = ModlogInsertForm::admin_purge_person(local_user_view.person.id, &data.reason); Modlog::create(&mut context.pool(), &[form]).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/site/purge/post.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{is_admin, purge_post_images}, }; use lemmy_db_schema::source::{ local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, post::Post, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::PurgePost; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn purge_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Only let admin purge an item is_admin(&local_user_view)?; // Read the post to get the community_id let post = Post::read(&mut context.pool(), data.post_id).await?; // Also check that you're a higher admin LocalUser::is_higher_admin_check( &mut context.pool(), local_user_view.person.id, vec![post.creator_id], ) .await?; purge_post_images(post.url.clone(), post.thumbnail_url.clone(), &context).await; Post::delete(&mut context.pool(), data.post_id).await?; // Mod tables let form = ModlogInsertForm::admin_purge_post(local_user_view.person.id, post.community_id, &data.reason); Modlog::create(&mut context.pool(), &[form]).await?; ActivityChannel::submit_activity( SendActivityData::RemovePost { post, moderator: local_user_view.person.clone(), reason: data.reason.clone(), removed: true, with_replies: false, }, &context, )?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api/src/site/registration_applications/approve.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use diesel_async::scoped_futures::ScopedFutureExt; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::{ local_user::{LocalUser, LocalUserUpdateForm}, registration_application::{RegistrationApplication, RegistrationApplicationUpdateForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_registration_applications::{ RegistrationApplicationView, api::{ApproveRegistrationApplication, RegistrationApplicationResponse}, }; use lemmy_diesel_utils::{connection::get_conn, traits::Crud, utils::diesel_string_update}; use lemmy_email::account::{send_application_approved_email, send_application_denied_email}; use lemmy_utils::error::LemmyResult; pub async fn approve_registration_application( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let app_id = data.id; // Only let admins do this is_admin(&local_user_view)?; let pool = &mut context.pool(); let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); let approved_user_id = conn .run_transaction(|conn| { async move { // Update the registration with reason, admin_id let deny_reason = diesel_string_update(tx_data.deny_reason.as_deref()); let app_form = RegistrationApplicationUpdateForm { admin_id: Some(Some(local_user_view.person.id)), deny_reason, updated_at: Some(Some(Utc::now())), }; let registration_application = RegistrationApplication::update(&mut conn.into(), app_id, &app_form).await?; // Update the local_user row let local_user_form = LocalUserUpdateForm { accepted_application: Some(tx_data.approve), ..Default::default() }; let approved_user_id = registration_application.local_user_id; LocalUser::update(&mut conn.into(), approved_user_id, &local_user_form).await?; Ok(approved_user_id) } .scope_boxed() }) .await?; let approved_local_user_view = LocalUserView::read(&mut context.pool(), approved_user_id).await?; if approved_local_user_view.local_user.email.is_some() { // Email sending may fail, but this won't revert the application approval if data.approve { send_application_approved_email(&approved_local_user_view, context.settings())?; } else { send_application_denied_email( &approved_local_user_view, data.deny_reason.clone(), context.settings(), )?; } } // Read the view let registration_application = RegistrationApplicationView::read(&mut context.pool(), app_id).await?; Ok(Json(RegistrationApplicationResponse { registration_application, })) } ================================================ FILE: crates/api/api/src/site/registration_applications/get.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_registration_applications::{ RegistrationApplicationView, api::{GetRegistrationApplication, RegistrationApplicationResponse}, }; use lemmy_utils::error::LemmyResult; /// Lists registration applications, filterable by undenied only. pub async fn get_registration_application( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Make sure user is an admin is_admin(&local_user_view)?; // Read the view let registration_application = RegistrationApplicationView::read_by_person(&mut context.pool(), data.person_id).await?; Ok(Json(RegistrationApplicationResponse { registration_application, })) } ================================================ FILE: crates/api/api/src/site/registration_applications/list.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_registration_applications::{ RegistrationApplicationView, api::ListRegistrationApplications, impls::RegistrationApplicationQuery, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; /// Lists registration applications, filterable by undenied only. pub async fn list_registration_applications( Query(data): Query, context: Data, local_user_view: LocalUserView, ) -> LemmyResult>> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; // Make sure user is an admin is_admin(&local_user_view)?; let registration_applications = RegistrationApplicationQuery { unread_only: data.unread_only, verified_email_only: Some(local_site.require_email_verification), page_cursor: data.page_cursor, limit: data.limit, } .list(&mut context.pool()) .await?; Ok(Json(registration_applications)) } ================================================ FILE: crates/api/api/src/site/registration_applications/mod.rs ================================================ pub mod approve; pub mod get; pub mod list; #[cfg(test)] mod tests; ================================================ FILE: crates/api/api/src/site/registration_applications/tests.rs ================================================ use crate::{ local_user::unread_counts::get_unread_counts, site::registration_applications::{ approve::approve_registration_application, list::list_registration_applications, }, }; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_crud::site::update::edit_site; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ source::{ local_site::{LocalSite, LocalSiteUpdateForm}, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, person::{Person, PersonInsertForm}, registration_application::{RegistrationApplication, RegistrationApplicationInsertForm}, }, test_data::TestData, }; use lemmy_db_schema_file::{InstanceId, enums::RegistrationMode}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_registration_applications::{ RegistrationApplicationView, api::ApproveRegistrationApplication, }; use lemmy_db_views_site::api::EditSite; use lemmy_diesel_utils::{connection::DbPool, traits::Crud}; use lemmy_utils::{CACHE_DURATION_API, error::LemmyResult}; use serial_test::serial; async fn create_test_site(context: &Data) -> LemmyResult<(TestData, LocalUserView)> { let pool = &mut context.pool(); let data = TestData::create(pool).await?; // Enable some local site settings let local_site_form = LocalSiteUpdateForm { require_email_verification: Some(true), application_question: Some(Some(".".to_string())), registration_mode: Some(RegistrationMode::RequireApplication), site_setup: Some(true), ..Default::default() }; LocalSite::update(pool, &local_site_form).await?; let admin_person = Person::create( pool, &PersonInsertForm::test_form(data.instance.id, "admin"), ) .await?; LocalUser::create( pool, &LocalUserInsertForm::test_form_admin(admin_person.id), vec![], ) .await?; let admin_local_user_view = LocalUserView::read_person(pool, admin_person.id).await?; Ok((data, admin_local_user_view)) } async fn signup( pool: &mut DbPool<'_>, instance_id: InstanceId, name: &str, email: Option<&str>, ) -> LemmyResult<(LocalUser, RegistrationApplication)> { let person_insert_form = PersonInsertForm::test_form(instance_id, name); let person = Person::create(pool, &person_insert_form).await?; let local_user_insert_form = match email { Some(email) => LocalUserInsertForm { email: Some(email.to_string()), email_verified: Some(false), ..LocalUserInsertForm::test_form(person.id) }, None => LocalUserInsertForm::test_form(person.id), }; let local_user = LocalUser::create(pool, &local_user_insert_form, vec![]).await?; let application_insert_form = RegistrationApplicationInsertForm { local_user_id: local_user.id, answer: "x".to_string(), }; let application = RegistrationApplication::create(pool, &application_insert_form).await?; Ok((local_user, application)) } async fn get_application_statuses( context: &Data, admin: LocalUserView, ) -> LemmyResult<( i64, Vec, Vec, )> { let Json(unread_counts) = get_unread_counts(context.clone(), admin.clone()).await?; let Json(unread_applications) = list_registration_applications( Query::from_query("unread_only=true")?, context.clone(), admin.clone(), ) .await?; let Json(all_applications) = list_registration_applications( Query::from_query("unread_only=false")?, context.clone(), admin, ) .await?; Ok(( unread_counts .registration_application_count .unwrap_or_default(), unread_applications.items, all_applications.items, )) } #[serial] #[tokio::test] #[expect(clippy::indexing_slicing)] async fn test_application_approval() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let (data, admin_local_user_view) = create_test_site(&context).await?; // Non-unread counts unfortunately are duplicated due to different types (i64 vs usize) let mut expected_total_applications = 0; let mut expected_unread_applications = 0u8; let (local_user_with_email, app_with_email) = signup( pool, data.instance.id, "user_w_email", Some("lemmy@localhost"), ) .await?; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // When email verification is required and the email is not verified the application should not // be visible to admins assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); LocalUser::update( pool, local_user_with_email.id, &LocalUserUpdateForm { email_verified: Some(true), ..Default::default() }, ) .await?; expected_total_applications += 1; expected_unread_applications += 1; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // When email verification is required and the email is verified the application should be // visible to admins assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert!( !unread_applications[0] .creator_local_user .accepted_application ); assert_eq!(all_applications.len(), expected_total_applications,); approve_registration_application( Json(ApproveRegistrationApplication { id: app_with_email.id, approve: true, deny_reason: None, }), context.clone(), admin_local_user_view.clone(), ) .await?; expected_unread_applications -= 1; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // When the application is approved it should only be returned for unread queries assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); assert!(all_applications[0].creator_local_user.accepted_application); let (_local_user, app_with_email_2) = signup( pool, data.instance.id, "user_w_email_2", Some("lemmy2@localhost"), ) .await?; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // Email not verified, so application still not visible assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); Box::pin(edit_site( Json(EditSite { require_email_verification: Some(false), ..Default::default() }), context.clone(), admin_local_user_view.clone(), )) .await?; // TODO: There is probably a better way to ensure cache invalidation tokio::time::sleep(CACHE_DURATION_API).await; expected_total_applications += 1; expected_unread_applications += 1; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // After disabling email verification the application should now be visible assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); approve_registration_application( Json(ApproveRegistrationApplication { id: app_with_email_2.id, approve: false, deny_reason: None, }), context.clone(), admin_local_user_view.clone(), ) .await?; expected_unread_applications -= 1; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // Denied applications should not be marked as unread assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); signup(pool, data.instance.id, "user_wo_email", None).await?; expected_total_applications += 1; expected_unread_applications += 1; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // New user without email should immediately be visible assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); signup(pool, data.instance.id, "user_w_email_3", None).await?; expected_total_applications += 1; expected_unread_applications += 1; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // New user with email should immediately be visible assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); Box::pin(edit_site( Json(EditSite { registration_mode: Some(RegistrationMode::Open), ..Default::default() }), context.clone(), admin_local_user_view.clone(), )) .await?; // TODO: There is probably a better way to ensure cache invalidation tokio::time::sleep(CACHE_DURATION_API).await; let (application_count, unread_applications, all_applications) = get_application_statuses(&context, admin_local_user_view.clone()).await?; // TODO: At this time applications do not get approved when switching to open registration, so the // numbers will not change. See https://github.com/LemmyNet/lemmy/issues/4969 // expected_application_count = 0; // expected_unread_applications_len = 0; // When applications are not required all previous applications should become approved but still // visible assert_eq!(application_count, i64::from(expected_unread_applications),); assert_eq!( unread_applications.len(), usize::from(expected_unread_applications), ); assert_eq!(all_applications.len(), expected_total_applications,); LocalSite::delete(pool).await?; // Instance deletion cascades cleanup of all created persons data.delete(pool).await?; Ok(()) } ================================================ FILE: crates/api/api/src/sitemap.rs ================================================ use actix_web::{ HttpResponse, http::header::{self, CacheDirective}, web::Data, }; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_schema::source::post::Post; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::dburl::DbUrl; use lemmy_utils::error::LemmyResult; use sitemap_rs::{url::Url, url_set::UrlSet}; use tracing::info; fn generate_urlset(posts: Vec<(DbUrl, chrono::DateTime)>) -> LemmyResult { let urls = posts .into_iter() .map_while(|(url, date_time)| { Url::builder(url.to_string()) .last_modified(date_time.into()) .build() .ok() }) .collect(); Ok(UrlSet::new(urls)?) } pub async fn get_sitemap(context: Data) -> LemmyResult { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; check_private_instance(&None, &local_site)?; info!("Generating sitemap...",); let posts = Post::list_for_sitemap(&mut context.pool()).await?; info!("Loaded latest {} posts", posts.len()); let mut buf = Vec::::new(); generate_urlset(posts)?.write(&mut buf)?; Ok( HttpResponse::Ok() .content_type("application/xml") .insert_header(header::CacheControl(vec![CacheDirective::MaxAge(3_600)])) // 1 h .body(buf), ) } #[cfg(test)] pub(crate) mod tests { use crate::sitemap::generate_urlset; use chrono::{DateTime, NaiveDate, Utc}; use elementtree::Element; use lemmy_diesel_utils::dburl::DbUrl; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use url::Url; #[tokio::test] async fn test_generate_urlset() -> LemmyResult<()> { let posts: Vec<(DbUrl, DateTime)> = vec![ ( Url::parse("https://example.com")?.into(), NaiveDate::from_ymd_opt(2022, 12, 1) .unwrap_or_default() .and_hms_opt(9, 10, 11) .unwrap_or_default() .and_utc(), ), ( Url::parse("https://lemmy.ml")?.into(), NaiveDate::from_ymd_opt(2023, 1, 1) .unwrap_or_default() .and_hms_opt(1, 2, 3) .unwrap_or_default() .and_utc(), ), ]; let mut buf = Vec::::new(); generate_urlset(posts)?.write(&mut buf)?; let root = Element::from_reader(buf.as_slice())?; assert_eq!(root.tag().name(), "urlset"); assert_eq!(root.child_count(), 2); assert!(root.children().all(|url| url.tag().name() == "url")); assert!(root.children().all(|url| url.child_count() == 2)); assert!(root.children().all(|url| { url .children() .next() .is_some_and(|element| element.tag().name() == "loc") })); assert!(root.children().all(|url| { url .children() .nth(1) .is_some_and(|element| element.tag().name() == "lastmod") })); assert_eq!( root .children() .next() .and_then(|n| n.children().find(|element| element.tag().name() == "loc")) .map(Element::text) .unwrap_or_default(), "https://example.com/" ); assert_eq!( root .children() .next() .and_then(|n| n .children() .find(|element| element.tag().name() == "lastmod")) .map(Element::text) .unwrap_or_default(), "2022-12-01T09:10:11+00:00" ); assert_eq!( root .children() .nth(1) .and_then(|n| n.children().find(|element| element.tag().name() == "loc")) .map(Element::text) .unwrap_or_default(), "https://lemmy.ml/" ); assert_eq!( root .children() .nth(1) .and_then(|n| n .children() .find(|element| element.tag().name() == "lastmod")) .map(Element::text) .unwrap_or_default(), "2023-01-01T01:02:03+00:00" ); Ok(()) } } ================================================ FILE: crates/api/api_common/Cargo.toml ================================================ [package] name = "lemmy_api_common" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_api_common" path = "src/lib.rs" doctest = false test = false [lints] workspace = true [features] full = [] ts-rs = [ "lemmy_utils/ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs", "lemmy_db_views_comment/ts-rs", "lemmy_db_views_community/ts-rs", "lemmy_db_views_community_follower/ts-rs", "lemmy_db_views_community_follower_approval/ts-rs", "lemmy_db_views_community_moderator/ts-rs", "lemmy_db_views_custom_emoji/ts-rs", "lemmy_db_views_notification/ts-rs", "lemmy_db_views_local_image/ts-rs", "lemmy_db_views_local_user/ts-rs", "lemmy_db_views_modlog/ts-rs", "lemmy_db_views_person/ts-rs", "lemmy_db_views_person_content_combined/ts-rs", "lemmy_db_views_person_liked_combined/ts-rs", "lemmy_db_views_person_saved_combined/ts-rs", "lemmy_db_views_post/ts-rs", "lemmy_db_views_private_message/ts-rs", "lemmy_db_views_registration_applications/ts-rs", "lemmy_db_views_report_combined/ts-rs", "lemmy_db_views_search_combined/ts-rs", "lemmy_db_views_site/ts-rs", "lemmy_db_views_vote/ts-rs", ] [dependencies] lemmy_utils.workspace = true lemmy_db_schema.workspace = true lemmy_db_schema_file.workspace = true lemmy_db_views_comment.workspace = true lemmy_db_views_community.workspace = true lemmy_db_views_community_follower.workspace = true lemmy_db_views_community_follower_approval.workspace = true lemmy_db_views_community_moderator.workspace = true lemmy_db_views_custom_emoji.workspace = true lemmy_db_views_notification.workspace = true lemmy_db_views_local_image.workspace = true lemmy_db_views_local_user.workspace = true lemmy_db_views_modlog.workspace = true lemmy_db_views_person.workspace = true lemmy_db_views_person_content_combined.workspace = true lemmy_db_views_person_liked_combined.workspace = true lemmy_db_views_person_saved_combined.workspace = true lemmy_db_views_post_comment_combined.workspace = true lemmy_db_views_post.workspace = true lemmy_db_views_private_message.workspace = true lemmy_db_views_registration_applications.workspace = true lemmy_db_views_report_combined.workspace = true lemmy_db_views_search_combined.workspace = true lemmy_db_views_site.workspace = true lemmy_db_views_vote.workspace = true lemmy_diesel_utils.workspace = true ================================================ FILE: crates/api/api_common/README.md ================================================ # lemmy_api_common This crate provides all the data types which are necessary to build a client for [Lemmy](https://join-lemmy.org/). You can use them with the HTTP client of your choice. Here is an example using [reqwest](https://crates.io/crates/reqwest): ```rust let params = GetPosts { community_name: Some("asklemmy".to_string()), ..Default::default() }; let client = Client::new(); let response = client .get("https://lemmy.ml/api/v4/post/list") .query(¶ms) .send() .await?; let json = response.json::().await.unwrap(); print!("{:?}", &json); ``` As you can see, each API endpoint needs a parameter type ( GetPosts), path (/post/list) and response type (GetPostsResponse). You can find the paths and handler methods from [this file](https://github.com/LemmyNet/lemmy/blob/main/src/api_routes_http.rs). The parameter type and response type are defined on each handler method. For a real example of a Lemmy API client, look at [lemmyBB](https://github.com/LemmyNet/lemmyBB/tree/main/src/api). Lemmy also provides a websocket API. You can find the full websocket code in [this file](https://github.com/LemmyNet/lemmy/blob/main/src/api_routes_websocket.rs). ## Generate TypeScript bindings TypeScript bindings (API types) can be generated by running `cargo test --features full`. The ts files be generated into a `bindings` folder. This crate uses [`ts_rs`](https://docs.rs/ts-rs/6.2.1/ts_rs/#traits) macros `derive(TS)` and `ts(export)` to attribute types for binding generating. ================================================ FILE: crates/api/api_common/src/account.rs ================================================ pub use lemmy_db_views_person_content_combined::api::{ListPersonHidden, ListPersonRead}; pub use lemmy_db_views_person_liked_combined::ListPersonLiked; pub use lemmy_db_views_person_saved_combined::ListPersonSaved; pub use lemmy_db_views_post_comment_combined::PostCommentCombinedView; pub use lemmy_db_views_site::api::{DeleteAccount, MyUserInfo, SaveUserSettings}; pub mod auth { pub use lemmy_db_schema::source::login_token::LoginToken; pub use lemmy_db_views_registration_applications::api::{CaptchaAnswer, Register}; pub use lemmy_db_views_site::api::{ CaptchaResponse, ChangePassword, EditTotp, EditTotpResponse, ExportDataResponse, GenerateTotpSecretResponse, GetCaptchaResponse, ListLoginsResponse, Login, LoginResponse, PasswordChangeAfterReset, PasswordReset, ResendVerificationEmail, UserSettingsBackup, VerifyEmail, }; } ================================================ FILE: crates/api/api_common/src/comment.rs ================================================ pub use lemmy_db_schema::{ newtypes::CommentId, source::comment::{Comment, CommentActions, CommentInsertForm}, }; pub use lemmy_db_views_comment::{ CommentSlimView, CommentView, api::{CommentResponse, GetComment, GetComments}, }; pub mod actions { pub use lemmy_db_views_comment::api::{ CreateComment, CreateCommentLike, DeleteComment, EditComment, SaveComment, }; pub mod moderation { pub use lemmy_db_views_comment::api::{ DistinguishComment, ListCommentLikes, PurgeComment, RemoveComment, }; } } ================================================ FILE: crates/api/api_common/src/community.rs ================================================ pub use lemmy_db_schema::{ newtypes::{CommunityId, CommunityTagId, MultiCommunityId}, source::{ community::{Community, CommunityActions}, community_tag::{CommunityTag, CommunityTagsView}, multi_community::{MultiCommunity, MultiCommunityFollow}, }, }; pub use lemmy_db_schema_file::enums::CommunityVisibility; pub use lemmy_db_views_community::{ CommunityView, MultiCommunityView, api::{ CommunityResponse, CreateMultiCommunity, CreateOrDeleteMultiCommunityEntry, EditCommunityNotifications, EditMultiCommunity, FollowMultiCommunity, GetCommunity, GetCommunityResponse, GetMultiCommunity, GetMultiCommunityResponse, GetRandomCommunity, ListCommunities, ListMultiCommunities, }, }; pub use lemmy_db_views_community_follower_approval::PendingFollowerView; pub use lemmy_db_views_community_moderator::CommunityModeratorView; pub mod actions { pub use lemmy_db_views_community::api::{ BlockCommunity, CreateCommunity, FollowCommunity, HideCommunity, }; pub mod moderation { pub use lemmy_db_schema_file::enums::CommunityFollowerState; pub use lemmy_db_views_community::api::{ AddModToCommunity, AddModToCommunityResponse, ApproveCommunityPendingFollower, BanFromCommunity, CommunityIdQuery, CreateCommunityTag, DeleteCommunity, DeleteCommunityTag, EditCommunity, EditCommunityTag, PurgeCommunity, RemoveCommunity, TransferCommunity, }; pub use lemmy_db_views_community_follower::CommunityFollowerView; pub use lemmy_db_views_community_follower_approval::{ PendingFollowerView, api::ListCommunityPendingFollows, }; } } ================================================ FILE: crates/api/api_common/src/custom_emoji.rs ================================================ pub use lemmy_db_schema::{ newtypes::CustomEmojiId, source::{custom_emoji::CustomEmoji, custom_emoji_keyword::CustomEmojiKeyword}, }; pub use lemmy_db_views_custom_emoji::{ CustomEmojiView, api::{ CreateCustomEmoji, CustomEmojiResponse, DeleteCustomEmoji, EditCustomEmoji, ListCustomEmojis, ListCustomEmojisResponse, }, }; ================================================ FILE: crates/api/api_common/src/error.rs ================================================ pub use lemmy_utils::error::{LemmyErrorType, UntranslatedError}; ================================================ FILE: crates/api/api_common/src/federation.rs ================================================ pub use lemmy_db_schema::{ newtypes::ActivityId, source::{ federation_allowlist::FederationAllowList, federation_blocklist::FederationBlockList, federation_queue_state::FederationQueueState, instance::{Instance, InstanceActions}, }, }; pub use lemmy_db_schema_file::{InstanceId, enums::FederationMode}; pub use lemmy_db_views_site::api::{ GetFederatedInstances, GetFederatedInstancesKind, ResolveObject, UserBlockInstanceCommunitiesParams, UserBlockInstancePersonsParams, }; pub mod administration { pub use lemmy_db_views_site::api::{AdminAllowInstanceParams, AdminBlockInstanceParams}; } ================================================ FILE: crates/api/api_common/src/language.rs ================================================ pub use lemmy_db_schema::{newtypes::LanguageId, source::language::Language}; ================================================ FILE: crates/api/api_common/src/lib.rs ================================================ pub mod account; pub mod comment; pub mod community; pub mod custom_emoji; pub mod error; pub mod federation; pub mod language; pub mod media; pub mod modlog; pub mod notification; pub mod oauth; pub mod person; pub mod plugin; pub mod post; pub mod private_message; pub mod report; pub mod search; pub mod site; pub mod tagline; pub use lemmy_db_schema_file::enums::VoteShow; pub use lemmy_db_views_site::api::SuccessResponse; pub use lemmy_db_views_vote::VoteView; pub use lemmy_diesel_utils::{ dburl::DbUrl, pagination::{PagedResponse, PaginationCursor}, sensitive::SensitiveString, }; ================================================ FILE: crates/api/api_common/src/media.rs ================================================ pub use lemmy_db_schema::source::images::{ImageDetails, LocalImage, RemoteImage}; pub use lemmy_db_views_local_image::{ LocalImageView, api::{DeleteImageParams, ImageGetParams, ImageProxyParams, ListMedia, UploadImageResponse}, }; ================================================ FILE: crates/api/api_common/src/modlog.rs ================================================ pub use lemmy_db_schema::{newtypes::ModlogId, source::modlog::Modlog}; pub use lemmy_db_views_modlog::api::GetModlog; ================================================ FILE: crates/api/api_common/src/notification.rs ================================================ pub use lemmy_db_schema::{ NotificationTypeFilter, newtypes::NotificationId, source::notification::Notification, }; pub use lemmy_db_views_notification::{ ListNotifications, NotificationView, api::MarkNotificationAsRead, }; ================================================ FILE: crates/api/api_common/src/oauth.rs ================================================ pub use lemmy_db_schema::{ newtypes::OAuthProviderId, source::{ oauth_account::OAuthAccount, oauth_provider::{AdminOAuthProvider, PublicOAuthProvider}, }, }; pub use lemmy_db_views_site::api::{ AuthenticateWithOauth, CreateOAuthProvider, DeleteOAuthProvider, EditOAuthProvider, }; ================================================ FILE: crates/api/api_common/src/person.rs ================================================ pub use lemmy_db_schema::{ PersonContentType, newtypes::LocalUserId, source::{ local_user::LocalUser, person::{Person, PersonActions}, }, }; pub use lemmy_db_schema_file::PersonId; pub use lemmy_db_views_local_user::LocalUserView; pub use lemmy_db_views_person::{ PersonView, api::{GetPersonDetails, GetPersonDetailsResponse, PersonResponse}, }; pub mod actions { pub use lemmy_db_schema::newtypes::PersonContentCombinedId; pub use lemmy_db_views_person::api::{BlockPerson, NotePerson}; pub use lemmy_db_views_person_content_combined::ListPersonContent; pub mod moderation { pub use lemmy_db_schema::{ newtypes::RegistrationApplicationId, source::registration_application::RegistrationApplication, }; pub use lemmy_db_views_person::api::{BanPerson, PurgePerson}; pub use lemmy_db_views_registration_applications::{ RegistrationApplicationView, api::{GetRegistrationApplication, RegistrationApplicationResponse}, }; } } ================================================ FILE: crates/api/api_common/src/plugin.rs ================================================ pub use lemmy_db_views_site::api::PluginMetadata; ================================================ FILE: crates/api/api_common/src/post.rs ================================================ pub use lemmy_db_schema::{ PostFeatureType, newtypes::PostId, source::post::{Post, PostActions, PostInsertForm, PostLikeForm}, }; pub use lemmy_db_schema_file::enums::{PostListingMode, PostNotificationsMode}; pub use lemmy_db_views_post::{ PostView, api::{ GetPosts, GetSiteMetadata, GetSiteMetadataResponse, LinkMetadata, OpenGraphData, PostResponse, }, }; pub use lemmy_db_views_search_combined::api::{GetPost, GetPostResponse}; pub mod actions { pub use lemmy_db_views_post::api::{ CreatePost, CreatePostLike, DeletePost, EditPost, EditPostNotifications, HidePost, MarkManyPostsAsRead, MarkPostAsRead, SavePost, }; pub mod moderation { pub use lemmy_db_views_post::api::{ FeaturePost, ListPostLikes, LockPost, ModEditPost, PurgePost, RemovePost, }; } } ================================================ FILE: crates/api/api_common/src/private_message.rs ================================================ pub use lemmy_db_schema::{newtypes::PrivateMessageId, source::private_message::PrivateMessage}; pub use lemmy_db_views_private_message::{PrivateMessageView, api::PrivateMessageResponse}; pub mod actions { pub use lemmy_db_views_private_message::api::{ CreatePrivateMessage, DeletePrivateMessage, EditPrivateMessage, }; } ================================================ FILE: crates/api/api_common/src/report.rs ================================================ pub use lemmy_db_schema::{ ReportType, newtypes::{CommentReportId, CommunityReportId, PostReportId, PrivateMessageReportId}, source::{ comment_report::CommentReport, community_report::CommunityReport, post_report::PostReport, private_message_report::PrivateMessageReport, }, }; pub use lemmy_db_views_report_combined::{ CommentReportView, CommunityReportView, PostReportView, PrivateMessageReportView, ReportCombinedView, api::{ CommentReportResponse, CommunityReportResponse, CreateCommentReport, CreateCommunityReport, CreatePostReport, CreatePrivateMessageReport, ListReports, PostReportResponse, PrivateMessageReportResponse, ResolveCommentReport, ResolveCommunityReport, ResolvePostReport, ResolvePrivateMessageReport, }, }; ================================================ FILE: crates/api/api_common/src/search.rs ================================================ pub use lemmy_db_schema::{ CommunitySortType, LikeType, PersonContentType, SearchSortType, SearchType, newtypes::SearchCombinedId, source::combined::search::SearchCombined, }; pub use lemmy_db_schema_file::enums::{CommentSortType, ListingType, PostSortType}; pub use lemmy_db_views_search_combined::{Search, SearchCombinedView, SearchResponse}; ================================================ FILE: crates/api/api_common/src/site.rs ================================================ pub use lemmy_db_schema::{ newtypes::{LocalSiteId, SiteId}, source::{ local_site::LocalSite, local_site_rate_limit::LocalSiteRateLimit, local_site_url_blocklist::LocalSiteUrlBlocklist, site::Site, }, }; pub use lemmy_db_schema_file::enums::RegistrationMode; pub use lemmy_db_views_site::{ SiteView, api::{GetSiteResponse, PostOrCommentOrPrivateMessage, SiteResponse, UnreadCountsResponse}, }; pub mod administration { pub use lemmy_db_views_local_user::api::AdminListUsers; pub use lemmy_db_views_person::api::{AddAdmin, AddAdminResponse}; pub use lemmy_db_views_registration_applications::api::{ ApproveRegistrationApplication, ListRegistrationApplications, }; pub use lemmy_db_views_site::api::{CreateSite, EditSite}; } ================================================ FILE: crates/api/api_common/src/tagline.rs ================================================ pub use lemmy_db_schema::{newtypes::TaglineId, source::tagline::Tagline}; pub use lemmy_db_views_site::api::{ListTaglines, TaglineResponse}; pub mod administration { pub use lemmy_db_views_site::api::{CreateTagline, DeleteTagline, EditTagline}; } ================================================ FILE: crates/api/api_crud/Cargo.toml ================================================ [package] name = "lemmy_api_crud" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lints] workspace = true [features] full = [] [dependencies] lemmy_db_views_comment = { workspace = true, features = ["full"] } lemmy_db_views_community = { workspace = true, features = ["full"] } lemmy_db_views_community_moderator = { workspace = true, features = ["full"] } lemmy_db_views_community_follower = { workspace = true, features = ["full"] } lemmy_db_views_post = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_person = { workspace = true, features = ["full"] } lemmy_db_views_custom_emoji = { workspace = true, features = ["full"] } lemmy_db_views_private_message = { workspace = true, features = ["full"] } lemmy_db_views_registration_applications = { workspace = true, features = [ "full", ] } lemmy_db_views_search_combined = { workspace = true, features = ["full"] } lemmy_db_views_site = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true, features = ["full"] } lemmy_db_schema_file = { workspace = true } lemmy_apub_objects = { workspace = true } lemmy_email = { workspace = true } activitypub_federation = { workspace = true } bcrypt = { workspace = true } actix-web = { workspace = true } url = { workspace = true } tracing = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } anyhow.workspace = true chrono.workspace = true accept-language = "3.1.0" regex = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } serde_with = { workspace = true } diesel-async = { workspace = true } lemmy_diesel_utils = { workspace = true } [package.metadata.cargo-shear] ignored = ["futures", "futures-util"] [dev-dependencies] [build-dependencies] serde = { workspace = true } serde_json = { workspace = true } [lib] doctest = false ================================================ FILE: crates/api/api_crud/src/comment/create.rs ================================================ use crate::community_use_pending; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_comment_depth, check_community_user_action, check_post_deleted_or_removed, get_url_blocklist, is_mod_or_admin, process_markdown, slur_regex, update_read_comments, }, }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm}, notification::Notification, }, traits::Likeable, }; use lemmy_db_views_comment::api::{CommentResponse, CreateComment}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::PostView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::is_valid_body_field, }; pub async fn create_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let content = process_markdown( &data.content, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; is_valid_body_field(&content, false)?; // Check for a community ban let post_id = data.post_id; let my_person_id = local_user_view.person.id; let local_instance_id = local_user_view.person.instance_id; // Read the full post view in order to get the comments count. let post_view = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, true, ) .await?; let post = post_view.post; let community_id = post_view.community.id; check_community_user_action(&local_user_view, &post_view.community, &mut context.pool()).await?; check_post_deleted_or_removed(&post)?; // Fetch the parent, if it exists let parent_opt = if let Some(parent_id) = data.parent_id { Comment::read(&mut context.pool(), parent_id).await.ok() } else { None }; // Check if post or parent is locked, no new comments let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view, community_id) .await .is_ok(); // We only need to check the parent comment here as when we lock a // comment we also lock all of its children. let locked = post.locked || parent_opt.as_ref().is_some_and(|p| p.locked); if locked && !is_mod_or_admin { return Err(LemmyErrorType::Locked.into()); } // If there's a parent_id, check to make sure that comment is in that post // Strange issue where sometimes the post ID of the parent comment is incorrect if let Some(parent) = parent_opt.as_ref() { if parent.post_id != post_id { return Err(LemmyErrorType::CouldntCreate.into()); } check_comment_depth(parent)?; } let mut comment_form = CommentInsertForm { language_id: data.language_id, federation_pending: Some(community_use_pending(&post_view.community, &context).await), ..CommentInsertForm::new(my_person_id, data.post_id, content.clone()) }; comment_form = plugin_hook_before("local_comment_before_create", comment_form).await?; validate_post_language(&mut context.pool(), comment_form.language_id, community_id).await?; // Create the comment let parent_path = parent_opt.clone().map(|t| t.path); let inserted_comment = Comment::create(&mut context.pool(), &comment_form, parent_path.as_ref()).await?; plugin_hook_after("local_comment_after_create", &inserted_comment); NotifyData { comment: Some(inserted_comment.clone()), do_send_email: !local_site.disable_email_notifications, ..NotifyData::new( post.clone(), local_user_view.person.clone(), post_view.community, ) } .send(&context); // You like your own comment by default let like_form = CommentLikeForm::new(inserted_comment.id, my_person_id, Some(true)); CommentActions::like(&mut context.pool(), &like_form).await?; ActivityChannel::submit_activity( SendActivityData::CreateComment(inserted_comment.clone()), &context, )?; // Update the read comments, so your own new comment doesn't appear as a +1 unread update_read_comments( my_person_id, post_id, post.comments + 1, &mut context.pool(), ) .await?; // If we're responding to a comment where we're the recipient, // (ie we're the grandparent, or the recipient of the parent comment_reply), // then mark the parent as read. // Then we don't have to do it manually after we respond to a comment. if let Some(parent) = parent_opt { Notification::mark_read_by_comment_and_recipient( &mut context.pool(), parent.id, my_person_id, true, ) .await .ok(); } Ok(Json( build_comment_response( &context, inserted_comment.id, Some(local_user_view), local_instance_id, ) .await?, )) } ================================================ FILE: crates/api/api_crud/src/comment/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_user_action, }; use lemmy_db_schema::source::comment::{Comment, CommentUpdateForm}; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, DeleteComment}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn delete_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let comment_id = data.comment_id; let local_instance_id = local_user_view.person.instance_id; let orig_comment = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; // Dont delete it if its already been deleted. if orig_comment.comment.deleted == data.deleted { return Err(LemmyErrorType::CouldntUpdate.into()); } check_community_user_action( &local_user_view, &orig_comment.community, &mut context.pool(), ) .await?; // Verify that only the creator can delete if local_user_view.person.id != orig_comment.creator.id { return Err(LemmyErrorType::NoCommentEditAllowed.into()); } // Do the delete let deleted = data.deleted; let updated_comment = Comment::update( &mut context.pool(), comment_id, &CommentUpdateForm { deleted: Some(deleted), ..Default::default() }, ) .await?; let updated_comment_id = updated_comment.id; ActivityChannel::submit_activity( SendActivityData::DeleteComment( updated_comment, local_user_view.person.clone(), orig_comment.community, ), &context, )?; Ok(Json( build_comment_response( &context, updated_comment_id, Some(local_user_view), local_instance_id, ) .await?, )) } ================================================ FILE: crates/api/api_crud/src/comment/mod.rs ================================================ pub mod create; pub mod delete; pub mod read; pub mod remove; pub mod update; ================================================ FILE: crates/api/api_crud/src/comment/read.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, utils::check_private_instance, }; use lemmy_db_views_comment::api::{CommentResponse, GetComment}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; pub async fn get_comment( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = site_view.local_site; let local_instance_id = site_view.site.instance_id; check_private_instance(&local_user_view, &local_site)?; Ok(Json( build_comment_response(&context, data.id, local_user_view, local_instance_id).await?, )) } ================================================ FILE: crates/api/api_crud/src/comment/remove.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::{ source::{ comment::{Comment, CommentUpdateForm}, comment_report::CommentReport, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, }, traits::Reportable, }; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, RemoveComment}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn remove_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let comment_id = data.comment_id; let local_instance_id = local_user_view.person.instance_id; let orig_comment = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; check_community_mod_action( &local_user_view, &orig_comment.community, false, &mut context.pool(), ) .await?; LocalUser::is_higher_mod_or_admin_check( &mut context.pool(), orig_comment.community.id, local_user_view.person.id, vec![orig_comment.creator.id], ) .await?; // Don't allow removing or restoring comment which was deleted by user, as it would reveal // the comment text in mod log. if orig_comment.comment.deleted { return Err(LemmyErrorType::CouldntUpdate.into()); } let (updated_comment, forms) = if let Some(remove_children) = data.remove_children { let updated_comments: Vec = Comment::update_removed_for_comment_and_children( &mut context.pool(), &orig_comment.comment.path, remove_children, ) .await?; let updated_comment = updated_comments .iter() .find(|c| c.id == comment_id) .ok_or(LemmyErrorType::CouldntUpdate)? .clone(); let forms: Vec<_> = updated_comments .iter() // Filter out deleted comments here so their content doesn't show up in the modlog. .filter(|c| !c.deleted) .map(|comment| { ModlogInsertForm::mod_remove_comment( local_user_view.person.id, comment, orig_comment.community.id, remove_children, &data.reason, None, ) }) .collect(); CommentReport::resolve_all_for_thread( &mut context.pool(), &orig_comment.comment.path, local_user_view.person.id, ) .await?; (updated_comment, forms) } else { // Do the remove let removed = data.removed; let updated_comment = Comment::update( &mut context.pool(), comment_id, &CommentUpdateForm { removed: Some(removed), ..Default::default() }, ) .await?; CommentReport::resolve_all_for_object( &mut context.pool(), comment_id, local_user_view.person.id, ) .await?; // Mod tables let form = ModlogInsertForm::mod_remove_comment( local_user_view.person.id, &orig_comment.comment, orig_comment.community.id, removed, &data.reason, None, ); (updated_comment, vec![form]) }; let actions = Modlog::create(&mut context.pool(), &forms).await?; notify_mod_action(actions, &context); let updated_comment_id = updated_comment.id; ActivityChannel::submit_activity( SendActivityData::RemoveComment { comment: updated_comment, moderator: local_user_view.person.clone(), community: orig_comment.community, reason: data.reason.clone(), with_replies: data.remove_children.unwrap_or_default(), }, &context, )?; Ok(Json( build_comment_response( &context, updated_comment_id, Some(local_user_view), local_instance_id, ) .await?, )) } ================================================ FILE: crates/api/api_crud/src/comment/update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ build_response::build_comment_response, context::LemmyContext, notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_user_action, get_url_blocklist, process_markdown_opt, slur_regex}, }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, source::comment::{Comment, CommentUpdateForm}, }; use lemmy_db_views_comment::{ CommentView, api::{CommentResponse, EditComment}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::is_valid_body_field, }; pub async fn edit_comment( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let comment_id = data.comment_id; let local_instance_id = local_user_view.person.instance_id; let orig_comment = CommentView::read( &mut context.pool(), comment_id, Some(&local_user_view.local_user), local_instance_id, ) .await?; check_community_user_action( &local_user_view, &orig_comment.community, &mut context.pool(), ) .await?; // Verify that only the creator can edit if local_user_view.person.id != orig_comment.creator.id { return Err(LemmyErrorType::NoCommentEditAllowed.into()); } let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let content = process_markdown_opt( &data.content, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; if let Some(content) = &content { is_valid_body_field(content, false)?; } let comment_id = data.comment_id; let mut form = CommentUpdateForm { content, language_id: data.language_id, updated_at: Some(Some(Utc::now())), ..Default::default() }; form = plugin_hook_before("local_comment_before_update", form).await?; validate_post_language( &mut context.pool(), form.language_id, orig_comment.community.id, ) .await?; let updated_comment = Comment::update(&mut context.pool(), comment_id, &form).await?; plugin_hook_after("local_comment_after_update", &updated_comment); // Do the mentions / recipients NotifyData { comment: Some(updated_comment.clone()), ..NotifyData::new( orig_comment.post, local_user_view.person.clone(), orig_comment.community, ) } .send(&context); ActivityChannel::submit_activity( SendActivityData::UpdateComment(updated_comment.clone()), &context, )?; Ok(Json( build_comment_response( &context, updated_comment.id, Some(local_user_view), local_instance_id, ) .await?, )) } ================================================ FILE: crates/api/api_crud/src/community/create.rs ================================================ use activitypub_federation::{config::Data, http_signatures::generate_actor_keypair}; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_community_response, context::LemmyContext, utils::{ check_local_user_valid, check_nsfw_allowed, generate_featured_url, generate_followers_url, generate_inbox_url, generate_moderators_url, get_url_blocklist, is_admin, process_markdown_opt, slur_regex, }, }; use lemmy_db_schema::{ source::{ actor_language::{CommunityLanguage, LocalUserLanguage, SiteLanguage}, community::{ Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, }, }, traits::{ApubActor, Followable}, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_community::api::{CommunityResponse, CreateCommunity}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::{ slurs::check_slurs, validation::{ is_valid_actor_name, is_valid_body_field, is_valid_display_name, summary_length_check, }, }, }; pub async fn create_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let SiteView { site, local_site, .. } = SiteView::read_local(&mut context.pool()).await?; if local_site.community_creation_admin_only && is_admin(&local_user_view).is_err() { return Err(LemmyErrorType::OnlyAdminsCanCreateCommunities.into()); } check_nsfw_allowed(data.nsfw, Some(&local_site))?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; check_slurs(&data.name, &slur_regex)?; check_slurs(&data.title, &slur_regex)?; let sidebar = process_markdown_opt( &data.sidebar, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; let title = data.title.trim().to_string(); is_valid_display_name(&title)?; // Ensure that the sidebar has fewer than the max num characters... if let Some(sidebar) = &sidebar { is_valid_body_field(sidebar, false)?; } let summary = data.summary.clone(); if let Some(summary) = &summary { summary_length_check(summary)?; check_slurs(summary, &slur_regex)?; } is_valid_actor_name(&data.name)?; // Double check for duplicate community actor_ids let community_ap_id = Community::generate_local_actor_url(&data.name, context.settings())?; let community_dupe = Community::read_from_apub_id(&mut context.pool(), &community_ap_id).await?; if community_dupe.is_some() { return Err(LemmyErrorType::AlreadyExists.into()); } let keypair = generate_actor_keypair()?; let community_form = CommunityInsertForm { sidebar, summary, nsfw: data.nsfw, ap_id: Some(community_ap_id.clone()), private_key: Some(keypair.private_key), followers_url: Some(generate_followers_url(&community_ap_id)?), inbox_url: Some(generate_inbox_url()?), moderators_url: Some(generate_moderators_url(&community_ap_id)?), featured_url: Some(generate_featured_url(&community_ap_id)?), posting_restricted_to_mods: data.posting_restricted_to_mods, visibility: data.visibility, ..CommunityInsertForm::new( site.instance_id, data.name.clone(), title, keypair.public_key, ) }; let inserted_community = Community::create(&mut context.pool(), &community_form).await?; let community_id = inserted_community.id; // The community creator becomes a moderator let community_moderator_form = CommunityModeratorForm::new(community_id, local_user_view.person.id); CommunityActions::join(&mut context.pool(), &community_moderator_form).await?; // Follow your own community let community_follower_form = CommunityFollowerForm::new( community_id, local_user_view.person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(&mut context.pool(), &community_follower_form).await?; // Update the discussion_languages if that's provided let site_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?; let languages = if let Some(languages) = data.discussion_languages.clone() { // check that community languages are a subset of site languages // https://stackoverflow.com/a/64227550 let is_subset = languages.iter().all(|item| site_languages.contains(item)); if !is_subset { return Err(LemmyErrorType::LanguageNotAllowed.into()); } languages } else { // Copy languages from creator LocalUserLanguage::read(&mut context.pool(), local_user_view.local_user.id) .await? .into_iter() .filter(|l| site_languages.contains(l)) .collect() }; CommunityLanguage::update(&mut context.pool(), languages, community_id).await?; build_community_response(&context, local_user_view, community_id).await } ================================================ FILE: crates/api/api_crud/src/community/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_community_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_mod_action, check_local_user_valid, is_top_mod}, }; use lemmy_db_schema::source::community::{Community, CommunityUpdateForm}; use lemmy_db_views_community::api::{CommunityResponse, DeleteCommunity}; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn delete_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; // Fetch the community mods let community_mods = CommunityModeratorView::for_community(&mut context.pool(), data.community_id).await?; let community = Community::read(&mut context.pool(), data.community_id).await?; check_community_mod_action(&local_user_view, &community, true, &mut context.pool()).await?; // Make sure deleter is the top mod is_top_mod(&local_user_view, &community_mods)?; // Do the delete let community_id = data.community_id; let deleted = data.deleted; let community = Community::update( &mut context.pool(), community_id, &CommunityUpdateForm { deleted: Some(deleted), ..Default::default() }, ) .await?; ActivityChannel::submit_activity( SendActivityData::DeleteCommunity(local_user_view.person.clone(), community, data.deleted), &context, )?; build_community_response(&context, local_user_view, community_id).await } ================================================ FILE: crates/api/api_crud/src/community/list.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_views_community::{CommunityView, api::ListCommunities, impls::CommunityQuery}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_communities( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let local_site = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&local_user_view, &local_site.local_site)?; let local_user = local_user_view.map(|l| l.local_user); // Show nsfw content if param is true, or if content_warning exists let show_nsfw = data .show_nsfw .unwrap_or(local_site.site.content_warning.is_some()); let res = CommunityQuery { listing_type: data.type_, show_nsfw: Some(show_nsfw), sort: data.sort, time_range_seconds: data.time_range_seconds, local_user: local_user.as_ref(), page_cursor: data.page_cursor, limit: data.limit, ..Default::default() } .list(&local_site.site, &mut context.pool()) .await?; // Return the jwt Ok(Json(res)) } ================================================ FILE: crates/api/api_crud/src/community/mod.rs ================================================ pub mod create; pub mod delete; pub mod list; pub mod remove; pub mod update; ================================================ FILE: crates/api/api_crud/src/community/remove.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_community_response, context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_mod_action, is_admin}, }; use lemmy_db_schema::{ source::{ community::{Community, CommunityUpdateForm}, community_report::CommunityReport, modlog::{Modlog, ModlogInsertForm}, }, traits::Reportable, }; use lemmy_db_views_community::api::{CommunityResponse, RemoveCommunity}; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn remove_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let community = Community::read(&mut context.pool(), data.community_id).await?; check_community_mod_action(&local_user_view, &community, true, &mut context.pool()).await?; // Verify its an admin (only an admin can remove a community) is_admin(&local_user_view)?; // Do the remove let community_id = data.community_id; let removed = data.removed; let community = Community::update( &mut context.pool(), community_id, &CommunityUpdateForm { removed: Some(removed), ..Default::default() }, ) .await?; CommunityReport::resolve_all_for_object( &mut context.pool(), community_id, local_user_view.person.id, ) .await?; // Mod let community_owner = CommunityModeratorView::top_mod_for_community(&mut context.pool(), data.community_id).await?; let form = ModlogInsertForm::admin_remove_community( &local_user_view.person, data.community_id, community_owner, removed, &data.reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), context.app_data()); ActivityChannel::submit_activity( SendActivityData::RemoveCommunity { moderator: local_user_view.person.clone(), community, reason: data.reason.clone(), removed: data.removed, }, &context, )?; build_community_response(&context, local_user_view, community_id).await } ================================================ FILE: crates/api/api_crud/src/community/update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ build_response::build_community_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_community_mod_action, check_local_user_valid, check_nsfw_allowed, get_url_blocklist, process_markdown_opt, slur_regex, }, }; use lemmy_db_schema::source::{ actor_language::{CommunityLanguage, SiteLanguage}, community::{Community, CommunityUpdateForm}, modlog::{Modlog, ModlogInsertForm}, }; use lemmy_db_views_community::api::{CommunityResponse, EditCommunity}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{traits::Crud, utils::diesel_string_update}; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::{ slurs::{check_slurs, check_slurs_opt}, validation::{is_valid_body_field, is_valid_display_name}, }, }; pub async fn edit_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; check_slurs_opt(&data.title, &slur_regex)?; check_slurs_opt(&data.summary, &slur_regex)?; check_nsfw_allowed(data.nsfw, Some(&local_site))?; let title = data.title.as_ref().map(|x| x.trim().to_string()); if let Some(title) = &title { check_slurs(title, &slur_regex)?; is_valid_display_name(title)?; } let sidebar = diesel_string_update( process_markdown_opt( &data.sidebar, &slur_regex, &url_blocklist, &local_site, &context, ) .await? .as_deref(), ); if let Some(Some(sidebar)) = &sidebar { is_valid_body_field(sidebar, false)?; } let summary = diesel_string_update(data.summary.as_deref()); let old_community = Community::read(&mut context.pool(), data.community_id).await?; // Verify its a mod (only mods can edit it) check_community_mod_action(&local_user_view, &old_community, false, &mut context.pool()).await?; let community_id = data.community_id; if let Some(languages) = data.discussion_languages.clone() { let site_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?; // check that community languages are a subset of site languages // https://stackoverflow.com/a/64227550 let is_subset = languages.iter().all(|item| site_languages.contains(item)); if !is_subset { return Err(LemmyErrorType::LanguageNotAllowed.into()); } CommunityLanguage::update(&mut context.pool(), languages, community_id).await?; } let community_form = CommunityUpdateForm { title, sidebar, summary, nsfw: data.nsfw, posting_restricted_to_mods: data.posting_restricted_to_mods, visibility: data.visibility, updated_at: Some(Some(Utc::now())), ..Default::default() }; let community_id = data.community_id; let community = Community::update(&mut context.pool(), community_id, &community_form).await?; let visibility_changed = old_community.visibility != community.visibility; if visibility_changed { let form = ModlogInsertForm::mod_change_community_visibility( local_user_view.person.id, data.community_id, ); Modlog::create(&mut context.pool(), &[form]).await?; } // If community visibility was changed to local-only, mark it as deleted on other instances. Also // restore it if visibility is changed to public again. if visibility_changed && !old_community.visibility.can_federate() || !community.visibility.can_federate() { let mark_deleted = !community.visibility.can_federate(); let activity = SendActivityData::DeleteCommunity( local_user_view.person.clone(), community.clone(), mark_deleted, ); ActivityChannel::submit_activity(activity, &context)?; } ActivityChannel::submit_activity( SendActivityData::UpdateCommunity(local_user_view.person.clone(), community), &context, )?; build_community_response(&context, local_user_view, community_id).await } ================================================ FILE: crates/api/api_crud/src/custom_emoji/create.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::{ custom_emoji::{CustomEmoji, CustomEmojiInsertForm}, custom_emoji_keyword::CustomEmojiKeyword, }; use lemmy_db_views_custom_emoji::{ CustomEmojiView, api::{CreateCustomEmoji, CustomEmojiResponse}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn create_custom_emoji( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Make sure user is an admin is_admin(&local_user_view)?; let emoji_form = CustomEmojiInsertForm { shortcode: data.shortcode.to_lowercase().trim().to_string(), image_url: data.image_url.clone(), alt_text: data.alt_text.clone(), category: data.category.clone(), }; let emoji = CustomEmoji::create(&mut context.pool(), &emoji_form).await?; CustomEmojiKeyword::create_from_keywords(&mut context.pool(), emoji.id, &data.keywords).await?; let view = CustomEmojiView::get(&mut context.pool(), emoji.id).await?; Ok(Json(CustomEmojiResponse { custom_emoji: view })) } ================================================ FILE: crates/api/api_crud/src/custom_emoji/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::custom_emoji::CustomEmoji; use lemmy_db_views_custom_emoji::api::DeleteCustomEmoji; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn delete_custom_emoji( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Make sure user is an admin is_admin(&local_user_view)?; CustomEmoji::delete(&mut context.pool(), data.id).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api_crud/src/custom_emoji/list.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_custom_emoji::{ CustomEmojiView, api::{ListCustomEmojis, ListCustomEmojisResponse}, }; use lemmy_utils::error::LemmyError; pub async fn list_custom_emojis( Query(data): Query, context: Data, ) -> Result, LemmyError> { let custom_emojis = CustomEmojiView::list(&mut context.pool(), &data.category).await?; Ok(Json(ListCustomEmojisResponse { custom_emojis })) } ================================================ FILE: crates/api/api_crud/src/custom_emoji/mod.rs ================================================ pub mod create; pub mod delete; pub mod list; pub mod update; ================================================ FILE: crates/api/api_crud/src/custom_emoji/update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::{ custom_emoji::{CustomEmoji, CustomEmojiUpdateForm}, custom_emoji_keyword::CustomEmojiKeyword, }; use lemmy_db_views_custom_emoji::{ CustomEmojiView, api::{CustomEmojiResponse, EditCustomEmoji}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn edit_custom_emoji( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { // Make sure user is an admin is_admin(&local_user_view)?; let emoji_form = CustomEmojiUpdateForm { image_url: data.image_url.clone(), shortcode: data .shortcode .clone() .map(|s| s.to_lowercase().trim().to_string()), alt_text: data.alt_text.clone(), category: data.category.clone(), }; let emoji = CustomEmoji::update(&mut context.pool(), data.id, &emoji_form).await?; // Delete the existing keywords, and recreate if let Some(keywords) = &data.keywords { CustomEmojiKeyword::delete(&mut context.pool(), data.id).await?; CustomEmojiKeyword::create_from_keywords(&mut context.pool(), emoji.id, keywords).await?; } let view = CustomEmojiView::get(&mut context.pool(), emoji.id).await?; Ok(Json(CustomEmojiResponse { custom_emoji: view })) } ================================================ FILE: crates/api/api_crud/src/lib.rs ================================================ use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::community::{Community, CommunityActions}; pub mod comment; pub mod community; pub mod custom_emoji; pub mod multi_community; pub mod oauth_provider; pub mod post; pub mod private_message; pub mod site; pub mod tagline; pub mod user; /// Only mark new posts/comments to remote community as pending if it has any local followers. /// Otherwise it could never get updated to be marked as published. async fn community_use_pending(community: &Community, context: &LemmyContext) -> bool { if community.local { return false; } CommunityActions::check_accept_activity_in_community(&mut context.pool(), community) .await .is_ok() } ================================================ FILE: crates/api/api_crud/src/multi_community/create.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, utils::{check_local_user_valid, get_url_blocklist, process_markdown_opt, slur_regex}, }; use lemmy_db_schema::{ source::multi_community::{MultiCommunity, MultiCommunityFollowForm, MultiCommunityInsertForm}, traits::ApubActor, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_community::{ MultiCommunityView, api::{CreateMultiCommunity, MultiCommunityResponse}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::LemmyResult, utils::{ slurs::check_slurs, validation::{ is_valid_actor_name, is_valid_body_field, is_valid_display_name, summary_length_check, }, }, }; use url::Url; pub async fn create_multi_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let SiteView { site, local_site, .. } = SiteView::read_local(&mut context.pool()).await?; let my_person_id = local_user_view.person.id; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; is_valid_display_name(&data.name)?; check_slurs(&data.name, &slur_regex)?; let ap_id = MultiCommunity::generate_local_actor_url(&data.name, context.settings())?; let following_url = Url::parse(&format!("{}/following", ap_id))?; let title = data.title.as_ref().map(|x| x.trim().to_string()); if let Some(title) = &title { check_slurs(title, &slur_regex)?; is_valid_display_name(title)?; } // Ensure that the sidebar has fewer than the max num characters... let sidebar = process_markdown_opt( &data.sidebar, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; if let Some(sidebar) = &sidebar { is_valid_body_field(sidebar, false)?; } let summary = data.summary.clone(); if let Some(summary) = &summary { summary_length_check(summary)?; check_slurs(summary, &slur_regex)?; } is_valid_actor_name(&data.name)?; let form = MultiCommunityInsertForm { title, summary, sidebar, ap_id: Some(ap_id), private_key: site.private_key, inbox_url: Some(site.inbox_url), following_url: Some(following_url.into()), ..MultiCommunityInsertForm::new( my_person_id, local_user_view.person.instance_id, data.name.clone(), site.public_key, ) }; let multi = MultiCommunity::create(&mut context.pool(), &form).await?; // You follow your own community let follow_form = MultiCommunityFollowForm { multi_community_id: multi.id, person_id: my_person_id, follow_state: CommunityFollowerState::Accepted, }; MultiCommunity::follow(&mut context.pool(), &follow_form).await?; let multi_community_view = MultiCommunityView::read(&mut context.pool(), multi.id, Some(my_person_id)).await?; Ok(Json(MultiCommunityResponse { multi_community_view, })) } ================================================ FILE: crates/api/api_crud/src/multi_community/create_entry.rs ================================================ use super::{check_multi_community_creator, send_federation_update}; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_community_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_deleted_removed, check_local_user_valid}, }; use lemmy_db_schema::{ source::{ community::{Community, CommunityActions, CommunityFollowerForm}, multi_community::{MultiCommunity, MultiCommunityEntry, MultiCommunityEntryForm}, }, traits::Followable, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_community::api::{CommunityResponse, CreateOrDeleteMultiCommunityEntry}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn create_multi_community_entry( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let community_id = data.community_id; check_local_user_valid(&local_user_view)?; let multi = MultiCommunity::read(&mut context.pool(), data.id).await?; check_multi_community_creator(&multi, &local_user_view)?; let community = Community::read(&mut context.pool(), community_id).await?; check_community_deleted_removed(&community)?; MultiCommunityEntry::check_entry_limit(&mut context.pool(), data.id).await?; let form = MultiCommunityEntryForm { multi_community_id: data.id, community_id, }; let inserted_entry = MultiCommunityEntry::create(&mut context.pool(), &form).await?; if !community.local { let multicomm_follower = SiteView::read_system_account(&mut context.pool()).await?; let actions = CommunityActions::read(&mut context.pool(), community.id, multicomm_follower.id) .await .unwrap_or_default(); // follow the community if not already followed if actions.followed_at.is_none() { let form = CommunityFollowerForm::new( community.id, multicomm_follower.id, CommunityFollowerState::Pending, ); CommunityActions::follow(&mut context.pool(), &form).await?; ActivityChannel::submit_activity( SendActivityData::FollowCommunity(community, local_user_view.person.clone(), true), &context, )?; } } send_federation_update(multi, local_user_view.person.clone(), &context)?; build_community_response(&context, local_user_view, inserted_entry.community_id).await } ================================================ FILE: crates/api/api_crud/src/multi_community/delete_entry.rs ================================================ use super::{check_multi_community_creator, send_federation_update}; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_local_user_valid, }; use lemmy_db_schema::{ source::{ community::{Community, CommunityActions}, multi_community::{MultiCommunity, MultiCommunityEntry, MultiCommunityEntryForm}, }, traits::Followable, }; use lemmy_db_views_community::api::CreateOrDeleteMultiCommunityEntry; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{SiteView, api::SuccessResponse}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn delete_multi_community_entry( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let multi = MultiCommunity::read(&mut context.pool(), data.id).await?; check_multi_community_creator(&multi, &local_user_view)?; let community = Community::read(&mut context.pool(), data.community_id).await?; let form = MultiCommunityEntryForm { multi_community_id: data.id, community_id: data.community_id, }; MultiCommunityEntry::delete(&mut context.pool(), &form).await?; if !community.local { let used_in_multiple = MultiCommunityEntry::community_used_in_multiple(&mut context.pool(), &form).await?; // unfollow the community only if its not used in another multi-community if !used_in_multiple { let multicomm_follower = SiteView::read_system_account(&mut context.pool()).await?; CommunityActions::unfollow(&mut context.pool(), multicomm_follower.id, community.id).await?; ActivityChannel::submit_activity( SendActivityData::FollowCommunity(community, local_user_view.person.clone(), false), &context, )?; } } send_federation_update(multi, local_user_view.person, &context)?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api_crud/src/multi_community/list.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_community::{ MultiCommunityView, api::ListMultiCommunities, impls::MultiCommunityQuery, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyResult; pub async fn list_multi_communities( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult>> { let my_person_id = local_user_view.map(|l| l.person.id); let res = MultiCommunityQuery { listing_type: data.type_, sort: data.sort, creator_id: data.creator_id, my_person_id, time_range_seconds: data.time_range_seconds, page_cursor: data.page_cursor, limit: data.limit, ..Default::default() } .list(&mut context.pool()) .await?; Ok(Json(res)) } ================================================ FILE: crates/api/api_crud/src/multi_community/mod.rs ================================================ use activitypub_federation::config::Data; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, }; use lemmy_db_schema::source::{multi_community::MultiCommunity, person::Person}; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub mod create; pub mod create_entry; pub mod delete_entry; pub mod list; pub mod update; /// Check that current user is creator of multi-comm and can modify it. fn check_multi_community_creator( multi: &MultiCommunity, local_user_view: &LocalUserView, ) -> LemmyResult<()> { if multi.local && local_user_view.local_user.admin { Ok(()) } else if multi.creator_id != local_user_view.person.id { Err(LemmyErrorType::MultiCommunityUpdateWrongUser.into()) } else { Ok(()) } } fn send_federation_update( multi: MultiCommunity, person: Person, context: &Data, ) -> LemmyResult<()> { ActivityChannel::submit_activity( SendActivityData::UpdateMultiCommunity(multi, person), context, ) } ================================================ FILE: crates/api/api_crud/src/multi_community/update.rs ================================================ use super::{check_multi_community_creator, send_federation_update}; use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, utils::{check_local_user_valid, get_url_blocklist, process_markdown_opt, slur_regex}, }; use lemmy_db_schema::source::multi_community::{MultiCommunity, MultiCommunityUpdateForm}; use lemmy_db_views_community::{ MultiCommunityView, api::{EditMultiCommunity, MultiCommunityResponse}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{traits::Crud, utils::diesel_string_update}; use lemmy_utils::{ error::LemmyResult, utils::{ slurs::check_slurs, validation::{is_valid_body_field, is_valid_display_name, summary_length_check}, }, }; pub async fn edit_multi_community( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let multi_community_id = data.id; let my_person_id = local_user_view.person.id; check_local_user_valid(&local_user_view)?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let orig_multi = MultiCommunity::read(&mut context.pool(), data.id).await?; check_multi_community_creator(&orig_multi, &local_user_view)?; let title = data.title.as_ref().map(|x| x.trim().to_string()); if let Some(title) = &title { check_slurs(title, &slur_regex)?; is_valid_display_name(title)?; } let title = diesel_string_update(title.as_deref()); let sidebar = diesel_string_update( process_markdown_opt( &data.sidebar, &slur_regex, &url_blocklist, &local_site, &context, ) .await? .as_deref(), ); if let Some(Some(sidebar)) = &sidebar { is_valid_body_field(sidebar, false)?; } let summary = data.summary.clone(); if let Some(summary) = &summary { summary_length_check(summary)?; check_slurs(summary, &slur_regex)?; } let summary = diesel_string_update(summary.as_deref()); let form = MultiCommunityUpdateForm { title, sidebar, summary, deleted: data.deleted, updated_at: Some(Utc::now()), }; let multi = MultiCommunity::update(&mut context.pool(), multi_community_id, &form).await?; send_federation_update(multi, local_user_view.person, &context)?; let multi_community_view = MultiCommunityView::read(&mut context.pool(), multi_community_id, Some(my_person_id)).await?; Ok(Json(MultiCommunityResponse { multi_community_view, })) } ================================================ FILE: crates/api/api_crud/src/oauth_provider/create.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::oauth_provider::{AdminOAuthProvider, OAuthProviderInsertForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::CreateOAuthProvider; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyError; use url::Url; pub async fn create_oauth_provider( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { // Make sure user is an admin is_admin(&local_user_view)?; let cloned_data = data.clone(); let oauth_provider_form = OAuthProviderInsertForm { display_name: cloned_data.display_name, issuer: Url::parse(&cloned_data.issuer)?.into(), authorization_endpoint: Url::parse(&cloned_data.authorization_endpoint)?.into(), token_endpoint: Url::parse(&cloned_data.token_endpoint)?.into(), userinfo_endpoint: Url::parse(&cloned_data.userinfo_endpoint)?.into(), id_claim: cloned_data.id_claim, client_id: data.client_id.clone(), client_secret: data.client_secret.clone(), scopes: data.scopes.clone(), auto_verify_email: data.auto_verify_email, account_linking_enabled: data.account_linking_enabled, use_pkce: data.use_pkce, enabled: data.enabled, }; let oauth_provider = AdminOAuthProvider::create(&mut context.pool(), &oauth_provider_form).await?; Ok(Json(oauth_provider)) } ================================================ FILE: crates/api/api_crud/src/oauth_provider/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::oauth_provider::AdminOAuthProvider; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::{DeleteOAuthProvider, SuccessResponse}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyError; pub async fn delete_oauth_provider( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { // Make sure user is an admin is_admin(&local_user_view)?; AdminOAuthProvider::delete(&mut context.pool(), data.id).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api_crud/src/oauth_provider/mod.rs ================================================ pub mod create; pub mod delete; pub mod update; ================================================ FILE: crates/api/api_crud/src/oauth_provider/update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::oauth_provider::{AdminOAuthProvider, OAuthProviderUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::EditOAuthProvider; use lemmy_diesel_utils::{ traits::Crud, utils::{diesel_required_string_update, diesel_required_url_update}, }; use lemmy_utils::error::LemmyError; pub async fn edit_oauth_provider( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { // Make sure user is an admin is_admin(&local_user_view)?; let cloned_data = data.clone(); let oauth_provider_form = OAuthProviderUpdateForm { display_name: diesel_required_string_update(cloned_data.display_name.as_deref()), authorization_endpoint: diesel_required_url_update( cloned_data.authorization_endpoint.as_deref(), )?, token_endpoint: diesel_required_url_update(cloned_data.token_endpoint.as_deref())?, userinfo_endpoint: diesel_required_url_update(cloned_data.userinfo_endpoint.as_deref())?, id_claim: diesel_required_string_update(data.id_claim.as_deref()), client_secret: diesel_required_string_update(data.client_secret.as_deref()), scopes: diesel_required_string_update(data.scopes.as_deref()), auto_verify_email: data.auto_verify_email, account_linking_enabled: data.account_linking_enabled, enabled: data.enabled, use_pkce: data.use_pkce, updated_at: Some(Some(Utc::now())), }; let update_result = AdminOAuthProvider::update(&mut context.pool(), data.id, &oauth_provider_form).await?; let oauth_provider = AdminOAuthProvider::read(&mut context.pool(), update_result.id).await?; Ok(Json(oauth_provider)) } ================================================ FILE: crates/api/api_crud/src/post/create.rs ================================================ use super::convert_published_time; use crate::community_use_pending; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, request::generate_post_link_metadata, send_activity::SendActivityData, utils::{ check_community_user_action, check_nsfw_allowed, get_url_blocklist, honeypot_check, process_markdown_opt, send_webmention, slur_regex, update_post_tags, }, }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, source::post::{Post, PostActions, PostInsertForm, PostLikeForm}, traits::Likeable, }; use lemmy_db_views_community::CommunityView; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::{CreatePost, PostResponse}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{traits::Crud, utils::diesel_url_create}; use lemmy_utils::{ error::LemmyResult, utils::{ slurs::check_slurs, validation::{ is_url_blocked, is_valid_alt_text_field, is_valid_body_field, is_valid_post_title, is_valid_url, }, }, }; pub async fn create_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { honeypot_check(&data.honeypot)?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let slur_regex = slur_regex(&context).await?; check_slurs(&data.name, &slur_regex)?; let url_blocklist = get_url_blocklist(&context).await?; let body = process_markdown_opt( &data.body, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; let url = diesel_url_create(data.url.as_deref())?; let custom_thumbnail = diesel_url_create(data.custom_thumbnail.as_deref())?; check_nsfw_allowed(data.nsfw, Some(&local_site))?; is_valid_post_title(&data.name)?; if let Some(url) = &url { is_url_blocked(url, &url_blocklist)?; is_valid_url(url)?; } if let Some(custom_thumbnail) = &custom_thumbnail { is_valid_url(custom_thumbnail)?; } if let Some(alt_text) = &data.alt_text { is_valid_alt_text_field(alt_text)?; } if let Some(body) = &body { is_valid_body_field(body, true)?; } 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?; // Ensure that all posts in NSFW communities are marked as NSFW let nsfw = if community.nsfw { Some(true) } else { data.nsfw }; if community.posting_restricted_to_mods { let community_id = data.community_id; CommunityModeratorView::check_is_community_moderator( &mut context.pool(), community_id, local_user_view.local_user.person_id, ) .await?; } let scheduled_publish_time_at = convert_published_time(data.scheduled_publish_time_at, &local_user_view, &context).await?; let mut post_form = PostInsertForm { url, body, alt_text: data.alt_text.clone(), nsfw, language_id: data.language_id, federation_pending: Some(community_use_pending(community, &context).await), scheduled_publish_time_at, ..PostInsertForm::new( data.name.trim().to_string(), local_user_view.person.id, data.community_id, ) }; post_form = plugin_hook_before("local_post_before_create", post_form).await?; validate_post_language( &mut context.pool(), post_form.language_id, data.community_id, ) .await?; let inserted_post = Post::create(&mut context.pool(), &post_form).await?; plugin_hook_after("local_post_after_create", &inserted_post); if let Some(tags) = &data.tags { update_post_tags(&inserted_post, tags, &context).await?; } let community_id = community.id; let federate_post = if scheduled_publish_time_at.is_none() { send_webmention(inserted_post.clone(), community); |post| Some(SendActivityData::CreatePost(post)) } else { |_| None }; generate_post_link_metadata( inserted_post.clone(), custom_thumbnail.map(Into::into), federate_post, context.clone(), ) .await?; // They like their own post by default let person_id = local_user_view.person.id; let post_id = inserted_post.id; let like_form = PostLikeForm::new(post_id, person_id, Some(true)); PostActions::like(&mut context.pool(), &like_form).await?; NotifyData { do_send_email: !local_site.disable_email_notifications, ..NotifyData::new( inserted_post.clone(), local_user_view.person.clone(), community.clone(), ) } .send(&context); PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?; build_post_response(&context, community_id, local_user_view, post_id).await } ================================================ FILE: crates/api/api_crud/src/post/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_user_action, }; use lemmy_db_schema::source::{ community::Community, post::{Post, PostUpdateForm}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::{DeletePost, PostResponse}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn delete_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let post_id = data.post_id; let orig_post = Post::read(&mut context.pool(), post_id).await?; // Dont delete it if its already been deleted. if orig_post.deleted == data.deleted { return Err(LemmyErrorType::CouldntUpdate.into()); } let community = Community::read(&mut context.pool(), orig_post.community_id).await?; check_community_user_action(&local_user_view, &community, &mut context.pool()).await?; // Verify that only the creator can delete if !Post::is_post_creator(local_user_view.person.id, orig_post.creator_id) { return Err(LemmyErrorType::NoPostEditAllowed.into()); } // Update the post let post = Post::update( &mut context.pool(), post_id, &PostUpdateForm { deleted: Some(data.deleted), ..Default::default() }, ) .await?; ActivityChannel::submit_activity( SendActivityData::DeletePost(post, local_user_view.person.clone(), community), &context, )?; build_post_response(&context, orig_post.community_id, local_user_view, post_id).await } ================================================ FILE: crates/api/api_crud/src/post/mod.rs ================================================ use chrono::{DateTime, TimeZone, Utc}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::post::Post; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub mod create; pub mod delete; pub mod read; pub mod remove; pub mod update; async fn convert_published_time( scheduled_publish_time: Option, local_user_view: &LocalUserView, context: &LemmyContext, ) -> LemmyResult>> { const MAX_SCHEDULED_POSTS: i64 = 10; if let Some(scheduled_publish_time) = scheduled_publish_time { let converted = Utc .timestamp_opt(scheduled_publish_time, 0) .single() .ok_or(LemmyErrorType::InvalidUnixTime)?; if converted < Utc::now() { return Err(LemmyErrorType::PostScheduleTimeMustBeInFuture.into()); } if !local_user_view.local_user.admin { let count = Post::user_scheduled_post_count(local_user_view.person.id, &mut context.pool()).await?; if count >= MAX_SCHEDULED_POSTS { return Err(LemmyErrorType::TooManyScheduledPosts.into()); } } Ok(Some(converted)) } else { Ok(None) } } ================================================ FILE: crates/api/api_crud/src/post/read.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_private_instance, is_mod_or_admin_opt, update_read_comments}, }; use lemmy_db_schema::{ SearchType, source::{ comment::Comment, post::{Post, PostActions}, }, }; use lemmy_db_views_community::CommunityView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::PostView; use lemmy_db_views_search_combined::{ api::{GetPost, GetPostResponse}, impls::SearchCombinedQuery, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn get_post( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = site_view.local_site; let local_instance_id = site_view.site.instance_id; check_private_instance(&local_user_view, &local_site)?; let person_id = local_user_view.as_ref().map(|u| u.person.id); let local_user = local_user_view.as_ref().map(|l| l.local_user.clone()); // I'd prefer fetching the post_view by a comment join, but it adds a lot of boilerplate let post_id = if let Some(id) = data.id { id } else if let Some(comment_id) = data.comment_id { Comment::read(&mut context.pool(), comment_id) .await? .post_id } else { return Err(LemmyErrorType::NotFound.into()); }; // Check to see if the person is a mod or admin, to show deleted / removed let community_id = Post::read(&mut context.pool(), post_id).await?.community_id; let is_mod_or_admin = is_mod_or_admin_opt( &mut context.pool(), local_user_view.as_ref(), Some(community_id), ) .await .is_ok(); let post_view = PostView::read( &mut context.pool(), post_id, local_user.as_ref(), local_instance_id, is_mod_or_admin, ) .await?; let post_id = post_view.post.id; if let Some(person_id) = person_id { PostActions::mark_as_read(&mut context.pool(), person_id, &[post_id]).await?; update_read_comments( person_id, post_id, post_view.post.comments, &mut context.pool(), ) .await?; } // Necessary for the sidebar subscribed let community_view = CommunityView::read( &mut context.pool(), community_id, local_user.as_ref(), is_mod_or_admin, ) .await?; // Fetch the cross_posts let cross_posts = if let Some(url) = &post_view.post.url { SearchCombinedQuery { search_term: Some(url.inner().as_str().into()), post_url_only: Some(true), type_: Some(SearchType::Posts), ..Default::default() } .list(&mut context.pool(), &local_user_view, &site_view.site) .await? .iter() // Filter map to collect posts .filter_map(|f| f.to_post_view()) // Don't return this post as one of the cross_posts .filter(|x| x.post.id != post_id) .cloned() .collect::>() } else { Vec::new() }; // Return the jwt Ok(Json(GetPostResponse { post_view, community_view, cross_posts, })) } ================================================ FILE: crates/api/api_crud/src/post/remove.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, notify::notify_mod_action, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::{ source::{ comment::Comment, comment_report::CommentReport, community::Community, local_user::LocalUser, modlog::{Modlog, ModlogInsertForm}, post::{Post, PostUpdateForm}, post_report::PostReport, }, traits::Reportable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::api::{PostResponse, RemovePost}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn remove_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let post_id = data.post_id; let remove_post = data.remove_children.unwrap_or(data.removed); // We cannot use PostView to avoid a database read here, as it doesn't return removed items // by default. So we would have to pass in `is_mod_or_admin`, but that is impossible without // knowing which community the post belongs to. let orig_post = Post::read(&mut context.pool(), post_id).await?; let community = Community::read(&mut context.pool(), orig_post.community_id).await?; check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?; LocalUser::is_higher_mod_or_admin_check( &mut context.pool(), orig_post.community_id, local_user_view.person.id, vec![orig_post.creator_id], ) .await?; // Update the post let post = Post::update( &mut context.pool(), post_id, &PostUpdateForm { removed: Some(remove_post), ..Default::default() }, ) .await?; PostReport::resolve_all_for_object(&mut context.pool(), post_id, local_user_view.person.id) .await?; // Mod tables let form = ModlogInsertForm::mod_remove_post( local_user_view.person.id, &post, remove_post, &data.reason, None, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context.app_data()); if let Some(remove_children) = data.remove_children { let updated_comments: Vec = Comment::update_removed_for_post(&mut context.pool(), post_id, remove_children).await?; let forms: Vec<_> = updated_comments .iter() // Filter out deleted comments here so their content doesn't show up in the modlog. .filter(|c| !c.deleted) .map(|comment| { ModlogInsertForm::mod_remove_comment( local_user_view.person.id, comment, community.id, remove_children, &data.reason, None, ) }) .collect(); let actions = Modlog::create(&mut context.pool(), &forms).await?; notify_mod_action(actions, &context); CommentReport::resolve_all_for_post(&mut context.pool(), post.id, local_user_view.person.id) .await?; } ActivityChannel::submit_activity( SendActivityData::RemovePost { post, moderator: local_user_view.person.clone(), reason: data.reason.clone(), removed: remove_post, with_replies: data.remove_children.unwrap_or_default(), }, &context, )?; build_post_response(&context, community.id, local_user_view, post_id).await } ================================================ FILE: crates/api/api_crud/src/post/update.rs ================================================ use super::convert_published_time; use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ build_response::build_post_response, context::LemmyContext, notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, request::generate_post_link_metadata, send_activity::SendActivityData, utils::{ check_community_user_action, check_nsfw_allowed, get_url_blocklist, process_markdown_opt, send_webmention, slur_regex, update_post_tags, }, }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, source::{ community::Community, post::{Post, PostUpdateForm}, }, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{ PostView, api::{EditPost, PostResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{ traits::Crud, utils::{diesel_string_update, diesel_url_update}, }; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::{ slurs::check_slurs, validation::{ is_url_blocked, is_valid_alt_text_field, is_valid_body_field, is_valid_post_title, is_valid_url, }, }, }; use std::ops::Deref; pub async fn edit_post( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let local_instance_id = local_user_view.person.instance_id; let url = diesel_url_update(data.url.as_deref())?; let custom_thumbnail = diesel_url_update(data.custom_thumbnail.as_deref())?; let url_blocklist = get_url_blocklist(&context).await?; let slur_regex = slur_regex(&context).await?; let body = diesel_string_update( process_markdown_opt( &data.body, &slur_regex, &url_blocklist, &local_site, &context, ) .await? .as_deref(), ); check_nsfw_allowed(data.nsfw, Some(&local_site))?; let alt_text = diesel_string_update(data.alt_text.as_deref()); if let Some(name) = &data.name { is_valid_post_title(name)?; check_slurs(name, &slur_regex)?; } if let Some(Some(body)) = &body { is_valid_body_field(body, true)?; } if let Some(Some(alt_text)) = &alt_text { is_valid_alt_text_field(alt_text)?; } if let Some(Some(url)) = &url { is_url_blocked(url, &url_blocklist)?; is_valid_url(url)?; } if let Some(Some(custom_thumbnail)) = &custom_thumbnail { is_valid_url(custom_thumbnail)?; } let post_id = data.post_id; let orig_post = PostView::read( &mut context.pool(), post_id, Some(&local_user_view.local_user), local_instance_id, false, ) .await?; let nsfw = if orig_post.community.nsfw { Some(true) } else { data.nsfw }; check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?; // Verify that only the creator can edit if !Post::is_post_creator(local_user_view.person.id, orig_post.post.creator_id) { return Err(LemmyErrorType::NoPostEditAllowed.into()); } // handle changes to scheduled_publish_time let scheduled_publish_time_at = match ( orig_post.post.scheduled_publish_time_at, data.scheduled_publish_time_at, ) { // schedule time can be changed if post is still scheduled (and not published yet) (Some(_), Some(_)) => Some( convert_published_time(data.scheduled_publish_time_at, &local_user_view, &context).await?, ), // post was scheduled, gets changed to publish immediately (Some(_), None) => Some(None), // unchanged (_, _) => None, }; let mut post_form = PostUpdateForm { name: data.name.clone(), url, body, alt_text, nsfw, language_id: data.language_id, updated_at: Some(Some(Utc::now())), scheduled_publish_time_at, ..Default::default() }; post_form = plugin_hook_before("local_post_before_update", post_form).await?; validate_post_language( &mut context.pool(), post_form.language_id, orig_post.post.community_id, ) .await?; let post_id = data.post_id; let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?; plugin_hook_after("local_post_after_update", &post_form); if let Some(tags) = &data.tags { update_post_tags(&orig_post.post, tags, &context).await?; } NotifyData::new( updated_post.clone(), local_user_view.person.clone(), orig_post.community.clone(), ) .send(&context); // send out federation/webmention if necessary match ( orig_post.post.scheduled_publish_time_at, data.scheduled_publish_time_at, ) { // 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); generate_post_link_metadata( updated_post.clone(), custom_thumbnail.flatten().map(Into::into), |post| Some(SendActivityData::CreatePost(post)), context.clone(), ) .await?; } // post was already public, send update (None, _) => { generate_post_link_metadata( updated_post.clone(), custom_thumbnail.flatten().map(Into::into), |post| Some(SendActivityData::UpdatePost(post)), context.clone(), ) .await? } // schedule was changed, do nothing (Some(_), Some(_)) => {} }; build_post_response( context.deref(), orig_post.community.id, local_user_view, post_id, ) .await } ================================================ FILE: crates/api/api_crud/src/private_message/create.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, notify::notify_private_message, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{ check_local_user_valid, check_private_messages_enabled, get_url_blocklist, process_markdown, slur_regex, }, }; use lemmy_db_schema::{ source::{ person::PersonActions, private_message::{PrivateMessage, PrivateMessageInsertForm}, }, traits::Blockable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_private_message::{ PrivateMessageView, api::{CreatePrivateMessage, PrivateMessageResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{error::LemmyResult, utils::validation::is_valid_body_field}; pub async fn create_private_message( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let content = process_markdown( &data.content, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; is_valid_body_field(&content, false)?; PersonActions::read_block( &mut context.pool(), data.recipient_id, local_user_view.person.id, ) .await?; check_private_messages_enabled(&local_user_view)?; // Don't allow local sends to people who have private messages disabled let recipient_local_user_opt = LocalUserView::read_person(&mut context.pool(), data.recipient_id) .await .ok(); if let Some(recipient_local_user) = recipient_local_user_opt { check_private_messages_enabled(&recipient_local_user)?; } let mut form = PrivateMessageInsertForm::new( local_user_view.person.id, data.recipient_id, content.clone(), ); form = plugin_hook_before("local_private_message_before_create", form).await?; let inserted_private_message = PrivateMessage::create(&mut context.pool(), &form).await?; plugin_hook_after( "local_private_message_after_create", &inserted_private_message, ); let view = PrivateMessageView::read( &mut context.pool(), inserted_private_message.id, Some(&local_user_view.person), ) .await?; notify_private_message(&view, true, &context); ActivityChannel::submit_activity( SendActivityData::CreatePrivateMessage(view.clone()), &context, )?; Ok(Json(PrivateMessageResponse { private_message_view: view, })) } ================================================ FILE: crates/api/api_crud/src/private_message/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_local_user_valid, }; use lemmy_db_schema::source::private_message::{PrivateMessage, PrivateMessageUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_private_message::{ PrivateMessageView, api::{DeletePrivateMessage, PrivateMessageResponse}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn delete_private_message( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; // Checking permissions let private_message_id = data.private_message_id; let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?; let deleted = data.deleted; let form = if local_user_view.person.id == orig_private_message.recipient_id { PrivateMessageUpdateForm { deleted_by_recipient: Some(deleted), ..Default::default() } } else if local_user_view.person.id == orig_private_message.creator_id { PrivateMessageUpdateForm { deleted: Some(deleted), ..Default::default() } } else { return Err(LemmyErrorType::EditPrivateMessageNotAllowed.into()); }; // Doing the update let private_message = PrivateMessage::update(&mut context.pool(), private_message_id, &form).await?; let view = PrivateMessageView::read( &mut context.pool(), private_message_id, Some(&local_user_view.person), ) .await?; ActivityChannel::submit_activity( SendActivityData::DeletePrivateMessage(local_user_view.person, private_message, data.deleted), &context, )?; Ok(Json(PrivateMessageResponse { private_message_view: view, })) } ================================================ FILE: crates/api/api_crud/src/private_message/mod.rs ================================================ pub mod create; pub mod delete; pub mod update; ================================================ FILE: crates/api/api_crud/src/private_message/update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, notify::notify_private_message, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{check_local_user_valid, get_url_blocklist, process_markdown, slur_regex}, }; use lemmy_db_schema::source::private_message::{PrivateMessage, PrivateMessageUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_private_message::{ PrivateMessageView, api::{EditPrivateMessage, PrivateMessageResponse}, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::is_valid_body_field, }; pub async fn edit_private_message( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { check_local_user_valid(&local_user_view)?; // Checking permissions let private_message_id = data.private_message_id; let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?; if local_user_view.person.id != orig_private_message.creator_id { return Err(LemmyErrorType::EditPrivateMessageNotAllowed.into()); } // Doing the update let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let content = process_markdown( &data.content, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; is_valid_body_field(&content, false)?; let private_message_id = data.private_message_id; let mut form = PrivateMessageUpdateForm { content: Some(content), updated_at: Some(Some(Utc::now())), ..Default::default() }; form = plugin_hook_before("local_private_message_before_update", form).await?; let private_message = PrivateMessage::update(&mut context.pool(), private_message_id, &form).await?; plugin_hook_after("local_private_message_after_update", &private_message); let view = PrivateMessageView::read( &mut context.pool(), private_message_id, Some(&local_user_view.person), ) .await?; notify_private_message(&view, false, &context); ActivityChannel::submit_activity( SendActivityData::UpdatePrivateMessage(view.clone()), &context, )?; Ok(Json(PrivateMessageResponse { private_message_view: view, })) } ================================================ FILE: crates/api/api_crud/src/site/create.rs ================================================ use super::not_zero; use crate::site::{application_question_check, site_default_post_listing_type_check}; use activitypub_federation::{config::Data, http_signatures::generate_actor_keypair}; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, utils::{ generate_inbox_url, get_url_blocklist, is_admin, local_site_rate_limit_to_rate_limit_config, process_markdown_opt, slur_regex, }, }; use lemmy_db_schema::{ newtypes::MultiCommunityId, source::{ local_site::{LocalSite, LocalSiteUpdateForm}, local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitUpdateForm}, site::{Site, SiteUpdateForm}, }, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{CreateSite, SiteResponse}, }; use lemmy_diesel_utils::{ dburl::DbUrl, traits::Crud, utils::{diesel_opt_number_update, diesel_string_update}, }; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::{ slurs::check_slurs, validation::{ build_and_check_regex, is_valid_body_field, site_name_length_check, summary_length_check, }, }, }; use url::Url; pub async fn create_site( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; // Make sure user is an admin; other types of users should not create site data... is_admin(&local_user_view)?; validate_create_payload(&local_site, &data)?; let ap_id: DbUrl = Url::parse(&context.settings().get_protocol_and_hostname())?.into(); let inbox_url = Some(generate_inbox_url()?); let keypair = generate_actor_keypair()?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let sidebar = process_markdown_opt( &data.sidebar, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; let suggested_multi_community_id = diesel_opt_number_update(data.suggested_multi_community_id.map(|id| id.0)) .map(|id| id.map(MultiCommunityId)); let site_form = SiteUpdateForm { name: Some(data.name.clone()), sidebar: diesel_string_update(sidebar.as_deref()), summary: diesel_string_update(data.summary.as_deref()), ap_id: Some(ap_id), last_refreshed_at: Some(Utc::now()), inbox_url, private_key: Some(Some(keypair.private_key)), public_key: Some(keypair.public_key), content_warning: diesel_string_update(data.content_warning.as_deref()), ..Default::default() }; let site_id = local_site.site_id; Site::update(&mut context.pool(), site_id, &site_form).await?; let local_site_form = LocalSiteUpdateForm { // Set the site setup to true site_setup: Some(true), registration_mode: data.registration_mode, community_creation_admin_only: data.community_creation_admin_only, require_email_verification: data.require_email_verification, application_question: diesel_string_update(data.application_question.as_deref()), private_instance: data.private_instance, default_theme: data.default_theme.clone(), default_post_listing_type: data.default_post_listing_type, default_post_sort_type: data.default_post_sort_type, default_post_time_range_seconds: diesel_opt_number_update(data.default_post_time_range_seconds), default_comment_sort_type: data.default_comment_sort_type, reports_email_admins: data.reports_email_admins, legal_information: diesel_string_update(data.legal_information.as_deref()), application_email_admins: data.application_email_admins, updated_at: Some(Some(Utc::now())), slur_filter_regex: diesel_string_update(data.slur_filter_regex.as_deref()), federation_enabled: data.federation_enabled, default_post_listing_mode: data.default_post_listing_mode, post_upvotes: data.post_upvotes, post_downvotes: data.post_downvotes, comment_upvotes: data.comment_upvotes, comment_downvotes: data.comment_downvotes, disallow_nsfw_content: data.disallow_nsfw_content, disable_email_notifications: data.disable_email_notifications, suggested_multi_community_id, federation_signed_fetch: data.federation_signed_fetch, oauth_registration: data.oauth_registration, default_items_per_page: data.default_items_per_page, image_mode: data.image_mode, image_proxy_bypass_domains: diesel_string_update(data.image_proxy_bypass_domains.as_deref()), image_upload_timeout_seconds: data.image_upload_timeout_seconds, image_max_thumbnail_size: data.image_max_thumbnail_size, image_max_avatar_size: data.image_max_avatar_size, image_max_banner_size: data.image_max_banner_size, image_max_upload_size: data.image_max_upload_size, image_allow_video_uploads: data.image_allow_video_uploads, image_upload_disabled: data.image_upload_disabled, }; LocalSite::update(&mut context.pool(), &local_site_form).await?; let local_site_rate_limit_form = LocalSiteRateLimitUpdateForm { message_max_requests: data.rate_limit_message_max_requests, message_interval_seconds: not_zero(data.rate_limit_message_interval_seconds), post_max_requests: data.rate_limit_post_max_requests, post_interval_seconds: not_zero(data.rate_limit_post_interval_seconds), register_max_requests: data.rate_limit_register_max_requests, register_interval_seconds: not_zero(data.rate_limit_register_interval_seconds), image_max_requests: data.rate_limit_image_max_requests, image_interval_seconds: not_zero(data.rate_limit_image_interval_seconds), comment_max_requests: data.rate_limit_comment_max_requests, comment_interval_seconds: not_zero(data.rate_limit_comment_interval_seconds), search_max_requests: data.rate_limit_search_max_requests, search_interval_seconds: not_zero(data.rate_limit_search_interval_seconds), import_user_settings_max_requests: data.rate_limit_import_user_settings_max_requests, import_user_settings_interval_seconds: not_zero( data.rate_limit_import_user_settings_interval_seconds, ), updated_at: Some(Some(Utc::now())), }; LocalSiteRateLimit::update(&mut context.pool(), &local_site_rate_limit_form).await?; let site_view = SiteView::read_local(&mut context.pool()).await?; let rate_limit_config = local_site_rate_limit_to_rate_limit_config(&site_view.local_site_rate_limit); context.rate_limit_cell().set_config(rate_limit_config); Ok(Json(SiteResponse { site_view })) } fn validate_create_payload(local_site: &LocalSite, create_site: &CreateSite) -> LemmyResult<()> { // Make sure the site hasn't already been set up... if local_site.site_setup { return Err(LemmyErrorType::AlreadyExists.into()); }; // Check that the slur regex compiles, and returns the regex if valid... // Prioritize using new slur regex from the request; if not provided, use the existing regex. let slur_regex = build_and_check_regex( create_site .slur_filter_regex .as_deref() .or(local_site.slur_filter_regex.as_deref()), )?; site_name_length_check(&create_site.name)?; check_slurs(&create_site.name, &slur_regex)?; if let Some(desc) = &create_site.summary { summary_length_check(desc)?; check_slurs(desc, &slur_regex)?; } site_default_post_listing_type_check(&create_site.default_post_listing_type)?; // Ensure that the sidebar has fewer than the max num characters... if let Some(sidebar) = &create_site.sidebar { is_valid_body_field(sidebar, false)?; } application_question_check( &local_site.application_question, &create_site.application_question, create_site .registration_mode .unwrap_or(local_site.registration_mode), ) } #[cfg(test)] mod tests { use crate::site::create::validate_create_payload; use lemmy_db_schema::source::local_site::LocalSite; use lemmy_db_schema_file::enums::{ListingType, PostSortType, RegistrationMode}; use lemmy_db_views_site::api::CreateSite; use lemmy_utils::error::LemmyErrorType; #[test] fn test_validate_invalid_create_payload() { let invalid_payloads = [ ( "CreateSite attempted on set up LocalSite", &LemmyErrorType::AlreadyExists, &LocalSite { site_setup: true, private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("site_name"), ..Default::default() }, ), ( "CreateSite name matches LocalSite slur filter", &LemmyErrorType::Slurs, &LocalSite { site_setup: false, private_instance: true, slur_filter_regex: Some(String::from("(foo|bar)")), federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("foo site_name"), ..Default::default() }, ), ( "CreateSite name matches new slur filter", &LemmyErrorType::Slurs, &LocalSite { site_setup: false, private_instance: true, slur_filter_regex: Some(String::from("(foo|bar)")), federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("zeta site_name"), slur_filter_regex: Some(String::from("(zeta|alpha)")), ..Default::default() }, ), ( "CreateSite listing type is Subscribed, which is invalid", &LemmyErrorType::InvalidDefaultPostListingType, &LocalSite { site_setup: false, private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("site_name"), default_post_listing_type: Some(ListingType::Subscribed), ..Default::default() }, ), ( "CreateSite requires application, but neither it nor LocalSite has an application question", &LemmyErrorType::ApplicationQuestionRequired, &LocalSite { site_setup: false, private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("site_name"), registration_mode: Some(RegistrationMode::RequireApplication), ..Default::default() }, ), ]; invalid_payloads.iter().enumerate().for_each( |( idx, &(reason, expected_err, local_site, create_site), )| { match validate_create_payload( local_site, create_site, ) { Ok(_) => { panic!( "Got Ok, but validation should have failed with error: {} for reason: {}. invalid_payloads.nth({})", expected_err, reason, idx ) } Err(error) => { assert!( error.error_type.eq(&expected_err.clone()), "Got Err {:?}, but should have failed with message: {} for reason: {}. invalid_payloads.nth({})", error.error_type, expected_err, reason, idx ) } } }, ); } #[test] fn test_validate_valid_create_payload() { let valid_payloads = [ ( "No changes between LocalSite and CreateSite", &LocalSite { site_setup: false, private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("site_name"), ..Default::default() }, ), ( "CreateSite allows clearing and changing values", &LocalSite { site_setup: false, private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("site_name"), sidebar: Some(String::new()), summary: Some(String::new()), application_question: Some(String::new()), private_instance: Some(false), default_post_listing_type: Some(ListingType::All), default_post_sort_type: Some(PostSortType::Active), slur_filter_regex: Some(String::new()), federation_enabled: Some(true), registration_mode: Some(RegistrationMode::Open), ..Default::default() }, ), ( "CreateSite clears existing slur filter regex", &LocalSite { site_setup: false, private_instance: true, slur_filter_regex: Some(String::from("(foo|bar)")), federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("foo site_name"), slur_filter_regex: Some(String::new()), ..Default::default() }, ), ( "LocalSite has application question and CreateSite now requires applications,", &LocalSite { site_setup: false, application_question: Some(String::from("question")), private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &CreateSite { name: String::from("site_name"), registration_mode: Some(RegistrationMode::RequireApplication), ..Default::default() }, ), ]; valid_payloads .iter() .enumerate() .for_each(|(idx, &(reason, local_site, edit_site))| { assert!( validate_create_payload(local_site, edit_site).is_ok(), "Got Err, but should have got Ok for reason: {}. valid_payloads.nth({})", reason, idx ); }) } } ================================================ FILE: crates/api/api_crud/src/site/mod.rs ================================================ use lemmy_db_schema_file::enums::{ListingType, RegistrationMode}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub mod create; pub mod read; pub mod update; /// Checks whether the default post listing type is valid for a site. pub fn site_default_post_listing_type_check( default_post_listing_type: &Option, ) -> LemmyResult<()> { if let Some(listing_type) = default_post_listing_type { // Dont allow Subscribed or ModeratorView as default listing type if [ListingType::Subscribed, ListingType::ModeratorView].contains(listing_type) { Err(LemmyErrorType::InvalidDefaultPostListingType.into()) } else { Ok(()) } } else { Ok(()) } } /// Checks whether the application question and registration mode align. pub fn application_question_check( current_application_question: &Option, new_application_question: &Option, registration_mode: RegistrationMode, ) -> LemmyResult<()> { let has_no_question: bool = current_application_question.is_none() && new_application_question.is_none(); let is_nullifying_question: bool = new_application_question == &Some(String::new()); if registration_mode == RegistrationMode::RequireApplication && (has_no_question || is_nullifying_question) { Err(LemmyErrorType::ApplicationQuestionRequired.into()) } else { Ok(()) } } fn not_zero(val: Option) -> Option { match val { Some(0) => None, v => v, } } #[cfg(test)] mod tests { use crate::site::{application_question_check, not_zero, site_default_post_listing_type_check}; use lemmy_db_schema_file::enums::{ListingType, RegistrationMode}; #[test] fn test_site_default_post_listing_type_check() { assert!(site_default_post_listing_type_check(&None::).is_ok()); assert!(site_default_post_listing_type_check(&Some(ListingType::All)).is_ok()); assert!(site_default_post_listing_type_check(&Some(ListingType::Local)).is_ok()); assert!(site_default_post_listing_type_check(&Some(ListingType::Subscribed)).is_err()); } #[test] fn test_application_question_check() { assert!( application_question_check( &Some(String::from("q")), &Some(String::new()), RegistrationMode::RequireApplication ) .is_err(), "Expected application to be invalid because an application is required, current question: {:?}, new question: {:?}", "q", String::new(), ); assert!( application_question_check(&None, &None, RegistrationMode::RequireApplication).is_err(), "Expected application to be invalid because an application is required, current question: {:?}, new question: {:?}", None::, None:: ); assert!( application_question_check(&None, &None, RegistrationMode::Open).is_ok(), "Expected application to be valid because no application required, current question: {:?}, new question: {:?}, mode: {:?}", None::, None::, RegistrationMode::Open ); assert!( application_question_check( &None, &Some(String::from("q")), RegistrationMode::RequireApplication ) .is_ok(), "Expected application to be valid because new application provided, current question: {:?}, new question: {:?}, mode: {:?}", None::, Some(String::from("q")), RegistrationMode::RequireApplication ); assert!( application_question_check( &Some(String::from("q")), &None, RegistrationMode::RequireApplication ) .is_ok(), "Expected application to be valid because application existed, current question: {:?}, new question: {:?}, mode: {:?}", Some(String::from("q")), None::, RegistrationMode::RequireApplication ); } #[test] fn test_not_zero() { assert_eq!(None, not_zero(None)); assert_eq!(None, not_zero(Some(0))); assert_eq!(Some(5), not_zero(Some(5))); } } ================================================ FILE: crates/api/api_crud/src/site/read.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{ context::LemmyContext, plugins::{is_captcha_plugin_loaded, plugin_metadata}, }; use lemmy_db_schema::source::{ actor_language::SiteLanguage, language::Language, local_site_url_blocklist::LocalSiteUrlBlocklist, oauth_provider::AdminOAuthProvider, registration_application::RegistrationApplication, tagline::Tagline, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::PersonView; use lemmy_db_views_site::{SiteView, api::GetSiteResponse}; use lemmy_utils::{CacheLock, VERSION, build_cache, error::LemmyResult}; use std::sync::LazyLock; pub async fn get_site( local_user_view: Option, context: Data, ) -> LemmyResult> { // This data is independent from the user account so we can cache it across requests static CACHE: CacheLock = LazyLock::new(build_cache); let mut site_response = Box::pin(CACHE.try_get_with((), read_site(&context))) .await .map_err(|e| anyhow::anyhow!("Failed to construct site response: {e}"))?; // filter oauth_providers for public access if !local_user_view .map(|l| l.local_user.admin) .unwrap_or_default() { site_response.admin_oauth_providers = vec![]; } Ok(Json(site_response)) } async fn read_site(context: &LemmyContext) -> LemmyResult { let site_view = SiteView::read_local(&mut context.pool()).await?; let admins = PersonView::list_admins(None, site_view.instance.id, &mut context.pool()).await?; let all_languages = Language::read_all(&mut context.pool()).await?; let discussion_languages = SiteLanguage::read_local_raw(&mut context.pool()).await?; let blocked_urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?; let tagline = Tagline::get_random(&mut context.pool()).await.ok(); let admin_oauth_providers = AdminOAuthProvider::get_all(&mut context.pool()).await?; let oauth_providers = AdminOAuthProvider::convert_providers_to_public(admin_oauth_providers.clone()); let last_application_duration_seconds = RegistrationApplication::last_updated(&mut context.pool()) .await .ok() .and_then(|u| u.updated_published_duration()); Ok(GetSiteResponse { site_view, admins, version: VERSION.to_string(), all_languages, discussion_languages, blocked_urls, tagline, oauth_providers, admin_oauth_providers, active_plugins: plugin_metadata(), last_application_duration_seconds, captcha_enabled: is_captcha_plugin_loaded(), }) } ================================================ FILE: crates/api/api_crud/src/site/update.rs ================================================ use super::not_zero; use crate::site::{application_question_check, site_default_post_listing_type_check}; use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, utils::{ get_url_blocklist, is_admin, local_site_rate_limit_to_rate_limit_config, process_markdown_opt, slur_regex, }, }; use lemmy_db_schema::{ newtypes::MultiCommunityId, source::{ actor_language::SiteLanguage, local_site::{LocalSite, LocalSiteUpdateForm}, local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitUpdateForm}, local_site_url_blocklist::LocalSiteUrlBlocklist, local_user::LocalUser, site::{Site, SiteUpdateForm}, }, }; use lemmy_db_schema_file::enums::RegistrationMode; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{EditSite, SiteResponse}, }; use lemmy_diesel_utils::{ traits::Crud, utils::{diesel_opt_number_update, diesel_string_update}, }; use lemmy_utils::{ error::LemmyResult, utils::{ slurs::check_slurs_opt, validation::{ build_and_check_regex, check_urls_are_valid, is_valid_body_field, site_name_length_check, summary_length_check, }, }, }; pub async fn edit_site( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_site = site_view.local_site; let site = site_view.site; // Make sure user is an admin; other types of users should not update site data... is_admin(&local_user_view)?; validate_update_payload(&local_site, &data)?; if let Some(discussion_languages) = data.discussion_languages.clone() { SiteLanguage::update(&mut context.pool(), discussion_languages.clone(), &site).await?; } let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let sidebar = diesel_string_update( process_markdown_opt( &data.sidebar, &slur_regex, &url_blocklist, &local_site, &context, ) .await? .as_deref(), ); let default_post_time_range_seconds = diesel_opt_number_update(data.default_post_time_range_seconds); let default_items_per_page = data.default_items_per_page; let suggested_multi_community_id = diesel_opt_number_update(data.suggested_multi_community_id.map(|id| id.0)) .map(|id| id.map(MultiCommunityId)); let site_form = SiteUpdateForm { name: data.name.clone(), sidebar, summary: diesel_string_update(data.summary.as_deref()), content_warning: diesel_string_update(data.content_warning.as_deref()), updated_at: Some(Some(Utc::now())), ..Default::default() }; Site::update(&mut context.pool(), site.id, &site_form) .await // Ignore errors for all these, so as to not throw errors if no update occurs // Diesel will throw an error for empty update forms .ok(); let local_site_form = LocalSiteUpdateForm { site_setup: None, federation_signed_fetch: data.federation_signed_fetch, registration_mode: data.registration_mode, community_creation_admin_only: data.community_creation_admin_only, require_email_verification: data.require_email_verification, application_question: diesel_string_update(data.application_question.as_deref()), private_instance: data.private_instance, default_theme: data.default_theme.clone(), default_post_listing_type: data.default_post_listing_type, default_post_sort_type: data.default_post_sort_type, default_post_time_range_seconds, default_items_per_page, default_comment_sort_type: data.default_comment_sort_type, legal_information: diesel_string_update(data.legal_information.as_deref()), application_email_admins: data.application_email_admins, updated_at: Some(Some(Utc::now())), slur_filter_regex: diesel_string_update(data.slur_filter_regex.as_deref()), federation_enabled: data.federation_enabled, reports_email_admins: data.reports_email_admins, default_post_listing_mode: data.default_post_listing_mode, oauth_registration: data.oauth_registration, post_upvotes: data.post_upvotes, post_downvotes: data.post_downvotes, comment_upvotes: data.comment_upvotes, comment_downvotes: data.comment_downvotes, disallow_nsfw_content: data.disallow_nsfw_content, disable_email_notifications: data.disable_email_notifications, suggested_multi_community_id, image_mode: data.image_mode, image_proxy_bypass_domains: diesel_string_update(data.image_proxy_bypass_domains.as_deref()), image_upload_timeout_seconds: data.image_upload_timeout_seconds, image_max_thumbnail_size: data.image_max_thumbnail_size, image_max_avatar_size: data.image_max_avatar_size, image_max_banner_size: data.image_max_banner_size, image_max_upload_size: data.image_max_upload_size, image_allow_video_uploads: data.image_allow_video_uploads, image_upload_disabled: data.image_upload_disabled, }; let update_local_site = LocalSite::update(&mut context.pool(), &local_site_form) .await .ok(); let local_site_rate_limit_form = LocalSiteRateLimitUpdateForm { message_max_requests: data.rate_limit_message_max_requests, message_interval_seconds: not_zero(data.rate_limit_message_interval_seconds), post_max_requests: data.rate_limit_post_max_requests, post_interval_seconds: not_zero(data.rate_limit_post_interval_seconds), register_max_requests: data.rate_limit_register_max_requests, register_interval_seconds: not_zero(data.rate_limit_register_interval_seconds), image_max_requests: data.rate_limit_image_max_requests, image_interval_seconds: not_zero(data.rate_limit_image_interval_seconds), comment_max_requests: data.rate_limit_comment_max_requests, comment_interval_seconds: not_zero(data.rate_limit_comment_interval_seconds), search_max_requests: data.rate_limit_search_max_requests, search_interval_seconds: not_zero(data.rate_limit_search_interval_seconds), import_user_settings_max_requests: data.rate_limit_import_user_settings_max_requests, import_user_settings_interval_seconds: not_zero( data.rate_limit_import_user_settings_interval_seconds, ), updated_at: Some(Some(Utc::now())), }; LocalSiteRateLimit::update(&mut context.pool(), &local_site_rate_limit_form) .await .ok(); if let Some(url_blocklist) = data.blocked_urls.clone() { // If this validation changes it must be synced with // lemmy_utils::utils::markdown::create_url_blocklist_test_regex_set. let parsed_urls = check_urls_are_valid(&url_blocklist)?; LocalSiteUrlBlocklist::replace(&mut context.pool(), parsed_urls).await?; } // TODO can't think of a better way to do this. // If the server suddenly requires email verification, or required applications, no old users // will be able to log in. It really only wants this to be a requirement for NEW signups. // So if it was set from false, to true, you need to update all current users columns to be // verified. let old_require_application = local_site.registration_mode == RegistrationMode::RequireApplication; let new_require_application = update_local_site .as_ref() .map(|ols| ols.registration_mode == RegistrationMode::RequireApplication) .unwrap_or(false); if !old_require_application && new_require_application { LocalUser::set_all_users_registration_applications_accepted(&mut context.pool()).await?; } let new_require_email_verification = update_local_site .as_ref() .map(|ols| ols.require_email_verification) .unwrap_or(false); if !local_site.require_email_verification && new_require_email_verification { LocalUser::set_all_users_email_verified(&mut context.pool()).await?; } let site_view = SiteView::read_local(&mut context.pool()).await?; let rate_limit_config = local_site_rate_limit_to_rate_limit_config(&site_view.local_site_rate_limit); context.rate_limit_cell().set_config(rate_limit_config); Ok(Json(SiteResponse { site_view })) } fn validate_update_payload(local_site: &LocalSite, edit_site: &EditSite) -> LemmyResult<()> { // Check that the slur regex compiles, and return the regex if valid... // Prioritize using new slur regex from the request; if not provided, use the existing regex. let slur_regex = build_and_check_regex( edit_site .slur_filter_regex .as_deref() .or(local_site.slur_filter_regex.as_deref()), )?; if let Some(name) = &edit_site.name { // The name doesn't need to be updated, but if provided it cannot be blanked out... site_name_length_check(name)?; check_slurs_opt(&edit_site.name, &slur_regex)?; } if let Some(summary) = &edit_site.summary { summary_length_check(summary)?; check_slurs_opt(&edit_site.summary, &slur_regex)?; } site_default_post_listing_type_check(&edit_site.default_post_listing_type)?; // Ensure that the sidebar has fewer than the max num characters... if let Some(sidebar) = &edit_site.sidebar { is_valid_body_field(sidebar, false)?; } application_question_check( &local_site.application_question, &edit_site.application_question, edit_site .registration_mode .unwrap_or(local_site.registration_mode), ) } #[cfg(test)] mod tests { use crate::site::update::validate_update_payload; use lemmy_db_schema::source::local_site::LocalSite; use lemmy_db_schema_file::enums::{ListingType, PostSortType, RegistrationMode}; use lemmy_db_views_site::api::EditSite; use lemmy_utils::error::LemmyErrorType; #[test] fn test_validate_invalid_update_payload() { let invalid_payloads = [ ( "EditSite name matches LocalSite slur filter", &LemmyErrorType::Slurs, &LocalSite { private_instance: true, slur_filter_regex: Some(String::from("(foo|bar)")), federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite { name: Some(String::from("foo site_name")), ..Default::default() }, ), ( "EditSite name matches new slur filter", &LemmyErrorType::Slurs, &LocalSite { private_instance: true, slur_filter_regex: Some(String::from("(foo|bar)")), federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite { name: Some(String::from("zeta site_name")), slur_filter_regex: Some(String::from("(zeta|alpha)")), ..Default::default() }, ), ( "EditSite listing type is Subscribed, which is invalid", &LemmyErrorType::InvalidDefaultPostListingType, &LocalSite { private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite { name: Some(String::from("site_name")), default_post_listing_type: Some(ListingType::Subscribed), ..Default::default() }, ), ( "EditSite requires application, but neither it nor LocalSite has an application question", &LemmyErrorType::ApplicationQuestionRequired, &LocalSite { private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite { name: Some(String::from("site_name")), registration_mode: Some(RegistrationMode::RequireApplication), ..Default::default() }, ), ]; invalid_payloads.iter().enumerate().for_each( |( idx, &(reason, expected_err, local_site, edit_site), )| { match validate_update_payload(local_site, edit_site) { Ok(_) => { panic!( "Got Ok, but validation should have failed with error: {} for reason: {}. invalid_payloads.nth({})", expected_err, reason, idx ) } Err(error) => { assert!( error.error_type.eq(&expected_err.clone()), "Got Err {:?}, but should have failed with message: {} for reason: {}. invalid_payloads.nth({})", error.error_type, expected_err, reason, idx ) } } }, ); } #[test] fn test_validate_valid_update_payload() { let valid_payloads = [ ( "No changes between LocalSite and EditSite", &LocalSite { private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite::default(), ), ( "EditSite allows clearing and changing values", &LocalSite { private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite { name: Some(String::from("site_name")), sidebar: Some(String::new()), summary: Some(String::new()), application_question: Some(String::new()), private_instance: Some(false), default_post_listing_type: Some(ListingType::All), default_post_sort_type: Some(PostSortType::Active), slur_filter_regex: Some(String::new()), registration_mode: Some(RegistrationMode::Open), federation_enabled: Some(true), ..Default::default() }, ), ( "EditSite name passes slur filter regex", &LocalSite { private_instance: true, slur_filter_regex: Some(String::from("(foo|bar)")), registration_mode: RegistrationMode::Open, federation_enabled: false, ..Default::default() }, &EditSite { name: Some(String::from("foo site_name")), slur_filter_regex: Some(String::new()), ..Default::default() }, ), ( "LocalSite has application question and EditSite now requires applications,", &LocalSite { application_question: Some(String::from("question")), private_instance: true, federation_enabled: false, registration_mode: RegistrationMode::Open, ..Default::default() }, &EditSite { name: Some(String::from("site_name")), registration_mode: Some(RegistrationMode::RequireApplication), ..Default::default() }, ), ]; valid_payloads .iter() .enumerate() .for_each(|(idx, &(reason, local_site, edit_site))| { assert!( validate_update_payload(local_site, edit_site).is_ok(), "Got Err, but should have got Ok for reason: {}. valid_payloads.nth({})", reason, idx ); }) } } ================================================ FILE: crates/api/api_crud/src/tagline/create.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, utils::{get_url_blocklist, is_admin, process_markdown, slur_regex}, }; use lemmy_db_schema::source::tagline::{Tagline, TaglineInsertForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{CreateTagline, TaglineResponse}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyError; pub async fn create_tagline( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { // Make sure user is an admin is_admin(&local_user_view)?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let content = process_markdown( &data.content, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; let tagline_form = TaglineInsertForm { content }; let tagline = Tagline::create(&mut context.pool(), &tagline_form).await?; Ok(Json(TaglineResponse { tagline })) } ================================================ FILE: crates/api/api_crud/src/tagline/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_db_schema::source::tagline::Tagline; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::{DeleteTagline, SuccessResponse}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyError; pub async fn delete_tagline( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { // Make sure user is an admin is_admin(&local_user_view)?; Tagline::delete(&mut context.pool(), data.id).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api_crud/src/tagline/list.rs ================================================ use actix_web::web::{Data, Json, Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::tagline::Tagline; use lemmy_db_views_site::api::ListTaglines; use lemmy_diesel_utils::pagination::PagedResponse; use lemmy_utils::error::LemmyError; pub async fn list_taglines( Query(data): Query, context: Data, ) -> Result>, LemmyError> { let taglines = Tagline::list(&mut context.pool(), data.page_cursor, data.limit).await?; Ok(Json(taglines)) } ================================================ FILE: crates/api/api_crud/src/tagline/mod.rs ================================================ pub mod create; pub mod delete; pub mod list; pub mod update; ================================================ FILE: crates/api/api_crud/src/tagline/update.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, utils::{get_url_blocklist, is_admin, process_markdown, slur_regex}, }; use lemmy_db_schema::source::tagline::{Tagline, TaglineUpdateForm}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{ SiteView, api::{EditTagline, TaglineResponse}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyError; pub async fn edit_tagline( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> Result, LemmyError> { // Make sure user is an admin is_admin(&local_user_view)?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let content = process_markdown( &data.content, &slur_regex, &url_blocklist, &local_site, &context, ) .await?; let tagline_form = TaglineUpdateForm { content, updated_at: Some(Some(Utc::now())), }; let tagline = Tagline::update(&mut context.pool(), data.id, &tagline_form).await?; Ok(Json(TaglineResponse { tagline })) } ================================================ FILE: crates/api/api_crud/src/user/create.rs ================================================ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, http_signatures::generate_actor_keypair, }; use actix_web::{HttpRequest, rt::time::sleep, web::Json}; use diesel_async::{AsyncPgConnection, scoped_futures::ScopedFutureExt}; use lemmy_api_utils::{ claims::Claims, context::LemmyContext, plugins::{is_captcha_plugin_loaded, plugin_validate_captcha}, utils::{ check_email_verified, check_local_user_valid, check_registration_application, generate_featured_url, generate_followers_url, generate_inbox_url, generate_moderators_url, honeypot_check, password_length_check, slur_regex, }, }; use lemmy_apub_objects::objects::community::ApubCommunity; use lemmy_db_schema::{ newtypes::OAuthProviderId, source::{ actor_language::SiteLanguage, community::{Community, CommunityActions, CommunityInsertForm, CommunityModeratorForm}, language::Language, local_site::LocalSite, local_user::{LocalUser, LocalUserInsertForm}, oauth_account::{OAuthAccount, OAuthAccountInsertForm}, oauth_provider::AdminOAuthProvider, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm}, registration_application::{RegistrationApplication, RegistrationApplicationInsertForm}, }, traits::{ApubActor, Likeable}, }; use lemmy_db_schema_file::enums::RegistrationMode; use lemmy_db_views_community::CommunityView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::PersonView; use lemmy_db_views_registration_applications::api::Register; use lemmy_db_views_site::{ SiteView, api::{AuthenticateWithOauth, LoginResponse}, }; use lemmy_diesel_utils::{connection::get_conn, pagination::PagedResponse, traits::Crud}; use lemmy_email::{ account::send_verification_email_if_required, admin::send_new_applicant_email_to_admins, user_language, }; use lemmy_utils::{ error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult}, spawn_try_task, utils::{ slurs::{check_slurs, check_slurs_opt}, validation::is_valid_actor_name, }, }; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::{collections::HashSet, sync::LazyLock, time::Duration}; use tracing::info; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] /// Response from OAuth token endpoint struct TokenResponse { pub access_token: String, pub token_type: String, pub expires_in: Option, pub refresh_token: Option, pub scope: Option, } pub async fn register( Json(data): Json, req: HttpRequest, context: Data, ) -> LemmyResult> { let pool = &mut context.pool(); let site_view = SiteView::read_local(pool).await?; let local_site = site_view.local_site.clone(); let require_registration_application = local_site.registration_mode == RegistrationMode::RequireApplication; if local_site.registration_mode == RegistrationMode::Closed { return Err(LemmyErrorType::RegistrationClosed.into()); } password_length_check(&data.password)?; honeypot_check(&data.honeypot)?; if local_site.require_email_verification && data.email.is_none() { return Err(LemmyErrorType::EmailRequired.into()); } // make sure the registration answer is provided when the registration application is required if local_site.site_setup { validate_registration_answer(require_registration_application, &data.answer)?; } // Make sure passwords match if data.password != data.password_verify { return Err(LemmyErrorType::PasswordsDoNotMatch.into()); } if local_site.site_setup && is_captcha_plugin_loaded() { let answer = data.captcha_answer.clone().unwrap_or_default(); let uuid = data.captcha_uuid.clone().unwrap_or_default(); plugin_validate_captcha(answer, uuid).await?; } let slur_regex = slur_regex(&context).await?; check_slurs(&data.username, &slur_regex)?; check_slurs_opt(&data.answer, &slur_regex)?; Person::check_username_taken(pool, &data.username).await?; if let Some(email) = &data.email { LocalUser::check_is_email_taken(pool, email).await?; } // Automatically set their application as accepted, if they created this with open registration. // Also fixes a bug which allows users to log in when registrations are changed to closed. let accepted_application = Some(!require_registration_application); // Show nsfw content if param is true, or if content_warning exists let show_nsfw = data .show_nsfw .unwrap_or(site_view.site.content_warning.is_some()); let language_tags = get_language_tags(&req); // Wrap the insert person, insert local user, and create registration, // in a transaction, so that if any fail, the rows aren't created. let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); let tx_context = context.clone(); let user = conn .run_transaction(|conn| { async move { // We have to create both a person, and local_user let person = create_person(tx_data.username.clone(), &site_view, &tx_context, conn).await?; // Create the local user let local_user_form = LocalUserInsertForm { email: tx_data.email.as_deref().map(str::to_lowercase), show_nsfw: Some(show_nsfw), accepted_application, ..LocalUserInsertForm::new(person.id, Some(tx_data.password.to_string())) }; let local_user = create_local_user( conn, language_tags, local_user_form, &site_view.local_site, &tx_context, ) .await?; if site_view.local_site.site_setup && require_registration_application && let Some(answer) = tx_data.answer.clone() { // Create the registration application let form = RegistrationApplicationInsertForm { local_user_id: local_user.id, answer, }; RegistrationApplication::create(&mut conn.into(), &form).await?; } Ok(LocalUserView { person, local_user, banned: false, ban_expires_at: None, }) } .scope_boxed() }) .await?; // Email the admins, only if email verification is not required if local_site.application_email_admins && !local_site.require_email_verification { send_new_applicant_email_to_admins(&data.username, pool, context.settings()).await?; } let mut login_response = LoginResponse { jwt: None, registration_created: false, verify_email_sent: false, }; // Log the user in directly if the site is not setup, or email verification and application aren't // required if !local_site.site_setup || (!require_registration_application && !local_site.require_email_verification) { let jwt = Claims::generate(user.local_user.id, data.stay_logged_in, req, &context).await?; login_response.jwt = Some(jwt); } else { login_response.verify_email_sent = send_verification_email_if_required( &local_site, &user, &mut context.pool(), context.settings(), ) .await?; if require_registration_application { login_response.registration_created = true; } } Ok(Json(login_response)) } pub async fn authenticate_with_oauth( Json(data): Json, req: HttpRequest, context: Data, ) -> LemmyResult> { let pool = &mut context.pool(); let site_view = SiteView::read_local(pool).await?; let local_site = site_view.local_site.clone(); // Show nsfw content if param is true, or if content_warning exists let show_nsfw = data .show_nsfw .unwrap_or(site_view.site.content_warning.is_some()); let language_tags = get_language_tags(&req); // validate inputs if data.oauth_provider_id == OAuthProviderId(0) || data.code.is_empty() || data.code.len() > 300 { return Err(LemmyErrorType::OauthAuthorizationInvalid.into()); } // validate the redirect_uri let redirect_uri = &data.redirect_uri; if redirect_uri.host_str().unwrap_or("").is_empty() || !redirect_uri.path().eq(&String::from("/oauth/callback")) || !redirect_uri.query().unwrap_or("").is_empty() { return Err(LemmyErrorType::OauthAuthorizationInvalid.into()); } // validate the PKCE challenge if let Some(code_verifier) = &data.pkce_code_verifier { check_code_verifier(code_verifier)?; } // Fetch the OAUTH provider and make sure it's enabled let oauth_provider_id = data.oauth_provider_id; let oauth_provider = AdminOAuthProvider::read(pool, oauth_provider_id) .await .ok() .ok_or(LemmyErrorType::OauthAuthorizationInvalid)?; if !oauth_provider.enabled { return Err(LemmyErrorType::OauthAuthorizationInvalid.into()); } let token_response = oauth_request_access_token( &context, &oauth_provider, &data.code, data.pkce_code_verifier.as_deref(), redirect_uri.as_str(), ) .await?; let user_info = oidc_get_user_info( &context, &oauth_provider, token_response.access_token.as_str(), ) .await?; let oauth_user_id = read_user_info(&user_info, oauth_provider.id_claim.as_str())?; let require_registration_application = local_site.registration_mode == RegistrationMode::RequireApplication; let mut login_response = LoginResponse { jwt: None, registration_created: false, verify_email_sent: false, }; // Lookup user by oauth_user_id let mut local_user_view = LocalUserView::find_by_oauth_id(pool, oauth_provider.id, &oauth_user_id).await; let local_user = if let Ok(user_view) = local_user_view { // user found by oauth_user_id => Login user let local_user = user_view.clone().local_user; login_response.registration_created = local_site.site_setup && require_registration_application && !local_user.accepted_application && !local_user.admin && data.answer.is_some(); check_local_user_valid(&user_view)?; check_email_verified(&user_view, &site_view)?; check_registration_application(&user_view, &site_view.local_site, pool).await?; local_user } else { // user has never previously registered using oauth // prevent registration if registration is closed if local_site.registration_mode == RegistrationMode::Closed { return Err(LemmyErrorType::RegistrationClosed.into()); } // prevent registration if registration is closed for OAUTH providers if !local_site.oauth_registration { return Err(LemmyErrorType::OauthRegistrationClosed.into()); } // Extract the OAUTH email claim from the returned user_info let email = read_user_info(&user_info, "email")?; // Lookup user by OAUTH email and link accounts local_user_view = LocalUserView::find_by_email(pool, &email).await; if let Ok(user_view) = local_user_view { // user found by email => link and login if linking is allowed // we only allow linking by email when email_verification is required otherwise emails cannot // be trusted if oauth_provider.account_linking_enabled && site_view.local_site.require_email_verification { // WARNING: // If an admin switches the require_email_verification config from false to true, // users who signed up before the switch could have accounts with unverified emails falsely // marked as verified. check_local_user_valid(&user_view)?; check_email_verified(&user_view, &site_view)?; check_registration_application(&user_view, &site_view.local_site, pool).await?; // Link with OAUTH => Login user let oauth_account_form = OAuthAccountInsertForm::new(user_view.local_user.id, oauth_provider.id, oauth_user_id); OAuthAccount::create(pool, &oauth_account_form).await?; user_view.local_user.clone() } else { return Err(LemmyErrorType::EmailAlreadyTaken.into()); } } else { // No user was found by email => Register as new user // make sure the registration answer is provided when the registration application is required validate_registration_answer(require_registration_application, &data.answer)?; let slur_regex = slur_regex(&context).await?; // Wrap the insert person, insert local user, and create registration, // in a transaction, so that if any fail, the rows aren't created. let conn = &mut get_conn(pool).await?; let tx_data = data.clone(); let tx_context = context.clone(); let user = conn .run_transaction(|conn| { async move { // make sure the username is provided let username = tx_data .username .as_ref() .ok_or(LemmyErrorType::RegistrationUsernameRequired)?; check_slurs(username, &slur_regex)?; check_slurs_opt(&tx_data.answer, &slur_regex)?; Person::check_username_taken(&mut conn.into(), username).await?; // We have to create a person, a local_user, and an oauth_account let person = create_person(username.clone(), &site_view, &tx_context, conn).await?; // Create the local user let local_user_form = LocalUserInsertForm { email: Some(str::to_lowercase(&email)), show_nsfw: Some(show_nsfw), accepted_application: Some(!require_registration_application), email_verified: Some(oauth_provider.auto_verify_email), ..LocalUserInsertForm::new(person.id, None) }; let local_user = create_local_user( conn, language_tags, local_user_form, &site_view.local_site, &tx_context, ) .await?; // Create the oauth account let oauth_account_form = OAuthAccountInsertForm::new(local_user.id, oauth_provider.id, oauth_user_id); OAuthAccount::create(&mut conn.into(), &oauth_account_form).await?; // prevent sign in until application is accepted if login_response.registration_created { // Create the registration application RegistrationApplication::create( &mut conn.into(), &RegistrationApplicationInsertForm { local_user_id: local_user.id, // We already check earlier that this Some, however using `ok_or` is cleaner // than unwrap or expect (which also requires clippy allow). answer: data .answer .ok_or(LemmyErrorType::RegistrationApplicationAnswerRequired)?, }, ) .await?; } Ok(LocalUserView { person, local_user, banned: false, ban_expires_at: None, }) } .scope_boxed() }) .await?; // Check email is verified when required login_response.verify_email_sent = send_verification_email_if_required( &local_site, &user, &mut context.pool(), context.settings(), ) .await?; user.local_user } }; if !login_response.registration_created && !login_response.verify_email_sent { let jwt = Claims::generate(local_user.id, data.stay_logged_in, req, &context).await?; login_response.jwt = Some(jwt); } Ok(Json(login_response)) } async fn create_person( username: String, site_view: &SiteView, context: &LemmyContext, conn: &mut AsyncPgConnection, ) -> Result { let actor_keypair = generate_actor_keypair()?; is_valid_actor_name(&username)?; let ap_id = Person::generate_local_actor_url(&username, context.settings())?; // Register the new person let person_form = PersonInsertForm { ap_id: Some(ap_id.clone()), inbox_url: Some(generate_inbox_url()?), private_key: Some(actor_keypair.private_key), ..PersonInsertForm::new( username.clone(), actor_keypair.public_key, site_view.site.instance_id, ) }; // insert the person let inserted_person = Person::create(&mut conn.into(), &person_form).await?; Ok(inserted_person) } fn get_language_tags(req: &HttpRequest) -> Vec { req .headers() .get("Accept-Language") .map(|hdr| accept_language::parse(hdr.to_str().unwrap_or_default())) .iter() .flatten() // Remove the optional region code .map(|lang_str| lang_str.split('-').next().unwrap_or_default().to_string()) .collect::>() } async fn create_local_user( conn: &mut AsyncPgConnection, language_tags: Vec, mut local_user_form: LocalUserInsertForm, local_site: &LocalSite, context: &Data, ) -> Result { let conn_ = &mut conn.into(); let all_languages = Language::read_all(conn_).await?; // use hashset to avoid duplicates let mut language_ids = HashSet::new(); // Enable site languages. Ignored if all languages are enabled. let discussion_languages = SiteLanguage::read(conn_, local_site.site_id).await?; // Enable languages from `Accept-Language` header only if no site languages are set. Otherwise it // is possible that browser languages are only set to e.g. French, and the user won't see any // English posts. if !discussion_languages.is_empty() { for l in &language_tags { if let Some(found) = all_languages.iter().find(|all| &all.code == l) { language_ids.insert(found.id); } } } language_ids.extend(discussion_languages); let language_ids = language_ids.into_iter().collect(); local_user_form.default_listing_type = Some(local_site.default_post_listing_type); local_user_form.post_listing_mode = Some(local_site.default_post_listing_mode); // If its the initial site setup, they are an admin local_user_form.admin = Some(!local_site.site_setup); local_user_form.interface_language = language_tags.first().cloned(); let inserted_local_user = LocalUser::create(conn_, &local_user_form, language_ids).await?; // If we are setting up a new site, fetch initial communities and create welcome post. if !local_site.site_setup { local_user_form.admin = Some(true); create_welcome_post(inserted_local_user.clone(), context); fetch_community_list(context.clone()); } Ok(inserted_local_user) } fn validate_registration_answer( require_registration_application: bool, answer: &Option, ) -> LemmyResult<()> { if require_registration_application && answer.is_none() { return Err(LemmyErrorType::RegistrationApplicationAnswerRequired.into()); } Ok(()) } async fn oauth_request_access_token( context: &Data, oauth_provider: &AdminOAuthProvider, code: &str, pkce_code_verifier: Option<&str>, redirect_uri: &str, ) -> LemmyResult { let mut form = vec![ ("client_id", &*oauth_provider.client_id), ("client_secret", &*oauth_provider.client_secret), ("code", code), ("grant_type", "authorization_code"), ("redirect_uri", redirect_uri), ]; if let Some(code_verifier) = pkce_code_verifier { form.push(("code_verifier", code_verifier)); } // Request an Access Token from the OAUTH provider let response = context .client() .post(oauth_provider.token_endpoint.as_str()) .header("Accept", "application/json") .form(&form[..]) .send() .await .with_lemmy_type(LemmyErrorType::OauthLoginFailed)? .error_for_status() .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?; // Extract the access token let token_response = response .json::() .await .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?; Ok(token_response) } async fn oidc_get_user_info( context: &Data, oauth_provider: &AdminOAuthProvider, access_token: &str, ) -> LemmyResult { // Request the user info from the OAUTH provider let response = context .client() .get(oauth_provider.userinfo_endpoint.as_str()) .header("Accept", "application/json") .bearer_auth(access_token) .send() .await .with_lemmy_type(LemmyErrorType::OauthLoginFailed)? .error_for_status() .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?; // Extract the OAUTH user_id claim from the returned user_info let user_info = response .json::() .await .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?; Ok(user_info) } fn read_user_info(user_info: &serde_json::Value, key: &str) -> LemmyResult { if let Some(value) = user_info.get(key) { let result = serde_json::from_value::(value.clone()) .with_lemmy_type(LemmyErrorType::OauthLoginFailed)?; return Ok(result); } Err(LemmyErrorType::OauthLoginFailed.into()) } #[expect(clippy::expect_used)] fn check_code_verifier(code_verifier: &str) -> LemmyResult<()> { static VALID_CODE_VERIFIER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-._~]{43,128}$").expect("compile regex")); let check = VALID_CODE_VERIFIER_REGEX.is_match(code_verifier); if check { Ok(()) } else { Err(LemmyErrorType::InvalidCodeVerifier.into()) } } fn fetch_community_list(context: Data) { // Only do this in release mode. if cfg!(debug_assertions) { //return; } spawn_try_task(async move { let instances = context .settings() .setup .clone() .unwrap_or_default() .bootstrap_instances; let mut communities: Vec> = vec![]; for i in instances { info!("Trying to fetch community list from {i}"); let res = context .client() .get(format!( "https://{i}/api/v4/community/list?type_=all&sort=active_monthly&limit=50" )) .send() .await; if let Ok(res) = res && let Ok(json) = res.json::>().await { communities = json .items .into_iter() // exclude nsfw .filter(|c| !c.community.nsfw) .map(|c| c.community.ap_id.into()) .collect(); info!("Successfully fetched community list from {i}"); break; } info!("Failed to fetch community list from {i}"); } // also prefetch these two communities as they are linked in the welcome post communities.insert(0, "https://lemmy.ml/c/announcements".parse()?); communities.insert(0, "https://lemmy.ml/c/lemmy".parse()?); // Fetch communities themselves let tasks = communities.iter().map(|c| async { let context = context.reset_request_count(); c.dereference(&context).await.ok(); }); // This could be made faster by running tasks in parallel with try_join_all or // FuturesUnordered. However that causes massive slowdown as each community fetch // starts additional background tasks to fetch moderators, recent posts etc. So we // need to run it one by one and sleep in between. for t in tasks { t.await; sleep(Duration::from_secs(1)).await; } Ok(()) }) } fn create_welcome_post(local_user: LocalUser, context: &LemmyContext) { let context = context.clone(); spawn_try_task(async move { let pool = &mut context.pool(); let site = SiteView::read_local(pool).await?; let admins = PersonView::list_admins(None, site.instance.id, &mut context.pool()).await?; let initial_user = admins.first(); let person = SiteView::read_system_account(&mut context.pool()).await?; // Create main community let community_name = "main".to_string(); let community_ap_id = Community::generate_local_actor_url(&community_name, context.settings())?; let keypair = generate_actor_keypair()?; let community_form = CommunityInsertForm { ap_id: Some(community_ap_id.clone()), private_key: Some(keypair.private_key), followers_url: Some(generate_followers_url(&community_ap_id)?), inbox_url: Some(generate_inbox_url()?), moderators_url: Some(generate_moderators_url(&community_ap_id)?), featured_url: Some(generate_featured_url(&community_ap_id)?), ..CommunityInsertForm::new( site.site.instance_id, community_name, "Main".to_string(), keypair.public_key, ) }; let community = Community::create(pool, &community_form).await?; // Add initial admin user as community mod (not necessary but looks cleaner) if let Some(initial_user) = initial_user { let mod_form = CommunityModeratorForm::new(community.id, initial_user.person.id); CommunityActions::join(pool, &mod_form).await?; } // Create post in this community with getting started info let lang = user_language(&local_user); let title = lang.welcome_post_title().to_string(); let body = lang.welcome_post_body().to_string(); let post_form = PostInsertForm { body: Some(body), featured_local: Some(true), ..PostInsertForm::new(title, person.id, community.id) }; let post = Post::create(pool, &post_form).await?; // Own upvote for post let like_form = PostLikeForm::new(post.id, person.id, Some(true)); PostActions::like(&mut context.pool(), &like_form).await?; Ok(()) }) } ================================================ FILE: crates/api/api_crud/src/user/delete.rs ================================================ use activitypub_federation::config::Data; use actix_web::web::Json; use bcrypt::verify; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::purge_user_account, }; use lemmy_db_schema::source::{ community::CommunityActions, login_token::LoginToken, oauth_account::OAuthAccount, person::Person, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::{DeleteAccount, SuccessResponse}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; pub async fn delete_account( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let local_instance_id = local_user_view.person.instance_id; // Verify the password let valid: bool = local_user_view .local_user .password_encrypted .as_ref() .and_then(|password_encrypted| verify(&data.password, password_encrypted).ok()) .unwrap_or(false); if !valid { return Err(LemmyErrorType::IncorrectLogin.into()); } if data.delete_content { purge_user_account(local_user_view.person.id, local_instance_id, &context).await?; } else { // These are already run in purge_user_account, // but should be done anyway even if delete_content is false OAuthAccount::delete_user_accounts(&mut context.pool(), local_user_view.local_user.id).await?; CommunityActions::leave_mod_team_for_all_communities( &mut context.pool(), local_user_view.person.id, ) .await?; Person::delete_account( &mut context.pool(), local_user_view.person.id, local_instance_id, ) .await?; } LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?; ActivityChannel::submit_activity( SendActivityData::DeleteUser(local_user_view.person, data.delete_content), &context, )?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/api/api_crud/src/user/mod.rs ================================================ pub mod create; pub mod delete; pub mod my_user; ================================================ FILE: crates/api/api_crud/src/user/my_user.rs ================================================ use actix_web::web::{Data, Json}; use lemmy_api_utils::{context::LemmyContext, utils::check_local_user_deleted}; use lemmy_db_schema::{ MultiCommunityListingType, MultiCommunitySortType, source::{ actor_language::LocalUserLanguage, community::CommunityActions, instance::InstanceActions, keyword_block::LocalUserKeywordBlock, person::PersonActions, }, traits::Blockable, }; use lemmy_db_views_community::impls::MultiCommunityQuery; use lemmy_db_views_community_follower::CommunityFollowerView; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::MyUserInfo; use lemmy_utils::error::LemmyResult; pub async fn get_my_user( local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { check_local_user_deleted(&local_user_view)?; // Build the local user with parallel queries and add it to site response let person_id = local_user_view.person.id; let local_user_id = local_user_view.local_user.id; let pool = &mut context.pool(); let ( follows, community_blocks, instance_communities_blocks, instance_persons_blocks, person_blocks, moderates, multi_community_follows, keyword_blocks, discussion_languages, ) = lemmy_diesel_utils::try_join_with_pool!(pool => ( |pool| CommunityFollowerView::for_person(pool, person_id), |pool| CommunityActions::read_blocks_for_person(pool, person_id), |pool| InstanceActions::read_communities_block_for_person(pool, person_id), |pool| InstanceActions::read_persons_block_for_person(pool, person_id), |pool| PersonActions::read_blocks_for_person(pool, person_id), |pool| CommunityModeratorView::for_person(pool, person_id, Some(&local_user_view.local_user)), |pool| MultiCommunityQuery { my_person_id: Some(person_id), listing_type: Some(MultiCommunityListingType::Subscribed), sort: Some(MultiCommunitySortType::NameAsc), no_limit: Some(true), ..Default::default() } .list(pool), |pool| LocalUserKeywordBlock::read(pool, local_user_id), |pool| LocalUserLanguage::read(pool, local_user_id) ))?; Ok(Json(MyUserInfo { local_user_view: local_user_view.clone(), follows, moderates, multi_community_follows: multi_community_follows.items, community_blocks, instance_communities_blocks, instance_persons_blocks, person_blocks, keyword_blocks, discussion_languages, })) } ================================================ FILE: crates/api/api_utils/Cargo.toml ================================================ [package] name = "lemmy_api_utils" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true [lib] name = "lemmy_api_utils" path = "src/lib.rs" doctest = false [lints] workspace = true [features] full = [ "lemmy_db_schema/full", "lemmy_db_views_community/full", "lemmy_db_views_community_follower_approval/full", "lemmy_db_views_community_moderator/full", "lemmy_db_views_local_image/full", "lemmy_db_views_local_user/full", "lemmy_db_views_site/full", "lemmy_db_views_private_message/full", "lemmy_db_views_comment/full", "lemmy_db_views_post/full", "lemmy_db_views_notification/full", "lemmy_db_views_registration_applications/full", ] [dependencies] lemmy_db_schema = { workspace = true } lemmy_db_schema_file = { workspace = true } lemmy_db_views_community = { workspace = true } lemmy_db_views_community_follower_approval = { workspace = true } lemmy_db_views_community_moderator = { workspace = true } lemmy_db_views_local_image = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_db_views_site = { workspace = true } lemmy_db_views_private_message = { workspace = true } lemmy_db_views_comment = { workspace = true } lemmy_db_views_post = { workspace = true } lemmy_db_views_notification = { workspace = true } lemmy_db_views_registration_applications = { workspace = true } lemmy_email = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } lemmy_utils = { workspace = true } extism = { workspace = true } extism-convert = { workspace = true } extism-manifest = "1.13.0" reqwest-middleware = { workspace = true } activitypub_federation = { workspace = true } mime = { version = "0.3.17" } mime_guess = "2.0.5" infer = "0.19.0" chrono = { workspace = true } encoding_rs = "0.8.35" futures = { workspace = true } reqwest = { workspace = true } actix-web = { workspace = true } actix-web-httpauth = { version = "0.8.2" } enum-map = { workspace = true } url = { workspace = true } moka = { workspace = true } webmention = { version = "0.6.0" } urlencoding = { workspace = true } webpage = { version = "2.0", default-features = false, features = ["serde"] } regex = { workspace = true } jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } either.workspace = true derive-new.workspace = true lemmy_diesel_utils = { workspace = true } rustls = { workspace = true } [dev-dependencies] serial_test = { workspace = true } pretty_assertions = { workspace = true } lemmy_db_views_notification = { workspace = true, features = ["full"] } diesel_ltree = { workspace = true } ================================================ FILE: crates/api/api_utils/src/build_response.rs ================================================ use crate::{context::LemmyContext, utils::is_mod_or_admin}; use actix_web::web::Json; use lemmy_db_schema::{ newtypes::{CommentId, CommunityId, PostId}, source::actor_language::CommunityLanguage, }; use lemmy_db_schema_file::InstanceId; use lemmy_db_views_comment::{CommentView, api::CommentResponse}; use lemmy_db_views_community::{CommunityView, api::CommunityResponse}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{PostView, api::PostResponse}; use lemmy_utils::error::LemmyResult; pub async fn build_comment_response( context: &LemmyContext, comment_id: CommentId, local_user_view: Option, local_instance_id: InstanceId, ) -> LemmyResult { let local_user = local_user_view.map(|l| l.local_user); let comment_view = CommentView::read( &mut context.pool(), comment_id, local_user.as_ref(), local_instance_id, ) .await?; Ok(CommentResponse { comment_view }) } pub async fn build_community_response( context: &LemmyContext, local_user_view: LocalUserView, community_id: CommunityId, ) -> LemmyResult> { let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view, community_id) .await .is_ok(); let local_user = local_user_view.local_user; let community_view = CommunityView::read( &mut context.pool(), community_id, Some(&local_user), is_mod_or_admin, ) .await?; let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?; Ok(Json(CommunityResponse { community_view, discussion_languages, })) } pub async fn build_post_response( context: &LemmyContext, community_id: CommunityId, local_user_view: LocalUserView, post_id: PostId, ) -> LemmyResult> { let is_mod_or_admin = is_mod_or_admin(&mut context.pool(), &local_user_view, community_id) .await .is_ok(); let local_user = local_user_view.local_user; let post_view = PostView::read( &mut context.pool(), post_id, Some(&local_user), local_user_view.person.instance_id, is_mod_or_admin, ) .await?; Ok(Json(PostResponse { post_view })) } ================================================ FILE: crates/api/api_utils/src/claims.rs ================================================ use crate::context::LemmyContext; use actix_web::{HttpRequest, http::header::USER_AGENT}; use chrono::{DateTime, Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use lemmy_db_schema::{ newtypes::LocalUserId, source::login_token::{LoginToken, LoginTokenCreateForm}, }; use lemmy_diesel_utils::sensitive::SensitiveString; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] pub struct Claims { /// local_user_id, standard claim by RFC 7519. pub sub: String, /// Server domain pub iss: String, /// Time when this token was issued as UNIX-timestamp in seconds pub iat: i64, /// Expiration timestamp pub exp: i64, } impl Claims { pub async fn validate(jwt: &str, context: &LemmyContext) -> LemmyResult { let validation = Validation::default(); let jwt_secret = &context.secret().jwt_secret; let key = DecodingKey::from_secret(jwt_secret.as_ref()); let claims = decode::(jwt, &key, &validation).with_lemmy_type(LemmyErrorType::NotLoggedIn)?; let user_id = LocalUserId(claims.claims.sub.parse()?); LoginToken::validate(&mut context.pool(), user_id, jwt).await?; Ok(user_id) } pub async fn generate( user_id: LocalUserId, stay_logged_in: Option, req: HttpRequest, context: &LemmyContext, ) -> LemmyResult { let hostname = context.settings().hostname.clone(); let now = Utc::now(); let exp = if stay_logged_in.unwrap_or_default() { // Login doesnt expire DateTime::::MAX_UTC } else { // Login expires after one week now + Duration::weeks(1) }; let my_claims = Claims { sub: user_id.0.to_string(), iss: hostname, iat: now.timestamp(), exp: exp.timestamp(), }; let secret = &context.secret().jwt_secret; let key = EncodingKey::from_secret(secret.as_ref()); let token: SensitiveString = encode(&Header::default(), &my_claims, &key)?.into(); let ip = req .connection_info() .realip_remote_addr() .map(ToString::to_string); let user_agent = req .headers() .get(USER_AGENT) .and_then(|ua| ua.to_str().ok()) .map(ToString::to_string); let form = LoginTokenCreateForm { token: token.clone(), user_id, ip, user_agent, }; LoginToken::create(&mut context.pool(), form).await?; Ok(token) } } #[cfg(test)] mod tests { use crate::{claims::Claims, context::LemmyContext}; use actix_web::test::TestRequest; use lemmy_db_schema::source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_should_not_validate_user_token_after_password_change() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "Gerry9812"); let inserted_person = Person::create(pool, &new_person).await?; let local_user_form = LocalUserInsertForm::test_form(inserted_person.id); let inserted_local_user = LocalUser::create(pool, &local_user_form, vec![]).await?; let req = TestRequest::default().to_http_request(); let jwt = Claims::generate(inserted_local_user.id, None, req, &context).await?; let valid = Claims::validate(&jwt, &context).await; assert!(valid.is_ok()); let num_deleted = Person::delete(pool, inserted_person.id).await?; assert_eq!(1, num_deleted); Ok(()) } } ================================================ FILE: crates/api/api_utils/src/context.rs ================================================ use crate::request::client_builder; use activitypub_federation::config::{Data, FederationConfig}; use lemmy_db_schema::source::secret::Secret; use lemmy_diesel_utils::connection::{ActualDbPool, DbPool, build_db_pool_for_tests}; use lemmy_utils::{ rate_limit::RateLimit, settings::{SETTINGS, structs::Settings}, }; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use std::sync::Arc; #[derive(Clone)] pub struct LemmyContext { pool: ActualDbPool, client: Arc, /// Pictrs requests must bypass proxy. Unfortunately no_proxy can only be set on ClientBuilder /// and not on RequestBuilder, so we need a separate client here. pictrs_client: Arc, secret: Arc, rate_limit_cell: RateLimit, } impl LemmyContext { pub fn create( pool: ActualDbPool, client: ClientWithMiddleware, pictrs_client: ClientWithMiddleware, secret: Secret, rate_limit_cell: RateLimit, ) -> LemmyContext { LemmyContext { pool, client: Arc::new(client), pictrs_client: Arc::new(pictrs_client), secret: Arc::new(secret), rate_limit_cell, } } pub fn pool(&self) -> DbPool<'_> { DbPool::Pool(&self.pool) } pub fn inner_pool(&self) -> &ActualDbPool { &self.pool } pub fn client(&self) -> &ClientWithMiddleware { &self.client } pub fn pictrs_client(&self) -> &ClientWithMiddleware { &self.pictrs_client } pub fn settings(&self) -> &'static Settings { &SETTINGS } pub fn secret(&self) -> &Secret { &self.secret } pub fn rate_limit_cell(&self) -> &RateLimit { &self.rate_limit_cell } /// Initialize a context for use in tests which blocks federation network calls. /// /// Do not use this in production code. #[expect(clippy::expect_used)] pub async fn init_test_federation_config() -> FederationConfig { // call this to run migrations let pool = build_db_pool_for_tests(); let client = client_builder(&SETTINGS).build().expect("build client"); let client = ClientBuilder::new(client).build(); let secret = Secret { id: 0, jwt_secret: String::new().into(), }; let rate_limit_cell = RateLimit::with_debug_config(); let context = LemmyContext::create( pool, client.clone(), client, secret, rate_limit_cell.clone(), ); FederationConfig::builder() .domain(context.settings().hostname.clone()) .app_data(context) .debug(true) // Dont allow any network fetches .http_fetch_limit(0) .build() .await .expect("build federation config") } pub async fn init_test_context() -> Data { let config = Self::init_test_federation_config().await; config.to_request_data() } } ================================================ FILE: crates/api/api_utils/src/lib.rs ================================================ pub mod build_response; pub mod claims; pub mod context; pub mod notify; pub mod plugins; pub mod request; pub mod send_activity; pub mod utils; ================================================ FILE: crates/api/api_utils/src/notify.rs ================================================ use crate::{context::LemmyContext, plugins::plugin_hook_notification}; use lemmy_db_schema::{ source::{ comment::Comment, community::{Community, CommunityActions}, instance::InstanceActions, modlog::Modlog, notification::{Notification, NotificationInsertForm}, person::{Person, PersonActions}, post::{Post, PostActions}, }, traits::{ApubActor, Blockable}, }; use lemmy_db_schema_file::{ PersonId, enums::{CommunityNotificationsMode, NotificationType, PostNotificationsMode}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{dburl::DbUrl, traits::Crud}; use lemmy_email::notifications::{NotificationEmailData, send_notification_email}; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, spawn_try_task, utils::mention::scrape_text_for_mentions, }; use std::{ collections::HashSet, hash::{Hash, Hasher}, }; use url::Url; #[derive(derive_new::new, Debug, Clone)] pub struct NotifyData { pub post: Post, pub creator: Person, pub community: Community, #[new(value = "None")] pub comment: Option, #[new(value = "false")] pub do_send_email: bool, #[new(value = "None")] pub apub_mentions: Option>, } struct CollectedNotifyData<'a> { recipient_id: PersonId, local_url: DbUrl, data: NotificationEmailData<'a>, kind: NotificationType, } /// For PartialEq and Hash, we only need to compare recipient id and object url. impl<'a> PartialEq for CollectedNotifyData<'a> { fn eq(&self, other: &CollectedNotifyData<'_>) -> bool { self.recipient_id == other.recipient_id && self.local_url == other.local_url } } impl<'a> Hash for CollectedNotifyData<'a> { fn hash(&self, state: &mut H) { self.recipient_id.hash(state); self.local_url.hash(state); } } impl<'a> Eq for CollectedNotifyData<'a> {} impl NotifyData { /// Scans the post/comment content for mentions, then sends notifications via db and email /// to mentioned users and parent creator. Spawns a task for background processing. pub fn send(self, context: &LemmyContext) { let context = context.clone(); spawn_try_task(self.send_internal(context)) } /// Logic for send(), in separate function so it can run serially in tests. pub async fn send_internal(self, context: LemmyContext) -> LemmyResult<()> { // Use set so that notifications are unique per user and object. let collected: HashSet<_> = [ self.notify_parent_creator(&context).await?, self.notify_mentions(&context).await?, self.notify_subscribers(&context).await?, ] .into_iter() .flatten() .collect(); let mut forms = vec![]; for c in collected { // Dont get notified about own actions if self.creator.id == c.recipient_id { continue; } if self .check_notifications_allowed(c.recipient_id, &context) .await .is_err() { continue; }; forms.push(if let Some(comment) = &self.comment { NotificationInsertForm::new_comment(comment, c.recipient_id, c.kind) } else { NotificationInsertForm::new_post(&self.post, c.recipient_id, c.kind) }); let Ok(user_view) = LocalUserView::read_person(&mut context.pool(), c.recipient_id).await else { // is a remote user, ignore continue; }; if self.do_send_email { send_notification_email(user_view, c.local_url, c.data, context.settings()); } } if !forms.is_empty() { let notifications = Notification::create(&mut context.pool(), &forms).await?; plugin_hook_notification(notifications, &context).await?; } Ok(()) } async fn check_notifications_allowed( &self, potential_blocker_id: PersonId, context: &LemmyContext, ) -> LemmyResult<()> { let pool = &mut context.pool(); // TODO: this needs too many queries for each user PersonActions::read_block(pool, potential_blocker_id, self.post.creator_id).await?; InstanceActions::read_communities_block(pool, potential_blocker_id, self.community.instance_id) .await?; InstanceActions::read_persons_block(pool, potential_blocker_id, self.creator.instance_id) .await?; CommunityActions::read_block(pool, potential_blocker_id, self.post.community_id).await?; let post_notifications = PostActions::read(pool, self.post.id, potential_blocker_id) .await .ok() .and_then(|a| a.notifications) .unwrap_or_default(); let community_notifications = CommunityActions::read(pool, self.community.id, potential_blocker_id) .await .ok() .and_then(|a| a.notifications) .unwrap_or_default(); if post_notifications == PostNotificationsMode::Mute || community_notifications == CommunityNotificationsMode::Mute { // The specific error type is irrelevant return Err(LemmyErrorType::NotFound.into()); } Ok(()) } fn content(&self) -> String { if let Some(comment) = self.comment.as_ref() { comment.content.clone() } else { self.post.body.clone().unwrap_or_default() } } fn link(&self, context: &LemmyContext) -> LemmyResult { if let Some(comment) = self.comment.as_ref() { Ok(comment.local_url(context.settings())?) } else { Ok(self.post.local_url(context.settings())?) } } async fn notify_parent_creator<'a>( &'a self, context: &LemmyContext, ) -> LemmyResult>> { let Some(comment) = self.comment.as_ref() else { return Ok(vec![]); }; // Get the parent data let (parent_creator_id, parent_comment) = if let Some(parent_comment_id) = comment.parent_comment_id() { let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?; (parent_comment.creator_id, Some(parent_comment)) } else { (self.post.creator_id, None) }; Ok(vec![CollectedNotifyData { recipient_id: parent_creator_id, local_url: comment.local_url(context.settings())?.into(), data: NotificationEmailData::Reply { comment, person: &self.creator, parent_comment, post: &self.post, }, kind: NotificationType::Reply, }]) } async fn notify_mentions<'a>( &'a self, context: &LemmyContext, ) -> LemmyResult>> { let mentions = if let Some(apub_mentions) = self.apub_mentions.clone() { apub_mentions } else { let scraped = scrape_text_for_mentions(&self.content()) .into_iter() .filter(|m| m.is_local(&context.settings().hostname) && m.name.ne(&self.creator.name)); let mut persons = vec![]; for m in scraped { let Ok(Some(p)) = Person::read_from_name(&mut context.pool(), &m.name, None, false).await else { // Ignore error if user is remote continue; }; persons.push(p); } persons }; let mut res = vec![]; for mention in mentions { res.push(CollectedNotifyData { recipient_id: mention.id, local_url: self.link(context)?.into(), data: NotificationEmailData::Mention { content: self.content().clone(), person: &self.creator, }, kind: NotificationType::Mention, }) } Ok(res) } async fn notify_subscribers<'a>( &'a self, context: &LemmyContext, ) -> LemmyResult>> { let is_post = self.comment.is_none(); let subscribers = vec![ PostActions::list_subscribers(self.post.id, &mut context.pool()).await?, CommunityActions::list_subscribers(self.post.community_id, is_post, &mut context.pool()) .await?, ] .into_iter() .flatten() .collect::>(); let mut res = vec![]; for recipient_id in subscribers { let d = if let Some(comment) = &self.comment { NotificationEmailData::PostSubscribed { post: &self.post, comment, } } else { NotificationEmailData::CommunitySubscribed { community: &self.community, post: &self.post, } }; res.push(CollectedNotifyData { recipient_id, local_url: self.link(context)?.into(), data: d, kind: NotificationType::Subscribed, }); } Ok(res) } } pub fn notify_private_message(view: &PrivateMessageView, is_create: bool, context: &LemmyContext) { let view = view.clone(); let context = context.clone(); spawn_try_task(async move { notify_private_message_internal(&view, is_create, &context).await }) } async fn notify_private_message_internal( view: &PrivateMessageView, is_create: bool, context: &LemmyContext, ) -> LemmyResult<()> { let Ok(local_recipient) = LocalUserView::read_person(&mut context.pool(), view.recipient.id).await else { return Ok(()); }; let form = NotificationInsertForm::new_private_message(&view.private_message); let notifications = Notification::create(&mut context.pool(), &[form]).await?; if is_create { plugin_hook_notification(notifications, context).await?; let site_view = SiteView::read_local(&mut context.pool()).await?; if !site_view.local_site.disable_email_notifications { let d = NotificationEmailData::PrivateMessage { sender: &view.creator, content: &view.private_message.content, }; send_notification_email( local_recipient, view.private_message.local_url(context.settings())?, d, context.settings(), ); } } Ok(()) } pub fn notify_mod_action(actions: Vec, context: &LemmyContext) { // Mod actions should notify the target person. If there is no target person then also no // notification. This means each mod action can only notify a single person (eg it is not possible // to notify all community mods when a community gets removed). let actions: Vec<_> = actions .into_iter() .filter(|a| a.target_person_id.is_some()) .collect(); if actions.is_empty() { return; } let context = context.clone(); spawn_try_task(async move { for action in actions { let Some(target_id) = action.target_person_id else { continue; }; let Ok(local_recipient) = LocalUserView::read_person(&mut context.pool(), target_id).await else { continue; }; let form = NotificationInsertForm::new_mod_action(action.id, local_recipient.person.id, action.mod_id); let notifications = Notification::create(&mut context.pool(), &[form]).await?; plugin_hook_notification(notifications, &context).await?; let modlog_url = format!( "{}/modlog?userId={}&actionType={}", context.settings().get_protocol_and_hostname(), local_recipient.person.id.0, action.kind ); let d = NotificationEmailData::ModAction { kind: action.kind, reason: action.reason.as_deref(), is_revert: action.is_revert, }; send_notification_email( local_recipient, Url::parse(&modlog_url)?.into(), d, context.settings(), ); } Ok(()) }) } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::{ context::LemmyContext, notify::{NotifyData, notify_private_message_internal}, }; use lemmy_db_schema::{ NotificationTypeFilter, assert_length, source::{ comment::{Comment, CommentInsertForm}, community::{Community, CommunityInsertForm}, instance::{Instance, InstanceActions, InstancePersonsBlockForm}, notification::{Notification, NotificationInsertForm}, person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonUpdateForm}, post::{Post, PostInsertForm}, private_message::{PrivateMessage, PrivateMessageInsertForm, PrivateMessageUpdateForm}, }, traits::Blockable, }; use lemmy_db_schema_file::enums::NotificationType; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_notification::{NotificationData, NotificationView, impls::NotificationQuery}; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { instance: Instance, timmy: LocalUserView, sara: LocalUserView, jessica: Person, community: Community, timmy_post: Post, jessica_post: Post, timmy_comment: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "lemmy-alpha").await?; let timmy = LocalUserView::create_test_user(pool, "timmy_pcv", "", false).await?; let sara = LocalUserView::create_test_user(pool, "sara_pcv", "", false).await?; let jessica_form = PersonInsertForm::test_form(instance.id, "jessica_mrv"); let jessica = Person::create(pool, &jessica_form).await?; let community_form = CommunityInsertForm::new( instance.id, "test community pcv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.person.id, community.id); let timmy_post = Post::create(pool, &timmy_post_form).await?; let jessica_post_form = PostInsertForm::new("jessica post prv".into(), jessica.id, community.id); let jessica_post = Post::create(pool, &jessica_post_form).await?; let timmy_comment_form = CommentInsertForm::new(timmy.person.id, timmy_post.id, "timmy comment prv".into()); let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; Ok(Data { instance, timmy, sara, jessica, community, timmy_post, jessica_post, timmy_comment, }) } async fn insert_private_message( form: PrivateMessageInsertForm, context: &LemmyContext, ) -> LemmyResult<()> { let pool = &mut context.pool(); let pm = PrivateMessage::create(pool, &form).await?; let view = PrivateMessageView::read(pool, pm.id, None).await?; notify_private_message_internal(&view, false, context).await?; Ok(()) } async fn setup_private_messages(data: &Data, context: &LemmyContext) -> LemmyResult<()> { let sara_timmy_message_form = PrivateMessageInsertForm::new( data.sara.person.id, data.timmy.person.id, "sara to timmy".into(), ); insert_private_message(sara_timmy_message_form, context).await?; let sara_jessica_message_form = PrivateMessageInsertForm::new( data.sara.person.id, data.jessica.id, "sara to jessica".into(), ); insert_private_message(sara_jessica_message_form, context).await?; let timmy_sara_message_form = PrivateMessageInsertForm::new( data.timmy.person.id, data.sara.person.id, "timmy to sara".into(), ); insert_private_message(timmy_sara_message_form, context).await?; let jessica_timmy_message_form = PrivateMessageInsertForm::new( data.jessica.id, data.timmy.person.id, "jessica to timmy".into(), ); insert_private_message(jessica_timmy_message_form, context).await?; Ok(()) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Instance::delete(pool, data.timmy.person.instance_id).await?; Ok(()) } #[tokio::test] #[serial] async fn replies() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = init_data(pool).await?; // Sara replied to timmys comment with a mention let sara_comment_form = CommentInsertForm::new( data.sara.person.id, data.timmy_post.id, "@timmy_notify@lemmy-alpha".into(), ); let sara_comment = Comment::create(pool, &sara_comment_form, Some(&data.timmy_comment.path)).await?; NotifyData { post: data.timmy_post.clone(), comment: Some(sara_comment.clone()), creator: data.sara.person.clone(), community: data.community.clone(), do_send_email: false, apub_mentions: None, } .send_internal(context.app_data().clone()) .await?; // Ensure that reply + mention only generates a single notification let timmy_unread_replies = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; assert_eq!(1, timmy_unread_replies); let timmy_inbox = NotificationQuery::default() .list(pool, &data.timmy.person) .await?; assert_length!(1, timmy_inbox); if let NotificationData::Comment(comment) = &timmy_inbox[0].data { assert_eq!(sara_comment.id, comment.comment.id); assert_eq!(data.timmy_post.id, comment.post.id); assert_eq!(data.sara.person.id, comment.creator.id); assert_eq!( data.timmy.person.id, timmy_inbox[0].notification.recipient_id ); assert_eq!(NotificationType::Reply, timmy_inbox[0].notification.kind); } else { panic!("wrong type") }; // Mark it as read Notification::mark_read_by_id_and_person( pool, timmy_inbox[0].notification.id, data.timmy.person.id, true, ) .await?; let timmy_unread_replies = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; assert_eq!(0, timmy_unread_replies); let timmy_inbox_unread = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.timmy.person) .await?; assert_length!(0, timmy_inbox_unread); // Make sure that marking as unread works Notification::mark_read_by_id_and_person( pool, timmy_inbox[0].notification.id, data.timmy.person.id, false, ) .await?; let timmy_unread_replies = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; assert_eq!(1, timmy_unread_replies); let timmy_inbox_unread = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.timmy.person) .await?; assert_length!(1, timmy_inbox_unread); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn mentions() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Timmy mentions sara in a comment let timmy_mention_sara_form = NotificationInsertForm::new_comment( &data.timmy_comment, data.sara.person.id, NotificationType::Mention, ); Notification::create(pool, &[timmy_mention_sara_form]).await?; // Jessica mentions sara in a post let jessica_mention_sara_form = NotificationInsertForm::new_post( &data.jessica_post, data.sara.person.id, NotificationType::Mention, ); Notification::create(pool, &[jessica_mention_sara_form]).await?; // Test to make sure counts and blocks work correctly let sara_unread_mentions = NotificationView::get_unread_count(pool, &data.sara.person, true).await?; assert_eq!(2, sara_unread_mentions); let sara_inbox = NotificationQuery::default() .list(pool, &data.sara.person) .await?; assert_length!(2, sara_inbox); if let NotificationData::Post(post) = &sara_inbox[0].data { assert_eq!(data.jessica_post.id, post.post.id); assert_eq!(data.jessica.id, post.creator.id); } else { panic!("wrong type") } assert_eq!(data.sara.person.id, sara_inbox[0].notification.recipient_id); assert_eq!(NotificationType::Mention, sara_inbox[0].notification.kind); if let NotificationData::Comment(comment) = &sara_inbox[1].data { assert_eq!(data.timmy_comment.id, comment.comment.id); assert_eq!(data.timmy_post.id, comment.post.id); assert_eq!(data.timmy.person.id, comment.creator.id); } else { panic!("wrong type"); } assert_eq!(data.sara.person.id, sara_inbox[1].notification.recipient_id); assert_eq!(NotificationType::Mention, sara_inbox[1].notification.kind); // Sara blocks timmy, and make sure these counts are now empty let sara_blocks_timmy_form = PersonBlockForm::new(data.sara.person.id, data.timmy.person.id); PersonActions::block(pool, &sara_blocks_timmy_form).await?; let sara_unread_mentions_after_block = NotificationView::get_unread_count(pool, &data.sara.person, true).await?; assert_eq!(1, sara_unread_mentions_after_block); let sara_inbox_after_block = NotificationQuery::default() .list(pool, &data.sara.person) .await?; assert_length!(1, sara_inbox_after_block); // Make sure the comment mention which timmy made is the hidden one assert_eq!( NotificationType::Mention, sara_inbox_after_block[0].notification.kind ); // Unblock user so we can reuse the same person PersonActions::unblock(pool, &sara_blocks_timmy_form).await?; // Test the type filter let sara_inbox_mentions_only = NotificationQuery { type_: Some(NotificationTypeFilter::Other(NotificationType::Mention)), ..Default::default() } .list(pool, &data.sara.person) .await?; assert_length!(2, sara_inbox_mentions_only); assert_eq!( NotificationType::Mention, sara_inbox_mentions_only[0].notification.kind ); // Turn Jessica into a bot account let person_update_form = PersonUpdateForm { bot_account: Some(true), ..Default::default() }; Person::update(pool, data.jessica.id, &person_update_form).await?; // Make sure sara hides bot let sara_unread_mentions_after_hide_bots = NotificationView::get_unread_count(pool, &data.sara.person, false).await?; assert_eq!(1, sara_unread_mentions_after_hide_bots); let sara_inbox_after_hide_bots = NotificationQuery::default() .list(pool, &data.sara.person) .await?; assert_length!(1, sara_inbox_after_hide_bots); // Make sure the post mention which jessica made is the hidden one assert_eq!( NotificationType::Mention, sara_inbox_after_hide_bots[0].notification.kind ); // Mark them all as read Notification::mark_all_as_read(pool, data.sara.person.id).await?; // Make sure none come back let sara_unread_mentions = NotificationView::get_unread_count(pool, &data.sara.person, true).await?; assert_eq!(0, sara_unread_mentions); let sara_inbox_unread = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.sara.person) .await?; assert_length!(0, sara_inbox_unread); cleanup(data, pool).await?; Ok(()) } /// Useful in combination with filter_map fn to_pm(x: NotificationView) -> Option { if let NotificationData::PrivateMessage(v) = x.data { Some(v) } else { None } } #[tokio::test] #[serial] async fn read_private_messages() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = init_data(pool).await?; setup_private_messages(&data, &context).await?; let timmy_messages: Vec<_> = NotificationQuery::default() .list(pool, &data.timmy.person) .await? .into_iter() .filter_map(to_pm) .collect(); // The read even shows timmy's sent messages assert_length!(3, &timmy_messages); assert_eq!(timmy_messages[0].creator.id, data.jessica.id); assert_eq!(timmy_messages[0].recipient.id, data.timmy.person.id); assert_eq!(timmy_messages[1].creator.id, data.timmy.person.id); assert_eq!(timmy_messages[1].recipient.id, data.sara.person.id); assert_eq!(timmy_messages[2].creator.id, data.sara.person.id); assert_eq!(timmy_messages[2].recipient.id, data.timmy.person.id); let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; assert_eq!(2, timmy_unread); let timmy_unread_messages: Vec<_> = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.timmy.person) .await? .into_iter() .filter_map(to_pm) .collect(); // The unread hides timmy's sent messages assert_length!(2, &timmy_unread_messages); assert_eq!(timmy_unread_messages[0].creator.id, data.jessica.id); assert_eq!(timmy_unread_messages[0].recipient.id, data.timmy.person.id); assert_eq!(timmy_unread_messages[1].creator.id, data.sara.person.id); assert_eq!(timmy_unread_messages[1].recipient.id, data.timmy.person.id); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn ensure_private_message_person_block() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = init_data(pool).await?; setup_private_messages(&data, &context).await?; // Make sure blocks are working let timmy_blocks_sara_form = PersonBlockForm::new(data.timmy.person.id, data.sara.person.id); let inserted_block = PersonActions::block(pool, &timmy_blocks_sara_form).await?; assert_eq!( (data.timmy.person.id, data.sara.person.id, true), ( inserted_block.person_id, inserted_block.target_id, inserted_block.blocked_at.is_some() ) ); let timmy_messages: Vec<_> = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.timmy.person) .await? .into_iter() .filter_map(to_pm) .collect(); assert_length!(1, &timmy_messages); let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; assert_eq!(1, timmy_unread); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn ensure_private_message_instance_block() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = init_data(pool).await?; setup_private_messages(&data, &context).await?; // Make sure instance_blocks are working let timmy_blocks_instance_form = InstancePersonsBlockForm::new(data.timmy.person.id, data.jessica.instance_id); let inserted_instance_block = InstanceActions::block_persons(pool, &timmy_blocks_instance_form).await?; assert_eq!( (data.timmy.person.id, data.jessica.instance_id, true), ( inserted_instance_block.person_id, inserted_instance_block.instance_id, inserted_instance_block.blocked_persons_at.is_some() ) ); let timmy_messages: Vec<_> = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.timmy.person) .await? .into_iter() .filter_map(to_pm) .collect(); // Messages from Jessica are blocked, only messages from Sara are going through. assert_length!(1, &timmy_messages); assert_eq!(data.sara.person.id, timmy_messages[0].creator.id); let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; assert_eq!(1, timmy_unread); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn private_message_delete_by_recipient() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = init_data(pool).await?; setup_private_messages(&data, &context).await?; let timmy_messages: Vec<_> = NotificationQuery::default() .list(pool, &data.timmy.person) .await? .into_iter() .filter_map(to_pm) .collect(); let timmy_recipient = timmy_messages .iter() .find(|x| x.recipient.id == data.timmy.person.id); let pm = timmy_recipient.map(|x| &x.private_message); // make sure the private message to timmy is found assert_ne!(pm, None); if let Some(pm) = pm { let view = PrivateMessageView::read(pool, pm.id, None).await?; let num_sender_messages_before = NotificationQuery::default() .list(pool, &view.creator) .await? .into_iter() .filter_map(to_pm) .count(); let form = PrivateMessageUpdateForm { deleted_by_recipient: Some(true), ..Default::default() }; let _pm = PrivateMessage::update(&mut context.pool(), pm.id, &form).await?; let timmy_messages_after: Vec<_> = NotificationQuery::default() .list(pool, &data.timmy.person) .await? .into_iter() .filter_map(to_pm) .collect(); let pm_exists = timmy_messages_after .iter() .find(|x| x.private_message.id == pm.id); // the private message should no longer exist assert_eq!(pm_exists, None); let num_sender_messages_after = NotificationQuery::default() .list(pool, &view.creator) .await? .into_iter() .filter_map(to_pm) .count(); // the sender should have the same # of messages assert_eq!(num_sender_messages_before, num_sender_messages_after); } cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/api/api_utils/src/plugins.rs ================================================ use crate::context::LemmyContext; use anyhow::anyhow; use extism::{ FromBytesOwned, Manifest, PluginBuilder, Pool, PoolPlugin, ToBytes, Wasm, WasmMetadata, }; use extism_convert::Json; use extism_manifest::HttpRequest; use lemmy_db_schema::source::{notification::Notification, person::Person}; use lemmy_db_views_notification::NotificationView; use lemmy_db_views_registration_applications::api::CaptchaAnswer; use lemmy_db_views_site::api::{CaptchaResponse, PluginMetadata}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ VERSION, error::{LemmyError, LemmyErrorType, LemmyResult}, settings::{SETTINGS, structs::PluginSettings}, }; use serde::{Deserialize, Serialize}; use std::{ env::var, ops::Deref, path::PathBuf, sync::{LazyLock, OnceLock}, time::Duration, }; use tokio::task::spawn_blocking; use tracing::{error, warn}; use url::Url; const GET_PLUGIN_TIMEOUT: Duration = Duration::from_secs(1); /// Call a plugin hook without rewriting data pub fn plugin_hook_after(name: &'static str, data: &T) where T: Clone + Serialize + for<'b> Deserialize<'b> + Sync + Send + 'static, { let plugins = LemmyPlugins::get_or_init(); if !plugins.function_exists(name) { return; } let data = data.clone(); spawn_blocking(move || run_plugin_hook_after(name, data)); } /// Calls plugin hook for the given notifications Loads additional data via /// NotificationView, but only if a plugin is active. pub async fn plugin_hook_notification( notifications: Vec, context: &LemmyContext, ) -> LemmyResult<()> { let name = "notification_after_create"; let plugins = LemmyPlugins::get_or_init(); if !plugins.function_exists(name) { return Ok(()); } for n in notifications { let person = Person::read(&mut context.pool(), n.recipient_id).await?; let view = NotificationView::read(&mut context.pool(), n.id, &person).await?; spawn_blocking(move || run_plugin_hook_after(name, view)); } Ok(()) } pub async fn plugin_get_captcha() -> LemmyResult { call_captcha_plugin("get_captcha", ()).await } pub async fn plugin_validate_captcha(answer: String, uuid: String) -> LemmyResult<()> { call_captcha_plugin("validate_captcha", CaptchaAnswer { answer, uuid }).await } async fn call_captcha_plugin< 'a, T: ToBytes<'a> + Send + 'static, R: FromBytesOwned + Send + 'static, >( name: &'static str, params: T, ) -> LemmyResult { let plugins = LemmyPlugins::get_or_init(); let Some(captcha_plugin) = plugins.captcha_plugin else { return Err(LemmyErrorType::PluginError("plugin not loaded".to_string()).into()); }; spawn_blocking(move || { if let Some(p) = captcha_plugin.pool.get(GET_PLUGIN_TIMEOUT)? { let res = p .call(name, params) .map_err(|e| LemmyErrorType::PluginError(e.to_string()))?; return Ok(res); } Err(LemmyErrorType::PluginError("plugin not loaded".to_string()).into()) }) .await? } pub fn is_captcha_plugin_loaded() -> bool { LemmyPlugins::get_or_init().captcha_plugin.is_some() } fn run_plugin_hook_after(name: &'static str, data: T) -> LemmyResult<()> where T: Clone + Serialize + for<'b> Deserialize<'b>, { let plugins = LemmyPlugins::get_or_init(); for p in plugins.plugins { if let Some(plugin) = p.get(name)? { let params: Json = data.clone().into(); plugin .call::, ()>(name, params) .map_err(|e| LemmyErrorType::PluginError(e.to_string()))?; } } Ok(()) } /// Call a plugin hook which can rewrite data pub async fn plugin_hook_before(name: &'static str, data: T) -> LemmyResult where T: Clone + Serialize + for<'a> Deserialize<'a> + Sync + Send + 'static, { let plugins = LemmyPlugins::get_or_init(); if !plugins.function_exists(name) { return Ok(data); } spawn_blocking(move || { let mut res: Json = data.into(); for p in plugins.plugins { if let Some(plugin) = p.get(name)? { let r = plugin .call(name, res) .map_err(|e| LemmyErrorType::PluginError(e.to_string()))?; res = r; } } Ok::<_, LemmyError>(res.0) }) .await? } pub fn plugin_metadata() -> Vec { static METADATA: OnceLock> = OnceLock::new(); if let Some(m) = METADATA.get() { m.clone() } else { // Loading metadata can take multiple seconds. Do this in background task to avoid blocking // /api/v4/site endpoint. std::thread::spawn(|| { METADATA.get_or_init(|| { let mut metadata = vec![]; for plugin in LemmyPlugins::get_or_init().plugins { let run = match plugin.pool.get(GET_PLUGIN_TIMEOUT) { Ok(p) => p, Err(e) => { error!("Failed to load plugin {}: {e}", plugin.filename); continue; } }; let m = run.and_then(|run| run.call("metadata", 0).ok()); if let Some(m) = m { metadata.push(m); } else { // Failed to load plugin metadata, use placeholder metadata.push(PluginMetadata { name: plugin.filename, url: None, description: None, }); } } metadata }); }); // Return empty metadata until loading is finished vec![] } } #[derive(Clone)] struct LemmyPlugins { plugins: Vec, captcha_plugin: Option, } #[derive(Clone)] struct LemmyPlugin { pool: Pool, filename: String, } impl LemmyPlugin { fn init(settings: PluginSettings) -> LemmyResult { let hash = if cfg!(debug_assertions) || var("DANGER_PLUGIN_SKIP_HASH_CHECK").is_ok() { None } else { // if no hash was provided in config, set a dummy value here to enforce hash check Some(settings.hash.unwrap_or_else(|| "dummy".to_string())) }; let meta = WasmMetadata { hash, name: None }; let (wasm, filename) = if settings.file.starts_with("http") { let name: Option = Url::parse(&settings.file)? .path_segments() .and_then(|mut p| p.next_back()) .map(std::string::ToString::to_string); let req = HttpRequest { url: settings.file.clone(), headers: Default::default(), method: None, }; (Wasm::Url { req, meta }, name) } else { let path = PathBuf::from(settings.file.clone()); let name: Option = path.file_name().map(|n| n.to_string_lossy().to_string()); (Wasm::File { path, meta }, name) }; let mut manifest = Manifest { wasm: vec![wasm], config: settings.config, allowed_hosts: settings.allowed_hosts, memory: Default::default(), allowed_paths: None, timeout_ms: None, }; manifest.config.insert( "lemmy_url".to_string(), format!("http://{}:{}/", SETTINGS.bind, SETTINGS.port), ); manifest .config .insert("lemmy_version".to_string(), VERSION.to_string()); let builder = move || PluginBuilder::new(manifest.clone()).with_wasi(true).build(); let pool = Pool::new(builder); Ok(LemmyPlugin { pool, filename: filename.unwrap_or(settings.file), }) } #[expect(clippy::if_then_some_else_none)] fn get(&self, name: &'static str) -> LemmyResult> { let p = self .pool .get(GET_PLUGIN_TIMEOUT)? .ok_or(anyhow!("plugin timeout"))?; Ok(if p.plugin().function_exists(name) { Some(p) } else { None }) } } impl LemmyPlugins { /// Load and initialize all plugins fn get_or_init() -> Self { static PLUGINS: LazyLock = LazyLock::new(|| { let mut plugins: Vec<_> = SETTINGS .plugins .iter() .flat_map(|p| { LemmyPlugin::init(p.clone()) .inspect_err(|e| warn!("Failed to load plugin {}: {e}", p.file)) .ok() }) .collect(); let mut captcha_plugin = None; for (i, p) in plugins.iter().enumerate() { let is_captcha = p .pool .function_exists("validate_captcha", GET_PLUGIN_TIMEOUT) .unwrap_or_default() && p .pool .function_exists("validate_captcha", GET_PLUGIN_TIMEOUT) .unwrap_or_default(); if is_captcha { captcha_plugin = Some(plugins.remove(i)); break; } } // Need to put captcha plugin back in so it can be shown in the active plugins list. if let Some(captcha_plugin) = &captcha_plugin { plugins.push(captcha_plugin.clone()); } LemmyPlugins { plugins, captcha_plugin, } }); PLUGINS.deref().clone() } /// Return early if no plugin is loaded for the given hook name fn function_exists(&self, name: &'static str) -> bool { self.plugins.iter().any(|p| { p.pool .function_exists(name, GET_PLUGIN_TIMEOUT) .unwrap_or(false) }) } } ================================================ FILE: crates/api/api_utils/src/request.rs ================================================ use crate::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::proxy_image_link, }; use activitypub_federation::config::Data; use chrono::{DateTime, Utc}; use encoding_rs::{Encoding, UTF_8}; use futures::StreamExt; use lemmy_db_schema::source::{ images::{ImageDetailsInsertForm, LocalImage, LocalImageForm}, local_site::LocalSite, post::{Post, PostUpdateForm}, }; use lemmy_db_schema_file::enums::ImageMode; use lemmy_db_views_post::api::{LinkMetadata, OpenGraphData}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ REQWEST_TIMEOUT, VERSION, error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError}, settings::structs::Settings, }; use mime::{Mime, TEXT_HTML}; use reqwest::{ Client, ClientBuilder, Response, header::{CONTENT_TYPE, LOCATION, RANGE}, redirect::Policy, }; use reqwest_middleware::ClientWithMiddleware; use serde::{Deserialize, Serialize}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use tokio::net::lookup_host; use tracing::{info, warn}; use url::Url; use urlencoding::encode; use webpage::{HTML, OpengraphObject}; pub fn client_builder(settings: &Settings) -> ClientBuilder { // https://github.com/seanmonstar/reqwest/issues/2924 let _ = rustls::crypto::ring::default_provider().install_default(); let user_agent = format!( "Lemmy/{}; +{}", *VERSION, settings.get_protocol_and_hostname() ); Client::builder() .user_agent(user_agent.clone()) .timeout(REQWEST_TIMEOUT) .connect_timeout(REQWEST_TIMEOUT) .redirect(Policy::none()) } /// Fetches metadata for the given link and optionally generates thumbnail. pub async fn fetch_link_metadata( url: &Url, context: &LemmyContext, recursion: bool, ) -> LemmyResult { if url.scheme() != "http" && url.scheme() != "https" { return Err(LemmyErrorType::InvalidUrl.into()); } // Resolve the domain and throw an error if it points to any internal IP, // using logic from nightly IpAddr::is_global. if !cfg!(debug_assertions) { // TODO: Replace with IpAddr::is_global() once stabilized // https://doc.rust-lang.org/std/net/enum.IpAddr.html#method.is_global let domain = url.domain().ok_or(UntranslatedError::UrlWithoutDomain)?; let invalid_ip = lookup_host((domain.to_owned(), 80)) .await? .any(|addr| match addr.ip() { IpAddr::V4(addr) => v4_is_invalid(addr), IpAddr::V6(addr) => v6_is_invalid(addr), }); if invalid_ip { return Err(LemmyErrorType::InvalidUrl.into()); } } info!("Fetching site metadata for url: {}", url); // We only fetch the first MB of data in order to not waste bandwidth especially for large // binary files. This high limit is particularly needed for youtube, which includes a lot of // javascript code before the opengraph tags. Mastodon also uses a 1 MB limit: // https://github.com/mastodon/mastodon/blob/295ad6f19a016b3f16e1201ffcbb1b3ad6b455a2/app/lib/request.rb#L213 let bytes_to_fetch = 1024 * 1024; let response = context .client() .get(url.as_str()) // we only need the first chunk of data. Note that we do not check for Accept-Range so the // server may ignore this and still respond with the full response .header(RANGE, format!("bytes=0-{}", bytes_to_fetch - 1)) /* -1 because inclusive */ .send() .await? .error_for_status()?; // Manually follow one redirect, using internal IP check. Further redirects are ignored. let location = response .headers() .get(LOCATION) .and_then(|l| l.to_str().ok()); if let (Some(location), false) = (location, recursion) { let url = location.parse()?; return Box::pin(fetch_link_metadata(&url, context, true)).await; } let mut content_type: Option = response .headers() .get(CONTENT_TYPE) .and_then(|h| h.to_str().ok()) .and_then(|h| h.parse().ok()) // If we don't get a content_type from the response (e.g. if the server is down), // then try to infer the content_type from the file extension. .or(mime_guess::from_path(url.path()).first()); let opengraph_data = { let is_html = content_type .as_ref() .map(|c| { // application/xhtml+xml is a subset of HTML let application_xhtml: Mime = "application/xhtml+xml".parse::().unwrap_or(TEXT_HTML); let allowed_mime_types = [TEXT_HTML.essence_str(), application_xhtml.essence_str()]; allowed_mime_types.contains(&c.essence_str()) }) .unwrap_or_default(); if is_html { // Can't use .text() here, because it only checks the content header, not the actual bytes // https://github.com/LemmyNet/lemmy/issues/1964 // So we want to do deep inspection of the actually returned bytes but need to be careful // not spend too much time parsing binary data as HTML // only take first bytes regardless of how many bytes the server returns let html_bytes = collect_bytes_until_limit(response, bytes_to_fetch).await?; extract_opengraph_data(&html_bytes, url) .map_err(|e| info!("{e}")) .unwrap_or_default() } else { let is_octet_type = content_type .as_ref() .map(|c| c.subtype() == "octet-stream") .unwrap_or_default(); // Overwrite the content type if its an octet type if is_octet_type { // Don't need to fetch as much data for this as we do with opengraph let octet_bytes = collect_bytes_until_limit(response, 512).await?; content_type = infer::get(&octet_bytes).map_or(content_type, |t| t.mime_type().parse().ok()); } Default::default() } }; Ok(LinkMetadata { opengraph_data, content_type: content_type.map(|c| c.to_string()), }) } fn v4_is_invalid(v4: Ipv4Addr) -> bool { v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_multicast() || v4.is_documentation() || v4.is_unspecified() || v4.is_broadcast() } fn v6_is_invalid(v6: Ipv6Addr) -> bool { let is_documentation = matches!( v6.segments(), [0x2001, 0xdb8, ..] | [0x3fff, 0..=0x0fff, ..] ); is_documentation || v6.is_loopback() || v6.is_multicast() || v6.is_unique_local() || v6.is_unicast_link_local() || v6.is_unspecified() || v6.to_ipv4_mapped().is_some_and(v4_is_invalid) } async fn collect_bytes_until_limit( response: Response, requested_bytes: usize, ) -> Result, LemmyError> { let mut stream = response.bytes_stream(); let mut bytes = Vec::with_capacity(requested_bytes); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(LemmyError::from)?; // we may go over the requested size here but the important part is we don't keep aggregating // more chunks than needed bytes.extend_from_slice(&chunk); if bytes.len() >= requested_bytes { bytes.truncate(requested_bytes); break; } } Ok(bytes) } /// Generates and saves a post thumbnail and metadata. /// /// Takes a callback to generate a send activity task, so that post can be federated with metadata. /// /// TODO: `federated_thumbnail` param can be removed once we federate full metadata and can /// write it to db directly, without calling this function. /// https://github.com/LemmyNet/lemmy/issues/4598 pub async fn generate_post_link_metadata( post: Post, custom_thumbnail: Option, send_activity: impl FnOnce(Post) -> Option + Send + 'static, context: Data, ) -> LemmyResult<()> { let metadata = match &post.url { Some(url) => fetch_link_metadata(url, &context, false) .await .unwrap_or_default(), _ => Default::default(), }; let is_image_post = metadata .content_type .as_ref() .is_some_and(|content_type| content_type.starts_with("image")); // Decide if we are allowed to generate local thumbnail let SiteView { site, local_site, .. } = SiteView::read_local(&mut context.pool()).await?; let allow_sensitive = site.content_warning.is_some(); let allow_generate_thumbnail = allow_sensitive || !post.nsfw; // Proxy the post url itself if it is an image let url = if let (true, Some(url)) = (is_image_post, post.url.clone()) { Some(Some( proxy_image_link(url.into(), &local_site, false, &context).await?, )) } else { None }; let image_url = if is_image_post { post.url.clone() } else { metadata.opengraph_data.image.clone() }; // Attempt to generate a thumbnail depending on the instance settings. Either by proxying, // storing image persistently in pict-rs or returning the remote url directly as thumbnail. let thumbnail_url = if let (false, Some(url)) = (is_image_post, custom_thumbnail) { proxy_image_link(url.clone(), &local_site, true, &context) .await .map_err(|e| warn!("Failed to proxy thumbnail: {e}")) .ok() .or(Some(url.into())) } else if let (true, Some(url)) = (allow_generate_thumbnail, image_url.clone()) { generate_pictrs_thumbnail(&post, &url, &local_site, &context) .await .map_err(|e| warn!("Failed to generate thumbnail: {e}")) .ok() .map(Into::into) .or(image_url) } else { image_url.clone() }; let form = PostUpdateForm { url, embed_title: Some(metadata.opengraph_data.title), embed_description: Some(metadata.opengraph_data.description), embed_video_url: Some(metadata.opengraph_data.embed_video_url), embed_video_width: Some(metadata.opengraph_data.video_width.map(i32::from)), embed_video_height: Some(metadata.opengraph_data.video_height.map(i32::from)), thumbnail_url: Some(thumbnail_url), url_content_type: Some(metadata.content_type), ..Default::default() }; let updated_post = Post::update(&mut context.pool(), post.id, &form).await?; if let Some(send_activity) = send_activity(updated_post) { ActivityChannel::submit_activity(send_activity, &context)?; } Ok(()) } /// Extract site metadata from HTML Opengraph attributes. fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult { let html = String::from_utf8_lossy(html_bytes); let mut page = HTML::from_string(html.to_string(), None)?; // If the web page specifies that it isn't actually UTF-8, re-decode the received bytes with the // proper encoding. If the specified encoding cannot be found, fall back to the original UTF-8 // version. if let Some(charset) = page.meta.get("charset") && charset != UTF_8.name() && let Some(encoding) = Encoding::for_label(charset.as_bytes()) { page = HTML::from_string(encoding.decode(html_bytes).0.into(), None)?; } let page_title = page.title; let page_description = page.description; let og_description = page .opengraph .properties .get("description") .map(std::string::ToString::to_string); let og_title = page .opengraph .properties .get("title") .map(std::string::ToString::to_string); let og_image = page .opengraph .images .first() .filter(|v| !v.url.is_empty()) // join also works if the target URL is absolute .and_then(|ogo| url.join(&ogo.url).ok()); let (og_image_width, og_image_height) = extract_opengraph_width_and_height(page.opengraph.images.first()); let og_embed_url = page .opengraph .videos .first() // Sometime sites provide `og:video` tags with empty content .filter(|v| !v.url.is_empty()) // join also works if the target URL is absolute .and_then(|v| url.join(&v.url).ok()); let (og_video_width, og_video_height) = extract_opengraph_width_and_height(page.opengraph.videos.first()); Ok(OpenGraphData { title: og_title.or(page_title), description: og_description.or(page_description), image: og_image.map(Into::into), image_width: og_image_width, image_height: og_image_height, embed_video_url: og_embed_url.map(Into::into), video_width: og_video_width, video_height: og_video_height, }) } fn extract_opengraph_width_and_height(ogo: Option<&OpengraphObject>) -> (Option, Option) { ( ogo.and_then(|ogo| extract_opengraph_int_field(ogo, "width")), ogo.and_then(|ogo| extract_opengraph_int_field(ogo, "height")), ) } fn extract_opengraph_int_field(ogo: &OpengraphObject, field: &str) -> Option { let w = ogo.properties.get(field)?; w.parse::().ok() } #[derive(Deserialize, Serialize, Debug)] pub struct PictrsResponse { #[serde(default)] pub files: Vec, pub msg: String, } #[derive(Deserialize, Serialize, Debug)] pub struct PictrsFile { pub file: String, pub details: PictrsFileDetails, } impl PictrsFile { pub fn image_url(&self, protocol_and_hostname: &str) -> Result { Url::parse(&format!( "{protocol_and_hostname}/api/v4/image/{}", self.file )) } } /// Stores extra details about a Pictrs image. #[derive(Deserialize, Serialize, Debug)] pub struct PictrsFileDetails { /// In pixels pub width: u16, /// In pixels pub height: u16, pub content_type: String, pub created_at: DateTime, pub blurhash: Option, } impl PictrsFileDetails { /// Builds the image form. This should always use the thumbnail_url, /// Because the post_view joins to it pub fn build_image_details_form(&self, thumbnail_url: &Url) -> ImageDetailsInsertForm { ImageDetailsInsertForm { link: thumbnail_url.clone().into(), width: self.width.into(), height: self.height.into(), content_type: self.content_type.clone(), blurhash: self.blurhash.clone(), } } } #[derive(Deserialize, Serialize, Debug)] struct PictrsPurgeResponse { msg: String, aliases: Vec, } /// Purges an image from pictrs /// Note: This should often be coerced from a Result to .ok() in order to fail softly, because: /// - It might fail due to image being not local /// - It might not be an image /// - Pictrs might not be set up pub async fn purge_image_from_pictrs_url( image_url: &Url, context: &LemmyContext, ) -> LemmyResult<()> { is_image_content_type(context.pictrs_client(), image_url).await?; let alias = image_url .path_segments() .ok_or(UntranslatedError::PurgeInvalidImageUrl)? .next_back() .ok_or(UntranslatedError::PurgeInvalidImageUrl)?; purge_image_from_pictrs(alias, context).await } pub async fn purge_image_from_pictrs(alias: &str, context: &LemmyContext) -> LemmyResult<()> { let pictrs_config = context.settings().pictrs()?; let purge_url = format!("{}internal/purge?alias={}", pictrs_config.url, alias); let pictrs_api_key = pictrs_config .api_key .ok_or(LemmyErrorType::PictrsApiKeyNotProvided)?; let response = context .pictrs_client() .post(&purge_url) .timeout(REQWEST_TIMEOUT) .header("x-api-token", pictrs_api_key) .send() .await? .error_for_status()?; let response: PictrsPurgeResponse = response.json().await.map_err(LemmyError::from)?; // Pictrs purges return all aliases. let aliases = response.aliases; // Delete db rows of aliases. LocalImage::delete_by_aliases(&mut context.pool(), &aliases) .await .ok(); match response.msg.as_str() { "ok" => Ok(()), _ => Err(LemmyErrorType::PictrsPurgeResponseError(response.msg).into()), } } /// Deletes an alias for an image from the local db and pictrs. If it's not the last / only alias, /// the image might remain. /// /// # Security Warning /// This is a low-level function that doesn't check if the user is allowed to delete the image /// alias. Callers MUST check if the user has permission to delete the alias /// before calling this function (the user is an admin or the image belongs to the user). pub async fn delete_image_alias(alias: &str, context: &LemmyContext) -> LemmyResult<()> { let pictrs_config = context.settings().pictrs()?; let url = format!("{}internal/delete?alias={}", pictrs_config.url, &alias); // Send the delete request to pictrs. context .pictrs_client() .post(&url) .header("X-Api-Token", pictrs_config.api_key.unwrap_or_default()) .timeout(REQWEST_TIMEOUT) .send() .await? .error_for_status()?; // Delete db row if any (old Lemmy versions didn't generate this). LocalImage::delete_by_alias(&mut context.pool(), alias) .await .ok(); Ok(()) } /// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url. async fn generate_pictrs_thumbnail( post: &Post, image_url: &Url, local_site: &LocalSite, context: &LemmyContext, ) -> LemmyResult { match local_site.image_mode { ImageMode::None => return Ok(image_url.clone()), ImageMode::ProxyAllImages => { return Ok( proxy_image_link(image_url.clone(), local_site, true, context) .await? .into(), ); } _ => {} }; // fetch remote non-pictrs images for persistent thumbnail link let fetch_url = format!( "{}image/download?url={}&resize={}", context.settings().pictrs()?.url, encode(image_url.as_str()), local_site.image_max_thumbnail_size ); let res = context .pictrs_client() .get(&fetch_url) .timeout(REQWEST_TIMEOUT) .send() .await? .error_for_status()? .json::() .await?; let image = res .files .first() .ok_or(LemmyErrorType::PictrsResponseError(res.msg))?; let form = LocalImageForm { pictrs_alias: image.file.clone(), // For thumbnails, the person_id is the post creator person_id: post.creator_id, thumbnail_for_post_id: Some(Some(post.id)), }; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); let thumbnail_url = image.image_url(&protocol_and_hostname)?; // Also store the details for the image let details_form = image.details.build_image_details_form(&thumbnail_url); LocalImage::create(&mut context.pool(), &form, &details_form).await?; Ok(thumbnail_url) } /// Fetches the image details for pictrs proxied images /// /// We don't need to check for image mode, as that's already been done pub async fn fetch_pictrs_proxied_image_details( image_url: &Url, context: &LemmyContext, ) -> LemmyResult { let pictrs_url = context.settings().pictrs()?.url; let encoded_image_url = encode(image_url.as_str()); // Pictrs needs you to fetch the proxied image before you can fetch the details let proxy_url = format!("{pictrs_url}image/original?proxy={encoded_image_url}"); context .pictrs_client() .get(&proxy_url) .timeout(REQWEST_TIMEOUT) .send() .await? .error_for_status() .with_lemmy_type(LemmyErrorType::NotAnImageType)?; let details_url = format!("{pictrs_url}image/details/original?proxy={encoded_image_url}"); let res = context .pictrs_client() .get(&details_url) .timeout(REQWEST_TIMEOUT) .send() .await? .error_for_status()? .json() .await?; Ok(res) } // TODO: get rid of this by reading content type from db async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> LemmyResult<()> { let response = client.get(url.as_str()).send().await?; if response .headers() .get("Content-Type") .ok_or(LemmyErrorType::NoContentTypeHeader)? .to_str()? .starts_with("image/") { Ok(()) } else { Err(LemmyErrorType::NotAnImageType.into()) } } #[cfg(test)] mod tests { use crate::{ context::LemmyContext, request::{extract_opengraph_data, fetch_link_metadata}, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; use url::Url; // These helped with testing #[tokio::test] #[serial] async fn test_link_metadata() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let sample_url = Url::parse("https://gitlab.com/IzzyOnDroid/repo/-/wikis/FAQ")?; let sample_res = fetch_link_metadata(&sample_url, &context, false).await?; assert_eq!( Some("FAQ · Wiki · IzzyOnDroid / repo · GitLab".to_string()), sample_res.opengraph_data.title ); assert_eq!( Some("The F-Droid compatible repo at https://apt.izzysoft.de/fdroid/".to_string()), sample_res.opengraph_data.description ); assert_eq!( Some( Url::parse("https://gitlab.com/uploads/-/system/project/avatar/4877469/iod_logo.png")? .into() ), sample_res.opengraph_data.image ); assert_eq!(None, sample_res.opengraph_data.embed_video_url); assert_eq!( Some(mime::TEXT_HTML_UTF_8.to_string()), sample_res.content_type ); Ok(()) } #[test] fn test_resolve_image_url() -> LemmyResult<()> { // url that lists the opengraph fields let url = Url::parse("https://example.com/one/two.html")?; // root relative url let html_bytes = b""; let metadata = extract_opengraph_data(html_bytes, &url)?; assert_eq!( metadata.image, Some(Url::parse("https://example.com/image.jpg")?.into()) ); // base relative url let html_bytes = b""; let metadata = extract_opengraph_data(html_bytes, &url)?; assert_eq!( metadata.image, Some(Url::parse("https://example.com/one/image.jpg")?.into()) ); // absolute url let html_bytes = b""; let metadata = extract_opengraph_data(html_bytes, &url)?; assert_eq!( metadata.image, Some(Url::parse("https://cdn.host.com/image.jpg")?.into()) ); // protocol relative url let html_bytes = b""; let metadata = extract_opengraph_data(html_bytes, &url)?; assert_eq!( metadata.image, Some(Url::parse("https://example.com/image.jpg")?.into()) ); // image width and height let html_bytes = b""; let metadata = extract_opengraph_data(html_bytes, &url)?; assert_eq!( (metadata.image_width, metadata.image_height), (Some(400), Some(200)) ); // Empty urls shouldn't return anything let html_bytes = b""; let metadata = extract_opengraph_data(html_bytes, &url)?; assert_eq!(metadata.image, None); Ok(()) } } ================================================ FILE: crates/api/api_utils/src/send_activity.rs ================================================ use crate::context::LemmyContext; use activitypub_federation::config::Data; use either::Either; use lemmy_db_schema::{ newtypes::CommunityId, source::{ comment::Comment, community::Community, multi_community::MultiCommunity, person::Person, post::Post, private_message::PrivateMessage, site::Site, }, }; use lemmy_db_schema_file::PersonId; use lemmy_db_views_community::api::BanFromCommunity; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_diesel_utils::dburl::DbUrl; use lemmy_utils::error::LemmyResult; use std::sync::LazyLock; use tokio::{ sync::{ Mutex, mpsc, mpsc::{UnboundedReceiver, UnboundedSender, WeakUnboundedSender}, }, task::JoinHandle, }; use url::Url; #[derive(Debug)] pub enum SendActivityData { CreatePost(Post), UpdatePost(Post), DeletePost(Post, Person, Community), RemovePost { post: Post, moderator: Person, reason: String, removed: bool, with_replies: bool, }, LockPost(Post, Person, bool, String), FeaturePost(Post, Person, bool), CreateComment(Comment), UpdateComment(Comment), DeleteComment(Comment, Person, Community), RemoveComment { comment: Comment, moderator: Person, community: Community, reason: String, with_replies: bool, }, LockComment(Comment, Person, bool, String), LikePostOrComment { object_id: DbUrl, actor: Person, community: Community, previous_is_upvote: Option, new_is_upvote: Option, }, FollowCommunity(Community, Person, bool), FollowMultiCommunity(MultiCommunity, Person, bool), AcceptFollower(CommunityId, PersonId), RejectFollower(CommunityId, PersonId), UpdateCommunity(Person, Community), DeleteCommunity(Person, Community, bool), RemoveCommunity { moderator: Person, community: Community, reason: String, removed: bool, }, AddModToCommunity { moderator: Person, community_id: CommunityId, target: PersonId, added: bool, }, BanFromCommunity { moderator: Person, community_id: CommunityId, target: Person, data: BanFromCommunity, }, BanFromSite { moderator: Person, banned_user: Person, reason: String, remove_or_restore_data: Option, ban: bool, expires_at: Option, }, CreatePrivateMessage(PrivateMessageView), UpdatePrivateMessage(PrivateMessageView), DeletePrivateMessage(Person, PrivateMessage, bool), DeleteUser(Person, bool), CreateReport { object_id: Url, actor: Person, receiver: Either, reason: String, }, SendResolveReport { object_id: Url, actor: Person, report_creator: Person, receiver: Either, }, UpdateMultiCommunity(MultiCommunity, Person), } // TODO: instead of static, move this into LemmyContext. make sure that stopping the process with // ctrl+c still works. static ACTIVITY_CHANNEL: LazyLock = LazyLock::new(|| { let (sender, receiver) = mpsc::unbounded_channel(); let weak_sender = sender.downgrade(); ActivityChannel { weak_sender, receiver: Mutex::new(receiver), keepalive_sender: Mutex::new(Some(sender)), } }); pub struct ActivityChannel { weak_sender: WeakUnboundedSender, receiver: Mutex>, keepalive_sender: Mutex>>, } impl ActivityChannel { pub async fn retrieve_activity() -> Option { let mut lock = ACTIVITY_CHANNEL.receiver.lock().await; lock.recv().await } pub fn submit_activity(data: SendActivityData, _context: &Data) -> LemmyResult<()> { // could do `ACTIVITY_CHANNEL.keepalive_sender.lock()` instead and get rid of weak_sender, // not sure which way is more efficient if let Some(sender) = ACTIVITY_CHANNEL.weak_sender.upgrade() { sender.send(data)?; } Ok(()) } pub async fn close(outgoing_activities_task: JoinHandle<()>) -> LemmyResult<()> { ACTIVITY_CHANNEL.keepalive_sender.lock().await.take(); outgoing_activities_task.await?; Ok(()) } } ================================================ FILE: crates/api/api_utils/src/utils.rs ================================================ use crate::{ claims::Claims, context::LemmyContext, request::{delete_image_alias, fetch_pictrs_proxied_image_details, purge_image_from_pictrs_url}, }; use actix_web::{HttpRequest, http::header::Header}; use actix_web_httpauth::headers::authorization::{Authorization, Bearer}; use chrono::{DateTime, Days, Local, TimeZone, Utc}; use enum_map::{EnumMap, enum_map}; use lemmy_db_schema::{ newtypes::{CommunityId, CommunityTagId, ModlogId, PostId, PostOrCommentId}, source::{ comment::{Comment, CommentActions, CommentLikeForm}, community::{Community, CommunityActions, CommunityUpdateForm}, community_tag::{CommunityTag, PostCommunityTag}, images::{ImageDetails, RemoteImage}, instance::InstanceActions, local_site::LocalSite, local_site_rate_limit::LocalSiteRateLimit, local_site_url_blocklist::LocalSiteUrlBlocklist, modlog::{Modlog, ModlogInsertForm}, oauth_account::OAuthAccount, person::{Person, PersonUpdateForm}, post::{Post, PostActions, PostLikeForm, PostReadCommentsForm}, private_message::PrivateMessage, registration_application::RegistrationApplication, site::Site, }, traits::Likeable, }; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::{FederationMode, ImageMode, RegistrationMode}, }; use lemmy_db_views_community_follower_approval::PendingFollowerView; use lemmy_db_views_community_moderator::{CommunityModeratorView, CommunityPersonBanView}; use lemmy_db_views_local_image::LocalImageView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{connection::DbPool, dburl::DbUrl, traits::Crud}; use lemmy_utils::{ CACHE_DURATION_FEDERATION, CacheLock, MAX_COMMENT_DEPTH_LIMIT, error::{ LemmyError, LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult, UntranslatedError, }, rate_limit::{ActionType, BucketConfig}, settings::SETTINGS, spawn_try_task, utils::{ markdown::{image_links::markdown_rewrite_image_links, markdown_check_for_blocked_urls}, slurs::remove_slurs, validation::{build_and_check_regex, clean_urls_in_text}, }, }; use moka::future::Cache; use regex::{Regex, RegexSet, escape}; use std::{collections::HashSet, sync::LazyLock}; use tracing::Instrument; use url::{ParseError, Url}; use urlencoding::encode; use webmention::{Webmention, WebmentionError}; pub const AUTH_COOKIE_NAME: &str = "jwt"; pub async fn check_is_mod_or_admin( pool: &mut DbPool<'_>, person_id: PersonId, community_id: CommunityId, ) -> LemmyResult<()> { let is_mod = CommunityModeratorView::check_is_community_moderator(pool, community_id, person_id) .await .is_ok(); let is_admin = LocalUserView::read_person(pool, person_id) .await .is_ok_and(|t| t.local_user.admin); if is_mod || is_admin { Ok(()) } else { Err(LemmyErrorType::NotAModOrAdmin.into()) } } /// Checks if a person is an admin, or moderator of any community. pub(crate) async fn check_is_mod_of_any_or_admin( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult<()> { let is_mod_of_any = CommunityModeratorView::is_community_moderator_of_any(pool, person_id) .await .is_ok(); let is_admin = LocalUserView::read_person(pool, person_id) .await .is_ok_and(|t| t.local_user.admin); if is_mod_of_any || is_admin { Ok(()) } else { Err(LemmyErrorType::NotAModOrAdmin.into()) } } pub async fn is_mod_or_admin( pool: &mut DbPool<'_>, local_user_view: &LocalUserView, community_id: CommunityId, ) -> LemmyResult<()> { check_local_user_valid(local_user_view)?; check_is_mod_or_admin(pool, local_user_view.person.id, community_id).await } pub async fn is_mod_or_admin_opt( pool: &mut DbPool<'_>, local_user_view: Option<&LocalUserView>, community_id: Option, ) -> LemmyResult<()> { if let Some(local_user_view) = local_user_view { if let Some(community_id) = community_id { is_mod_or_admin(pool, local_user_view, community_id).await } else { is_admin(local_user_view) } } else { Err(LemmyErrorType::NotAModOrAdmin.into()) } } /// Check that a person is either a mod of any community, or an admin /// /// Should only be used for read operations pub async fn check_community_mod_of_any_or_admin_action( local_user_view: &LocalUserView, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let person = &local_user_view.person; check_local_user_valid(local_user_view)?; check_is_mod_of_any_or_admin(pool, person.id).await } pub fn is_admin(local_user_view: &LocalUserView) -> LemmyResult<()> { check_local_user_valid(local_user_view)?; if !local_user_view.local_user.admin { Err(LemmyErrorType::NotAnAdmin.into()) } else { Ok(()) } } pub fn is_top_mod( local_user_view: &LocalUserView, community_mods: &[CommunityModeratorView], ) -> LemmyResult<()> { check_local_user_valid(local_user_view)?; if local_user_view.person.id != community_mods .first() .map(|cm| cm.moderator.id) .unwrap_or(PersonId(0)) { Err(LemmyErrorType::NotTopMod.into()) } else { Ok(()) } } /// Updates the read comment count for a post. Usually done when reading or creating a new comment. pub async fn update_read_comments( person_id: PersonId, post_id: PostId, read_comments: i32, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let person_post_agg_form = PostReadCommentsForm::new(post_id, person_id, read_comments); PostActions::update_read_comments(pool, &person_post_agg_form).await?; Ok(()) } pub fn check_local_user_valid(local_user_view: &LocalUserView) -> LemmyResult<()> { // Check for a site ban if local_user_view.banned { return Err(LemmyErrorType::SiteBan.into()); } check_local_user_deleted(local_user_view) } /// Check for account deletion pub fn check_local_user_deleted(local_user_view: &LocalUserView) -> LemmyResult<()> { if local_user_view.person.deleted { Err(LemmyErrorType::Deleted.into()) } else { Ok(()) } } /// Check if the user's email is verified if email verification is turned on /// However, skip checking verification if the user is an admin pub fn check_email_verified( local_user_view: &LocalUserView, site_view: &SiteView, ) -> LemmyResult<()> { if !local_user_view.local_user.admin && site_view.local_site.require_email_verification && !local_user_view.local_user.email_verified { return Err(LemmyErrorType::EmailNotVerified.into()); } Ok(()) } pub async fn check_registration_application( local_user_view: &LocalUserView, local_site: &LocalSite, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { if (local_site.registration_mode == RegistrationMode::RequireApplication || local_site.registration_mode == RegistrationMode::Closed) && !local_user_view.local_user.accepted_application && !local_user_view.local_user.admin { // Fetch the registration application. If no admin id is present its still pending. Otherwise it // was processed (either accepted or denied). let local_user_id = local_user_view.local_user.id; let registration = RegistrationApplication::find_by_local_user_id(pool, local_user_id).await?; if registration.admin_id.is_some() { return Err( LemmyErrorType::RegistrationDenied(registration.deny_reason.unwrap_or_default()).into(), ); } else { return Err(LemmyErrorType::RegistrationApplicationIsPending.into()); } } Ok(()) } /// Checks that a normal user action (eg posting or voting) is allowed in a given community. /// /// In particular it checks that neither the user nor community are banned or deleted, and that /// the user isn't banned. pub async fn check_community_user_action( local_user_view: &LocalUserView, community: &Community, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { check_local_user_valid(local_user_view)?; check_community_deleted_removed(community)?; CommunityPersonBanView::check(pool, local_user_view.person.id, community.id).await?; PendingFollowerView::check_private_community_action(pool, local_user_view.person.id, community) .await?; InstanceActions::check_ban(pool, local_user_view.person.id, community.instance_id).await?; Ok(()) } pub fn check_community_deleted_removed(community: &Community) -> LemmyResult<()> { if community.deleted || community.removed { return Err(LemmyErrorType::Deleted.into()); } Ok(()) } /// Check that the given user can perform a mod action in the community. /// /// In particular it checks that they're an admin or mod, wasn't banned and the community isn't /// removed/deleted. pub async fn check_community_mod_action( local_user_view: &LocalUserView, community: &Community, allow_deleted: bool, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { is_mod_or_admin(pool, local_user_view, community.id).await?; CommunityPersonBanView::check(pool, local_user_view.person.id, community.id).await?; // it must be possible to restore deleted community if !allow_deleted { check_community_deleted_removed(community)?; } Ok(()) } /// Don't allow creating reports for removed / deleted posts pub fn check_post_deleted_or_removed(post: &Post) -> LemmyResult<()> { if post.deleted || post.removed { Err(LemmyErrorType::Deleted.into()) } else { Ok(()) } } pub fn check_comment_deleted_or_removed(comment: &Comment) -> LemmyResult<()> { if comment.deleted || comment.removed { Err(LemmyErrorType::Deleted.into()) } else { Ok(()) } } pub async fn check_local_vote_mode( is_upvote: Option, post_or_comment_id: PostOrCommentId, local_site: &LocalSite, person_id: PersonId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let (downvote_setting, upvote_setting) = match post_or_comment_id { PostOrCommentId::Post(_) => (local_site.post_downvotes, local_site.post_upvotes), PostOrCommentId::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes), }; let downvote_fail = is_upvote == Some(false) && downvote_setting == FederationMode::Disable; let upvote_fail = is_upvote == Some(true) && upvote_setting == FederationMode::Disable; // Undo previous vote for item if new vote fails if downvote_fail || upvote_fail { match post_or_comment_id { PostOrCommentId::Post(post_id) => { let form = PostLikeForm::new(post_id, person_id, None); PostActions::like(pool, &form).await?; } PostOrCommentId::Comment(comment_id) => { let form = CommentLikeForm::new(comment_id, person_id, None); CommentActions::like(pool, &form).await?; } }; } Ok(()) } /// Dont allow bots to do certain actions, like voting pub fn check_bot_account(person: &Person) -> LemmyResult<()> { if person.bot_account { Err(LemmyErrorType::InvalidBotAction.into()) } else { Ok(()) } } pub fn check_private_instance( local_user_view: &Option, local_site: &LocalSite, ) -> LemmyResult<()> { if local_user_view.is_none() && local_site.private_instance { Err(LemmyErrorType::InstanceIsPrivate.into()) } else { Ok(()) } } /// If private messages are disabled, dont allow them to be sent / received pub fn check_private_messages_enabled(local_user_view: &LocalUserView) -> Result<(), LemmyError> { if !local_user_view.local_user.enable_private_messages { Err(LemmyErrorType::CouldntCreate.into()) } else { Ok(()) } } /// Checks the password length pub fn password_length_check(pass: &str) -> LemmyResult<()> { if !(10..=60).contains(&pass.chars().count()) { Err(LemmyErrorType::InvalidPassword.into()) } else { Ok(()) } } /// Checks for a honeypot. If this field is filled, fail the rest of the function pub fn honeypot_check(honeypot: &Option) -> LemmyResult<()> { if honeypot.is_some() && honeypot != &Some(String::new()) { Err(LemmyErrorType::HoneypotFailed.into()) } else { Ok(()) } } pub fn local_site_rate_limit_to_rate_limit_config( l: &LocalSiteRateLimit, ) -> EnumMap { enum_map! { ActionType::Message => (l.message_max_requests, l.message_interval_seconds), ActionType::Post => (l.post_max_requests, l.post_interval_seconds), ActionType::Register => (l.register_max_requests, l.register_interval_seconds), ActionType::Image => (l.image_max_requests, l.image_interval_seconds), ActionType::Comment => (l.comment_max_requests, l.comment_interval_seconds), ActionType::Search => (l.search_max_requests, l.search_interval_seconds), ActionType::ImportUserSettings => (l.import_user_settings_max_requests, l.import_user_settings_interval_seconds), } .map(|_key, (max_requests, interval)| BucketConfig { max_requests: u32::try_from(max_requests).unwrap_or(0), interval: u32::try_from(interval).unwrap_or(0), }) } pub async fn slur_regex(context: &LemmyContext) -> LemmyResult { static CACHE: CacheLock = LazyLock::new(|| { Cache::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_FEDERATION) .build() }); Ok( CACHE .try_get_with((), async { let local_site = SiteView::read_local(&mut context.pool()) .await .ok() .map(|s| s.local_site); build_and_check_regex(local_site.and_then(|s| s.slur_filter_regex).as_deref()) }) .await .map_err(|e| anyhow::anyhow!("Failed to construct regex: {e}"))?, ) } pub async fn get_url_blocklist(context: &LemmyContext) -> LemmyResult { static URL_BLOCKLIST: CacheLock = LazyLock::new(|| { Cache::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_FEDERATION) .build() }); Ok( URL_BLOCKLIST .try_get_with::<_, LemmyError>((), async { let urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?; // The urls are already validated on saving, so just escape them. // If this regex creation changes it must be synced with // lemmy_utils::utils::markdown::create_url_blocklist_test_regex_set. let regexes = urls.iter().map(|url| format!(r"\b{}\b", escape(&url.url))); let set = RegexSet::new(regexes)?; Ok(set) }) .await .map_err(|e| anyhow::anyhow!("Failed to build URL blocklist due to `{}`", e))?, ) } // `local_site` is optional so that tests work easily pub fn check_nsfw_allowed(nsfw: Option, local_site: Option<&LocalSite>) -> LemmyResult<()> { let is_nsfw = nsfw.unwrap_or_default(); let nsfw_disallowed = local_site.is_some_and(|s| s.disallow_nsfw_content); if nsfw_disallowed && is_nsfw { return Err(LemmyErrorType::NsfwNotAllowed.into()); } Ok(()) } /// Read the site for an ap_id. /// /// Used for GetCommunityResponse and GetPersonDetails pub async fn read_site_for_actor( ap_id: DbUrl, context: &LemmyContext, ) -> LemmyResult> { let site_id = Site::instance_ap_id_from_url(ap_id.clone().into()); let site = Site::read_from_apub_id(&mut context.pool(), &site_id.into()).await?; Ok(site) } pub async fn purge_post_images( url: Option, thumbnail_url: Option, context: &LemmyContext, ) { if let Some(url) = url { purge_image_from_pictrs_url(&url, context).await.ok(); } if let Some(thumbnail_url) = thumbnail_url { purge_image_from_pictrs_url(&thumbnail_url, context) .await .ok(); } } /// Delete local images attributed to a person fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) { let context_ = context.clone(); spawn_try_task(async move { let pictrs_uploads = LocalImageView::get_all_by_person_id(&mut context_.pool(), person_id).await?; // Delete their images for upload in pictrs_uploads { delete_image_alias(&upload.local_image.pictrs_alias, &context_) .await .ok(); } Ok(()) }); } /// Removes or restores user data. pub async fn remove_or_restore_user_data( mod_person_id: PersonId, banned_person_id: PersonId, removed: bool, reason: &str, bulk_action_parent_id: ModlogId, context: &LemmyContext, ) -> LemmyResult<()> { let pool = &mut context.pool(); // These actions are only possible when removing, not restoring if removed { delete_local_user_images(banned_person_id, context); // Update the fields to None Person::update( pool, banned_person_id, &PersonUpdateForm { avatar: Some(None), banner: Some(None), bio: Some(None), ..Default::default() }, ) .await?; // Communities // Remove all communities where they're the top mod // for now, remove the communities manually let first_mod_communities = CommunityModeratorView::get_community_first_mods(pool).await?; // Filter to only this banned users top communities let banned_user_first_communities: Vec = first_mod_communities .into_iter() .filter(|fmc| fmc.moderator.id == banned_person_id) .collect(); for first_mod_community in banned_user_first_communities { let community_id = first_mod_community.community.id; Community::update( pool, community_id, &CommunityUpdateForm { removed: Some(removed), ..Default::default() }, ) .await?; // Update the fields to None Community::update( pool, community_id, &CommunityUpdateForm { icon: Some(None), banner: Some(None), ..Default::default() }, ) .await?; } // Remove post and comment votes PostActions::remove_all_likes(pool, banned_person_id).await?; CommentActions::remove_all_likes(pool, banned_person_id).await?; } // Posts let removed_or_restored_posts = Post::update_removed_for_creator(pool, banned_person_id, removed).await?; create_modlog_entries_for_removed_or_restored_posts( pool, mod_person_id, &removed_or_restored_posts, removed, reason, bulk_action_parent_id, ) .await?; // Comments let removed_or_restored_comments = Comment::update_removed_for_creator(pool, banned_person_id, removed).await?; create_modlog_entries_for_removed_or_restored_comments( pool, mod_person_id, &removed_or_restored_comments, removed, reason, bulk_action_parent_id, ) .await?; // Private messages PrivateMessage::update_removed_for_creator(pool, banned_person_id, removed).await?; Ok(()) } async fn create_modlog_entries_for_removed_or_restored_posts( pool: &mut DbPool<'_>, mod_person_id: PersonId, posts: &[Post], removed: bool, reason: &str, bulk_action_parent_id: ModlogId, ) -> LemmyResult<()> { // Build the forms let forms: Vec<_> = posts .iter() .map(|post| { ModlogInsertForm::mod_remove_post( mod_person_id, post, removed, reason, Some(bulk_action_parent_id), ) }) .collect(); Modlog::create(pool, &forms).await?; Ok(()) } async fn create_modlog_entries_for_removed_or_restored_comments( pool: &mut DbPool<'_>, mod_person_id: PersonId, comments: &[Comment], removed: bool, reason: &str, bulk_action_parent_id: ModlogId, ) -> LemmyResult<()> { let mut forms: Vec = Vec::new(); for comment in comments { // This is extremely unfortunate, but since the comment table doesn't have community id, // you need to query the post table to get each of them, as they could be in any community let community_id = Post::read(pool, comment.post_id).await?.community_id; let form = ModlogInsertForm::mod_remove_comment( mod_person_id, comment, community_id, removed, reason, Some(bulk_action_parent_id), ); forms.push(form); } Modlog::create(pool, &forms).await?; Ok(()) } async fn create_modlog_entries_for_removed_or_restored_comments_in_community( pool: &mut DbPool<'_>, mod_person_id: PersonId, comments: &[Comment], community_id: CommunityId, removed: bool, reason: &str, bulk_action_parent_id: ModlogId, ) -> LemmyResult<()> { // Build the forms let forms: Vec<_> = comments .iter() .map(|comment| { ModlogInsertForm::mod_remove_comment( mod_person_id, comment, community_id, removed, reason, Some(bulk_action_parent_id), ) }) .collect(); Modlog::create(pool, &forms).await?; Ok(()) } pub async fn remove_or_restore_user_data_in_community( community_id: CommunityId, mod_person_id: PersonId, banned_person_id: PersonId, remove: bool, reason: &str, bulk_action_parent_id: ModlogId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { // These actions are only possible when removing, not restoring if remove { // Remove post and comment votes PostActions::remove_likes_in_community(pool, banned_person_id, community_id).await?; CommentActions::remove_likes_in_community(pool, banned_person_id, community_id).await?; } // Posts let posts = Post::update_removed_for_creator_and_community(pool, banned_person_id, community_id, remove) .await?; create_modlog_entries_for_removed_or_restored_posts( pool, mod_person_id, &posts, remove, reason, bulk_action_parent_id, ) .await?; // Comments let removed_comments = Comment::update_removed_for_creator_and_community(pool, banned_person_id, community_id, remove) .await?; create_modlog_entries_for_removed_or_restored_comments_in_community( pool, mod_person_id, &removed_comments, community_id, remove, reason, bulk_action_parent_id, ) .await?; Ok(()) } pub async fn purge_user_account( person_id: PersonId, local_instance_id: InstanceId, context: &LemmyContext, ) -> LemmyResult<()> { let pool = &mut context.pool(); // Delete their local images, if they're a local user // No need to update avatar and banner, those are handled in Person::delete_account delete_local_user_images(person_id, context); // Comments Comment::permadelete_for_creator(pool, person_id) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; // Posts Post::permadelete_for_creator(pool, person_id) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; // Leave communities they mod CommunityActions::leave_mod_team_for_all_communities(pool, person_id).await?; // Delete the oauth accounts linked to the local user if let Ok(local_user) = LocalUserView::read_person(pool, person_id).await { OAuthAccount::delete_user_accounts(pool, local_user.local_user.id).await?; } Person::delete_account(pool, person_id, local_instance_id).await?; Ok(()) } pub fn generate_followers_url(ap_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{ap_id}/followers"))?.into()) } pub fn generate_inbox_url() -> LemmyResult { let url = format!("{}/inbox", SETTINGS.get_protocol_and_hostname()); Ok(Url::parse(&url)?.into()) } pub fn generate_outbox_url(ap_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{ap_id}/outbox"))?.into()) } pub fn generate_featured_url(ap_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{ap_id}/featured"))?.into()) } pub fn generate_moderators_url(community_id: &DbUrl) -> LemmyResult { Ok(Url::parse(&format!("{community_id}/moderators"))?.into()) } /// Ensure that ban/block expiry is in valid range. If its in past, throw error. If its more /// than 10 years in future, convert to permanent ban. Otherwise return the same value. pub fn check_expire_time(expires_unix_opt: Option) -> LemmyResult>> { if let Some(expires_unix) = expires_unix_opt { let expires = Utc .timestamp_opt(expires_unix, 0) .single() .ok_or(LemmyErrorType::InvalidUnixTime)?; limit_expire_time(expires) } else { Ok(None) } } fn limit_expire_time(expires: DateTime) -> LemmyResult>> { const MAX_BAN_TERM: Days = Days::new(10 * 365); if expires < Local::now() { Err(LemmyErrorType::BanExpirationInPast.into()) } else if expires > Local::now() + MAX_BAN_TERM { Ok(None) } else { Ok(Some(expires)) } } pub fn check_conflicting_like_filters( liked_only: Option, disliked_only: Option, ) -> LemmyResult<()> { if liked_only.unwrap_or_default() && disliked_only.unwrap_or_default() { Err(LemmyErrorType::ContradictingFilters.into()) } else { Ok(()) } } pub async fn process_markdown( text: &str, slur_regex: &Regex, url_blocklist: &RegexSet, local_site: &LocalSite, context: &LemmyContext, ) -> LemmyResult { let text = remove_slurs(text, slur_regex); let text = clean_urls_in_text(&text); markdown_check_for_blocked_urls(&text, url_blocklist)?; if local_site.image_mode == ImageMode::ProxyAllImages { let (text, links) = markdown_rewrite_image_links(text); RemoteImage::create(&mut context.pool(), links.clone()).await?; // Create images and image detail rows for link in links { // Insert image details for the remote image let details_res = fetch_pictrs_proxied_image_details(&link, context).await; if let Ok(details) = details_res { let proxied = build_proxied_image_url(&link, false, local_site, context)?; let details_form = details.build_image_details_form(&proxied); ImageDetails::create(&mut context.pool(), &details_form).await?; } } Ok(text) } else { Ok(text) } } pub async fn process_markdown_opt( text: &Option, slur_regex: &Regex, url_blocklist: &RegexSet, local_site: &LocalSite, context: &LemmyContext, ) -> LemmyResult> { match text { Some(t) => process_markdown(t, slur_regex, url_blocklist, local_site, context) .await .map(Some), None => Ok(None), } } /// A wrapper for `proxy_image_link` for use in tests. /// /// The parameter `force_image_proxy` is the config value of `pictrs.image_proxy`. Its necessary to /// pass as separate parameter so it can be changed in tests. async fn proxy_image_link_internal( link: Url, local_site: &LocalSite, is_thumbnail: bool, context: &LemmyContext, ) -> LemmyResult { // Dont rewrite links pointing to local domain. if link.domain() == Some(&context.settings().hostname) { Ok(link.into()) } else if local_site.image_mode == ImageMode::ProxyAllImages { RemoteImage::create(&mut context.pool(), vec![link.clone()]).await?; let proxied = build_proxied_image_url(&link, is_thumbnail, local_site, context)?; // This should fail softly, since pictrs might not even be running let details_res = fetch_pictrs_proxied_image_details(&link, context).await; if let Ok(details) = details_res { let details_form = details.build_image_details_form(&proxied); ImageDetails::create(&mut context.pool(), &details_form).await?; }; Ok(proxied.into()) } else { Ok(link.into()) } } /// Rewrite a link to go through `/api/v4/image_proxy` endpoint. This is only for remote urls and /// if image_proxy setting is enabled. pub async fn proxy_image_link( link: Url, local_site: &LocalSite, is_thumbnail: bool, context: &LemmyContext, ) -> LemmyResult { proxy_image_link_internal(link, local_site, is_thumbnail, context).await } pub async fn proxy_image_link_opt_apub( link: Option, local_site: &LocalSite, context: &LemmyContext, ) -> LemmyResult> { if let Some(l) = link { proxy_image_link(l, local_site, false, context) .await .map(Some) } else { Ok(None) } } fn build_proxied_image_url( link: &Url, is_thumbnail: bool, local_site: &LocalSite, context: &LemmyContext, ) -> LemmyResult { let mut url = format!( "{}/api/v4/image/proxy?url={}", context.settings().get_protocol_and_hostname(), encode(link.as_str()), ); if is_thumbnail { url = format!("{url}&max_size={}", local_site.image_max_thumbnail_size); } Ok(Url::parse(&url)?) } pub async fn local_user_view_from_jwt( jwt: &str, context: &LemmyContext, ) -> LemmyResult { let local_user_id = Claims::validate(jwt, context) .await .with_lemmy_type(LemmyErrorType::NotLoggedIn)?; let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?; check_local_user_deleted(&local_user_view)?; Ok(local_user_view) } pub fn read_auth_token(req: &HttpRequest) -> LemmyResult> { // Try reading jwt from auth header if let Ok(header) = Authorization::::parse(req) { Ok(Some(header.as_ref().token().to_string())) } // If that fails, try to read from cookie else if let Some(cookie) = &req.cookie(AUTH_COOKIE_NAME) { Ok(Some(cookie.value().to_string())) } // Otherwise, there's no auth else { Ok(None) } } pub fn send_webmention(post: Post, community: &Community) { if let Some(url) = post.url.clone() && community.visibility.can_view_without_login() { spawn_try_task(async move { let mut webmention = Webmention::new::(post.ap_id.clone().into(), url.clone().into())?; webmention.set_checked(true); match webmention .send() .instrument(tracing::info_span!("Sending webmention")) .await { Err(WebmentionError::NoEndpointDiscovered(_)) => Ok(()), Ok(_) => Ok(()), Err(e) => Err(e).with_lemmy_type(UntranslatedError::CouldntSendWebmention.into()), } }); }; } /// Returns error if new comment exceeds maximum depth. /// /// Top-level comments have a path like `0.123` where 123 is the comment id. At the second level /// it is `0.123.456`, containing the parent id and current comment id. pub fn check_comment_depth(comment: &Comment) -> LemmyResult<()> { let path = &comment.path.0; let length = path.split('.').count(); // Need to increment by one because the path always starts with 0 if length > MAX_COMMENT_DEPTH_LIMIT + 1 { Err(LemmyErrorType::MaxCommentDepthReached.into()) } else { Ok(()) } } pub async fn update_post_tags( post: &Post, community_tag_ids: &[CommunityTagId], context: &LemmyContext, ) -> LemmyResult<()> { // validate tags let community_tags = CommunityTag::read_for_community(&mut context.pool(), post.community_id) .await? .into_iter() .map(|t| t.id) .collect::>(); if !community_tags.is_superset(&community_tag_ids.iter().copied().collect()) { return Err(LemmyErrorType::TagNotInCommunity.into()); } PostCommunityTag::update(&mut context.pool(), post, community_tag_ids).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use diesel_ltree::Ltree; use lemmy_db_schema::{ newtypes::{CommentId, LanguageId}, test_data::TestData, }; use pretty_assertions::assert_eq; use serial_test::serial; #[test] #[rustfmt::skip] fn password_length() { assert!(password_length_check("Õ¼¾°3yË,o¸ãtÌÈú|ÇÁÙAøüÒI©·¤(T]/ð>æºWæ[C¤bªWöaÃÎñ·{=û³&§½K/c").is_ok()); assert!(password_length_check("1234567890").is_ok()); assert!(password_length_check("short").is_err()); assert!(password_length_check("looooooooooooooooooooooooooooooooooooooooooooooooooooooooooong").is_err()); } #[test] fn honeypot() { assert!(honeypot_check(&None).is_ok()); assert!(honeypot_check(&Some(String::new())).is_ok()); assert!(honeypot_check(&Some("1".to_string())).is_err()); assert!(honeypot_check(&Some("message".to_string())).is_err()); } #[test] fn test_limit_ban_term() -> LemmyResult<()> { // Ban expires in past, should throw error assert!(limit_expire_time(Utc::now() - Days::new(5)).is_err()); // Legitimate ban term, return same value let fourteen_days = Utc::now() + Days::new(14); assert_eq!(limit_expire_time(fourteen_days)?, Some(fourteen_days)); let nine_years = Utc::now() + Days::new(365 * 9); assert_eq!(limit_expire_time(nine_years)?, Some(nine_years)); // Too long ban term, changes to None (permanent ban) assert_eq!(limit_expire_time(Utc::now() + Days::new(365 * 11))?, None); Ok(()) } #[tokio::test] #[serial] async fn test_proxy_image_link() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let test_data = TestData::create(pool).await?; let local_site = &test_data.local_site; // image from local domain is unchanged let local_url = Url::parse("http://lemmy-alpha/image.png")?; let proxied = proxy_image_link_internal(local_url.clone(), local_site, false, &context).await?; assert_eq!(&local_url, proxied.inner()); // image from remote domain is proxied let remote_image = Url::parse("http://lemmy-beta/image.png")?; let proxied = proxy_image_link_internal(remote_image.clone(), local_site, false, &context).await?; assert_eq!( "https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Flemmy-beta%2Fimage.png", proxied.as_str() ); // This fails, because the details can't be fetched without pictrs running, // And a remote image won't be inserted. assert!( RemoteImage::validate(&mut context.pool(), remote_image.into()) .await .is_ok() ); test_data.delete(&mut context.pool()).await?; Ok(()) } #[test] fn test_comment_depth() -> LemmyResult<()> { let mut comment = Comment { id: CommentId(0), creator_id: PersonId(0), post_id: PostId(0), content: String::new(), removed: false, published_at: Utc::now(), updated_at: None, deleted: false, ap_id: Url::parse("http://example.com")?.into(), local: false, path: Ltree("0.123".to_string()), distinguished: false, language_id: LanguageId(0), score: 0, upvotes: 0, downvotes: 0, child_count: 0, hot_rank: 0.0, controversy_rank: 0.0, report_count: 0, unresolved_report_count: 0, federation_pending: false, locked: false, }; assert!(check_comment_depth(&comment).is_ok()); comment.path = Ltree("0.123.456".to_string()); assert!(check_comment_depth(&comment).is_ok()); // build path with items 1 to 50 which is still acceptable let mut path = "0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50".to_string(); comment.path = Ltree(path.clone()); assert!(check_comment_depth(&comment).is_ok()); // add one more item and we exceed the max depth path.push_str(".51"); comment.path = Ltree(path); assert!(check_comment_depth(&comment).is_err()); Ok(()) } } ================================================ FILE: crates/api/routes/Cargo.toml ================================================ [package] name = "lemmy_api_routes" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true publish = false [lib] doctest = false test = false [lints] workspace = true [features] default = [] [dependencies] lemmy_api = { workspace = true } lemmy_api_crud = { workspace = true } lemmy_utils = { workspace = true } lemmy_routes = { workspace = true } actix-web = { workspace = true } ================================================ FILE: crates/api/routes/src/lib.rs ================================================ use actix_web::{guard, web::*}; use lemmy_api::{ comment::{ distinguish::distinguish_comment, like::like_comment, list_comment_likes::list_comment_likes, lock::lock_comment, save::save_comment, warning::create_comment_warning, }, community::{ add_mod::add_mod_to_community, ban::ban_from_community, block::user_block_community, follow::follow_community, multi_community_follow::follow_multi_community, pending_follows::{approve::post_pending_follows_approve, list::get_pending_follows_list}, random::get_random_community, tag::{create_community_tag, delete_community_tag, edit_community_tag}, transfer::transfer_community, update_notifications::edit_community_notifications, }, federation::{ list_comments::{list_comments, list_comments_slim}, list_person_content::list_person_content, list_posts::list_posts, read_community::get_community, read_multi_community::read_multi_community, read_person::read_person, resolve_object::resolve_object, search::search, user_settings_backup::{export_settings, import_settings}, }, local_user::{ add_admin::add_admin, ban_person::ban_from_site, block::user_block_person, change_password::change_password, change_password_after_reset::change_password_after_reset, donation_dialog_shown::donation_dialog_shown, export_data::export_data, generate_totp_secret::generate_totp_secret, get_captcha::get_captcha, list_hidden::list_person_hidden, list_liked::list_person_liked, list_logins::list_logins, list_media::list_media, list_read::list_person_read, list_saved::list_person_saved, login::login, logout::logout, note_person::user_note_person, notifications::{ list::list_notifications, mark_all_read::mark_all_notifications_read, mark_notification_read::mark_notification_as_read, }, resend_verification_email::resend_verification_email, reset_password::reset_password, save_settings::save_user_settings, unread_counts::get_unread_counts, update_totp::edit_totp, user_block_instance::{user_block_instance_communities, user_block_instance_persons}, validate_auth::validate_auth, verify_email::verify_email, }, post::{ feature::feature_post, get_link_metadata::get_link_metadata, hide::hide_post, like::like_post, list_post_likes::list_post_likes, lock::lock_post, mark_many_read::mark_posts_as_read, mark_read::mark_post_as_read, mod_update::mod_edit_post, save::save_post, update_notifications::edit_post_notifications, warning::create_post_warning, }, reports::{ comment_report::{create::create_comment_report, resolve::resolve_comment_report}, community_report::{create::create_community_report, resolve::resolve_community_report}, post_report::{create::create_post_report, resolve::resolve_post_report}, private_message_report::{create::create_pm_report, resolve::resolve_pm_report}, report_combined::list::list_reports, }, site::{ admin_allow_instance::admin_allow_instance, admin_block_instance::admin_block_instance, admin_list_users::admin_list_users, federated_instances::get_federated_instances, list_all_media::list_all_media, mod_log::get_mod_log, purge::{ comment::purge_comment, community::purge_community, person::purge_person, post::purge_post, }, registration_applications::{ approve::approve_registration_application, get::get_registration_application, list::list_registration_applications, }, }, }; use lemmy_api_crud::{ comment::{ create::create_comment, delete::delete_comment, read::get_comment, remove::remove_comment, update::edit_comment, }, community::{ create::create_community, delete::delete_community, list::list_communities, remove::remove_community, update::edit_community, }, custom_emoji::{ create::create_custom_emoji, delete::delete_custom_emoji, list::list_custom_emojis, update::edit_custom_emoji, }, multi_community::{ create::create_multi_community, create_entry::create_multi_community_entry, delete_entry::delete_multi_community_entry, list::list_multi_communities, update::edit_multi_community, }, oauth_provider::{ create::create_oauth_provider, delete::delete_oauth_provider, update::edit_oauth_provider, }, post::{ create::create_post, delete::delete_post, read::get_post, remove::remove_post, update::edit_post, }, private_message::{ create::create_private_message, delete::delete_private_message, update::edit_private_message, }, site::{create::create_site, read::get_site, update::edit_site}, tagline::{ create::create_tagline, delete::delete_tagline, list::list_taglines, update::edit_tagline, }, user::{ create::{authenticate_with_oauth, register}, delete::delete_account, my_user::get_my_user, }, }; use lemmy_routes::images::{ delete::{ delete_community_banner, delete_community_icon, delete_image, delete_image_admin, delete_site_banner, delete_site_icon, delete_user_avatar, delete_user_banner, }, download::{get_image, image_proxy}, pictrs_health, upload::{ upload_community_banner, upload_community_icon, upload_image, upload_site_banner, upload_site_icon, upload_user_avatar, upload_user_banner, }, }; use lemmy_utils::rate_limit::RateLimit; pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { cfg.service( scope("/api/v4") .wrap(rate_limit.message()) // Site .service( scope("/site") .route("", get().to(get_site)) .route("", post().to(create_site)) .route("", put().to(edit_site)) .route("/icon", post().to(upload_site_icon)) .route("/icon", delete().to(delete_site_icon)) .route("/banner", post().to(upload_site_banner)) .route("/banner", delete().to(delete_site_banner)), ) .route("/modlog", get().to(get_mod_log)) .service( resource("/search") .wrap(rate_limit.search()) .route(get().to(search)), ) .service( resource("/resolve_object") .wrap(rate_limit.search()) .route(get().to(resolve_object)), ) // Community .service( resource("/community") .guard(guard::Post()) .wrap(rate_limit.register()) .route(post().to(create_community)), ) .service( scope("/community") .route("", get().to(get_community)) .route("", put().to(edit_community)) .route("", delete().to(delete_community)) .route("/random", get().to(get_random_community)) .route("/list", get().to(list_communities)) .route("/follow", post().to(follow_community)) .route("/report", post().to(create_community_report)) .route("/report/resolve", put().to(resolve_community_report)) // Mod Actions .route("/remove", post().to(remove_community)) .route("/transfer", post().to(transfer_community)) .route("/ban_user", post().to(ban_from_community)) .route("/mod", post().to(add_mod_to_community)) .route("/icon", post().to(upload_community_icon)) .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(edit_community_tag)) .route("/tag", delete().to(delete_community_tag)) .route("/notifications", post().to(edit_community_notifications)) .service( scope("/pending_follows") .route("/list", get().to(get_pending_follows_list)) .route("/approve", post().to(post_pending_follows_approve)), ), ) .service( scope("/multi_community") .route("", post().to(create_multi_community)) .route("", put().to(edit_multi_community)) .route("", get().to(read_multi_community)) .route("/entry", post().to(create_multi_community_entry)) .route("/entry", delete().to(delete_multi_community_entry)) .route("/list", get().to(list_multi_communities)) .route("/follow", post().to(follow_multi_community)), ) .route("/federated_instances", get().to(get_federated_instances)) // Post .service( resource("/post") // Handle POST to /post separately to add the post() rate limitter .guard(guard::Post()) .wrap(rate_limit.post()) .route(post().to(create_post)), ) .service( resource("/post/site_metadata") .wrap(rate_limit.search()) .route(get().to(get_link_metadata)), ) .service( scope("/post") .route("", get().to(get_post)) .route("", put().to(edit_post)) .route("", delete().to(delete_post)) .route("/remove", post().to(remove_post)) .route("/mark_as_read", post().to(mark_post_as_read)) .route("/mark_as_read/many", post().to(mark_posts_as_read)) .route("/hide", post().to(hide_post)) .route("/lock", post().to(lock_post)) .route("/feature", post().to(feature_post)) .route("/list", get().to(list_posts)) .route("/like", post().to(like_post)) .route("/like/list", get().to(list_post_likes)) .route("/save", put().to(save_post)) .route("/report", post().to(create_post_report)) .route("/report/resolve", put().to(resolve_post_report)) .route("/notifications", post().to(edit_post_notifications)) .route("/mod_edit", put().to(mod_edit_post)) .route("/warn", post().to(create_post_warning)), ) // Comment .service( // Handle POST to /comment separately to add the comment() rate limitter resource("/comment") .guard(guard::Post()) .wrap(rate_limit.comment()) .route(post().to(create_comment)), ) .service( scope("/comment") .route("", get().to(get_comment)) .route("", put().to(edit_comment)) .route("", delete().to(delete_comment)) .route("/remove", post().to(remove_comment)) .route("/distinguish", post().to(distinguish_comment)) .route("/like", post().to(like_comment)) .route("/like/list", get().to(list_comment_likes)) .route("/save", put().to(save_comment)) .route("/lock", post().to(lock_comment)) .route("/list", get().to(list_comments)) .route("/list/slim", get().to(list_comments_slim)) .route("/warn", post().to(create_comment_warning)) .route("/report", post().to(create_comment_report)) .route("/report/resolve", put().to(resolve_comment_report)), ) // Private Message .service( scope("/private_message") .route("", post().to(create_private_message)) .route("", put().to(edit_private_message)) .route("", delete().to(delete_private_message)) .route("/report", post().to(create_pm_report)) .route("/report/resolve", put().to(resolve_pm_report)), ) // Reports .service( scope("/report") .wrap(rate_limit.message()) .route("/list", get().to(list_reports)), ) // User .service( scope("/account/auth") .guard(guard::Post()) .wrap(rate_limit.register()) .route("/register", post().to(register)) .route("/login", post().to(login)) .route("/logout", post().to(logout)) .route("/password_reset", post().to(reset_password)) .route("/password_change", post().to(change_password_after_reset)) .route("/change_password", put().to(change_password)) .route("/totp/generate", post().to(generate_totp_secret)) .route("/totp/edit", post().to(edit_totp)) .route("/verify_email", post().to(verify_email)) .route( "/resend_verification_email", post().to(resend_verification_email), ), ) .service( scope("/account") .route("/auth/get_captcha", get().to(get_captcha)) .route("", get().to(get_my_user)) .route("/unread_counts", get().to(get_unread_counts)) .service( scope("/media") .route("", delete().to(delete_image)) .route("/list", get().to(list_media)), ) .service( scope("/notification") .route("/list", get().to(list_notifications)) .route("/mark_as_read/all", post().to(mark_all_notifications_read)) .route("/mark_as_read", post().to(mark_notification_as_read)), ) .route("", delete().to(delete_account)) .route("/login/list", get().to(list_logins)) .route("/validate_auth", get().to(validate_auth)) .route("/donation_dialog_shown", post().to(donation_dialog_shown)) .route("/avatar", post().to(upload_user_avatar)) .route("/avatar", delete().to(delete_user_avatar)) .route("/banner", post().to(upload_user_banner)) .route("/banner", delete().to(delete_user_banner)) .service( scope("/block") .route("/person", post().to(user_block_person)) .route("/community", post().to(user_block_community)) .route( "/instance/communities", post().to(user_block_instance_communities), ) .route("/instance/persons", post().to(user_block_instance_persons)), ) .route("/saved", get().to(list_person_saved)) .route("/read", get().to(list_person_read)) .route("/hidden", get().to(list_person_hidden)) .route("/liked", get().to(list_person_liked)) .route("/settings/save", put().to(save_user_settings)) // Account settings import / export have a strict rate limit .service( scope("/settings") .wrap(rate_limit.import_user_settings()) .route("/export", get().to(export_settings)) .route("/import", post().to(import_settings)), ) .service( resource("/data/export") .wrap(rate_limit.import_user_settings()) .route(get().to(export_data)), ), ) // User actions .service( scope("/person") .route("", get().to(read_person)) .route("/content", get().to(list_person_content)) .route("/note", post().to(user_note_person)), ) // Admin Actions .service( scope("/admin") .route("/add", post().to(add_admin)) .service( scope("/registration_application") .route("", get().to(get_registration_application)) .route("/list", get().to(list_registration_applications)) .route("/approve", put().to(approve_registration_application)), ) .service( scope("/purge") .route("/person", post().to(purge_person)) .route("/community", post().to(purge_community)) .route("/post", post().to(purge_post)) .route("/comment", post().to(purge_comment)), ) .service( scope("/tagline") .route("", post().to(create_tagline)) .route("", put().to(edit_tagline)) .route("", delete().to(delete_tagline)) .route("/list", get().to(list_taglines)), ) .route("/ban", post().to(ban_from_site)) .route("/users", get().to(admin_list_users)) .service( scope("/instance") .route("/block", post().to(admin_block_instance)) .route("/allow", post().to(admin_allow_instance)), ), ) .service( scope("/custom_emoji") .route("", post().to(create_custom_emoji)) .route("", put().to(edit_custom_emoji)) .route("", delete().to(delete_custom_emoji)) .route("/list", get().to(list_custom_emojis)), ) .service( scope("/oauth_provider") .route("", post().to(create_oauth_provider)) .route("", put().to(edit_oauth_provider)) .route("", delete().to(delete_oauth_provider)), ) .service( scope("/oauth") .wrap(rate_limit.register()) .route("/authenticate", post().to(authenticate_with_oauth)), ) .service( scope("/image") .service( resource("") .wrap(rate_limit.image()) .route(post().to(upload_image)) .route(delete().to(delete_image_admin)), ) .route("/proxy", get().to(image_proxy)) .route("/health", get().to(pictrs_health)) .route("/list", get().to(list_all_media)) .route("/{filename}", get().to(get_image)), ), ); } ================================================ FILE: crates/api/routes_v3/Cargo.toml ================================================ [package] name = "lemmy_api_routes_v3" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true publish = false [lib] doctest = false test = false [lints] workspace = true [features] default = [] [dependencies] lemmy_api = { workspace = true } lemmy_api_crud = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_db_views_site = { workspace = true } lemmy_db_views_post = { workspace = true } lemmy_utils = { workspace = true } lemmy_api_utils = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_diesel_utils = { workspace = true } lemmy_db_views_registration_applications = { workspace = true } actix-web = { workspace = true } chrono = { workspace = true } url = { workspace = true } lemmy_api_019 = { package = "lemmy_api_common", version = "0.19.12" } lemmy_db_views_comment = { workspace = true, features = ["full"] } lemmy_db_views_community = { workspace = true, features = ["full"] } lemmy_db_views_search_combined = { workspace = true, features = ["full"] } lemmy_db_views_person = { workspace = true, features = ["full"] } lemmy_db_schema_file = { workspace = true } lemmy_db_views_report_combined = { workspace = true, features = ["full"] } activitypub_federation = { workspace = true } ================================================ FILE: crates/api/routes_v3/src/convert.rs ================================================ use actix_web::web::Json; use chrono::Utc; use lemmy_api_019::{ comment::CommentResponse as CommentResponseV3, lemmy_db_schema::{ CommentSortType as CommentSortTypeV3, ListingType as ListingTypeV3, RegistrationMode as RegistrationModeV3, SearchType as SearchTypeV3, SortType as SortTypeV3, SubscribedType as SubscribedTypeV3, aggregates::structs::{ CommentAggregates, CommunityAggregates, PersonAggregates, PostAggregates, SiteAggregates, }, newtypes::{ CommentId as CommentIdV3, CommunityId as CommunityIdV3, DbUrl as DbUrlV3, InstanceId, LanguageId as LanguageIdV3, LocalUserId as LocalUserIdV3, PersonId as PersonIdV3, PostId as PostIdV3, SiteId as SiteIdV3, }, sensitive::SensitiveString as SensitiveStringV3, source::{ comment::Comment as CommentV3, community::Community as CommunityV3, local_site::LocalSite as LocalSiteV3, local_site_rate_limit::LocalSiteRateLimit as LocalSiteRateLimitV3, local_user::LocalUser as LocalUserV3, local_user_vote_display_mode::LocalUserVoteDisplayMode as LocalUserVoteDisplayModeV3, person::Person as PersonV3, post::Post as PostV3, site::Site as SiteV3, }, }, lemmy_db_views::structs::{ CommentView as CommentViewV3, LocalUserView as LocalUserViewV3, PostView as PostViewV3, SiteView as SiteViewV3, }, lemmy_db_views_actor::structs::{CommunityView as CommunityViewV3, PersonView as PersonViewV3}, person::LoginResponse as LoginResponseV3, post::PostResponse as PostResponseV3, site::{MyUserInfo as MyUserInfoV3, SearchResponse as SearchResponseV3}, }; use lemmy_api_utils::plugins::is_captcha_plugin_loaded; use lemmy_db_schema::{ CommunitySortType, newtypes::LanguageId, source::{ comment::Comment, community::Community, local_site::LocalSite, local_user::LocalUser, person::Person, post::Post, site::Site, }, }; use lemmy_db_schema_file::enums::{ CommentSortType, CommunityFollowerState, ListingType, PostSortType, RegistrationMode, }; use lemmy_db_views_comment::{CommentView, api::CommentResponse}; use lemmy_db_views_community::CommunityView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::PersonView; use lemmy_db_views_post::{PostView, api::PostResponse}; use lemmy_db_views_search_combined::SearchCombinedView; use lemmy_db_views_site::{ SiteView, api::{LoginResponse, MyUserInfo}, }; use lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString}; use lemmy_utils::error::LemmyResult; use std::sync::LazyLock; use url::Url; #[expect(clippy::expect_used)] static DUMMY_URL: LazyLock = LazyLock::new(|| { Url::parse("http://example.com") .expect("parse dummy url") .into() }); pub(crate) fn convert_local_user_view2(local_user_view: LocalUserView) -> LocalUserViewV3 { let LocalUserView { local_user, person, .. } = local_user_view; let (person, counts) = convert_person(person); let local_user = convert_local_user(local_user); LocalUserViewV3 { local_user_vote_display_mode: LocalUserVoteDisplayModeV3 { local_user_id: local_user.id, score: false, upvotes: true, downvotes: true, upvote_percentage: false, }, local_user, person, counts, } } pub(crate) fn convert_local_user(local_user: LocalUser) -> LocalUserV3 { let LocalUser { id, person_id, show_nsfw, theme, interface_language, show_avatars, send_notifications_to_email, show_bot_accounts, show_read_posts, email_verified, accepted_application, open_links_in_new_tab, blur_nsfw, infinite_scroll_enabled, totp_2fa_enabled, enable_animated_images, collapse_bot_comments, last_donation_notification_at, .. } = local_user; LocalUserV3 { id: LocalUserIdV3(id.0), person_id: PersonIdV3(person_id.0), password_encrypted: Default::default(), email: None, show_nsfw, theme, default_sort_type: Default::default(), default_listing_type: Default::default(), interface_language, show_avatars, send_notifications_to_email, show_scores: false, show_bot_accounts, show_read_posts, email_verified, accepted_application, totp_2fa_secret: None, open_links_in_new_tab, blur_nsfw, auto_expand: false, infinite_scroll_enabled, admin: false, post_listing_mode: Default::default(), totp_2fa_enabled, enable_keyboard_navigation: false, enable_animated_images, collapse_bot_comments, last_donation_notification: last_donation_notification_at, } } pub(crate) fn convert_community_view(community_view: CommunityView) -> CommunityViewV3 { let CommunityView { community, community_actions, .. } = community_view; let counts = CommunityAggregates { community_id: CommunityIdV3(community.id.0), subscribers: community.subscribers.into(), posts: community.posts.into(), comments: community.comments.into(), published: community.published_at, users_active_day: community.users_active_day.into(), users_active_week: community.users_active_week.into(), users_active_month: community.users_active_month.into(), users_active_half_year: community.users_active_half_year.into(), hot_rank: community.hot_rank.into(), subscribers_local: community.subscribers_local.into(), }; CommunityViewV3 { community: convert_community(community), subscribed: convert_subscribed_type(community_actions.as_ref().and_then(|c| c.follow_state)), blocked: community_actions .as_ref() .and_then(|c| c.blocked_at) .is_some(), counts, banned_from_community: community_actions.and_then(|c| c.received_ban_at).is_some(), } } fn convert_subscribed_type(state: Option) -> SubscribedTypeV3 { match state { Some(CommunityFollowerState::Accepted) => SubscribedTypeV3::Subscribed, Some(CommunityFollowerState::Pending) => SubscribedTypeV3::Pending, Some(CommunityFollowerState::ApprovalRequired) => SubscribedTypeV3::Pending, Some(CommunityFollowerState::Denied) => SubscribedTypeV3::NotSubscribed, None => SubscribedTypeV3::NotSubscribed, } } pub(crate) fn convert_post_view(post_view: PostView) -> PostViewV3 { let PostView { post, creator, community, creator_is_admin, creator_is_moderator, creator_banned_from_community, post_actions, community_actions, .. } = post_view; let (post, counts) = convert_post(post); let my_vote = post_actions .as_ref() .and_then(|pa| pa.vote_is_upvote) .map(|vote_is_upvote| if vote_is_upvote { 1 } else { -1 }); PostViewV3 { post, creator: convert_person(creator).0, community: convert_community(community), image_details: None, creator_banned_from_community, banned_from_community: community_actions.and_then(|c| c.received_ban_at).is_some(), creator_is_moderator, creator_is_admin, counts, subscribed: SubscribedTypeV3::NotSubscribed, saved: post_actions.as_ref().and_then(|p| p.saved_at).is_some(), read: post_actions.as_ref().and_then(|p| p.read_at).is_some(), hidden: post_actions.as_ref().and_then(|p| p.hidden_at).is_some(), creator_blocked: false, my_vote, unread_comments: 0, } } pub(crate) fn convert_comment_view(comment_view: CommentView) -> CommentViewV3 { let CommentView { comment, creator, post, community, creator_is_admin, creator_is_moderator, creator_banned_from_community, comment_actions, .. } = comment_view; let (comment, counts) = convert_comment(comment); let my_vote = comment_actions .as_ref() .and_then(|pa| pa.vote_is_upvote) .map(|vote_is_upvote| if vote_is_upvote { 1 } else { -1 }); CommentViewV3 { comment, creator: convert_person(creator).0, post: convert_post(post).0, community: convert_community(community), counts, creator_banned_from_community, banned_from_community: false, creator_is_moderator, creator_is_admin, subscribed: SubscribedTypeV3::NotSubscribed, saved: comment_actions.and_then(|c| c.saved_at).is_some(), creator_blocked: false, my_vote, } } pub(crate) fn convert_comment(comment: Comment) -> (CommentV3, CommentAggregates) { let Comment { id, creator_id, post_id, content, removed, published_at, updated_at, deleted, ap_id, local, path, distinguished, language_id, score, upvotes, downvotes, child_count, hot_rank, controversy_rank, .. } = comment; let id = CommentIdV3(id.0); ( CommentV3 { id, creator_id: PersonIdV3(creator_id.0), post_id: PostIdV3(post_id.0), content, removed, published: published_at, updated: updated_at, deleted, ap_id: convert_db_url(ap_id), local, path: path.0, distinguished, language_id: LanguageIdV3(language_id.0), }, CommentAggregates { comment_id: id, score: score.into(), upvotes: upvotes.into(), downvotes: downvotes.into(), published: published_at, child_count, hot_rank: hot_rank.into(), controversy_rank: controversy_rank.into(), }, ) } pub(crate) fn convert_my_user(my_user: Option) -> Option { if let Some(my_user) = my_user { let MyUserInfo { local_user_view, .. } = my_user; Some(MyUserInfoV3 { local_user_view: convert_local_user_view2(local_user_view), follows: vec![], moderates: vec![], community_blocks: vec![], instance_blocks: vec![], person_blocks: vec![], discussion_languages: vec![], }) } else { None } } pub(crate) fn convert_person(person: Person) -> (PersonV3, PersonAggregates) { let Person { id, name, display_name, avatar, published_at, updated_at, ap_id, bio, local, public_key, last_refreshed_at, banner, deleted, matrix_user_id, bot_account, post_count, post_score, comment_count, comment_score, .. } = person; let id = PersonIdV3(id.0); ( PersonV3 { id, name, display_name, avatar: avatar.map(convert_db_url), banned: false, published: published_at, updated: updated_at, actor_id: convert_db_url(ap_id), bio, local, private_key: Default::default(), public_key, last_refreshed_at, banner: banner.map(convert_db_url), deleted, inbox_url: DUMMY_URL.clone(), shared_inbox_url: None, matrix_user_id, bot_account, ban_expires: None, instance_id: Default::default(), }, PersonAggregates { person_id: id, post_count: post_count.into(), post_score: post_score.into(), comment_count: comment_count.into(), comment_score: comment_score.into(), }, ) } pub(crate) fn convert_community(community: Community) -> CommunityV3 { let Community { id, name, title, removed, published_at, updated_at, deleted, nsfw, ap_id, local, public_key, last_refreshed_at, icon, banner, posting_restricted_to_mods, instance_id, summary: description, .. } = community; CommunityV3 { id: CommunityIdV3(id.0), name, title, description, removed, published: published_at, updated: updated_at, deleted, nsfw, actor_id: convert_db_url(ap_id), local, private_key: None, public_key, last_refreshed_at, icon: icon.map(convert_db_url), banner: banner.map(convert_db_url), followers_url: None, inbox_url: DUMMY_URL.clone(), shared_inbox_url: None, hidden: false, posting_restricted_to_mods, instance_id: InstanceId(instance_id.0), moderators_url: None, featured_url: None, visibility: Default::default(), } } pub(crate) fn convert_post(post: Post) -> (PostV3, PostAggregates) { let Post { id, name, url, body, creator_id, community_id, removed, locked, published_at, updated_at, deleted, nsfw, embed_title, embed_description, thumbnail_url, ap_id, local, embed_video_url, language_id, featured_community, featured_local, url_content_type, alt_text, comments, score, upvotes, downvotes, hot_rank, hot_rank_active, controversy_rank, scaled_rank, .. } = post; let post_id = PostIdV3(id.0); let creator_id = PersonIdV3(creator_id.0); let community_id = CommunityIdV3(community_id.0); ( PostV3 { id: post_id, name, url: url.map(convert_db_url), body, creator_id, community_id, removed, locked, published: published_at, updated: updated_at, deleted, nsfw, embed_title, embed_description, thumbnail_url: thumbnail_url.map(convert_db_url), ap_id: convert_db_url(ap_id), local, embed_video_url: embed_video_url.map(convert_db_url), language_id: LanguageIdV3(language_id.0), featured_community, featured_local, url_content_type, alt_text, }, PostAggregates { post_id, comments: comments.into(), score: score.into(), upvotes: upvotes.into(), downvotes: downvotes.into(), published: published_at, newest_comment_time_necro: Utc::now(), newest_comment_time: Utc::now(), featured_community, featured_local, hot_rank: hot_rank.into(), hot_rank_active: hot_rank_active.into(), community_id, creator_id, controversy_rank: controversy_rank.into(), instance_id: Default::default(), scaled_rank: scaled_rank.into(), }, ) } pub(crate) fn convert_site_view(site_view: SiteView) -> SiteViewV3 { let SiteView { site, local_site, .. } = site_view; let counts = SiteAggregates { site_id: SiteIdV3(site.id.0), users: local_site.users.into(), posts: local_site.posts.into(), comments: local_site.comments.into(), communities: local_site.communities.into(), users_active_day: local_site.users_active_day.into(), users_active_week: local_site.users_active_week.into(), users_active_month: local_site.users_active_month.into(), users_active_half_year: local_site.users_active_half_year.into(), }; SiteViewV3 { site: convert_site(site), local_site: convert_local_site(local_site), local_site_rate_limit: dummy_local_site_rate_limit(), counts, } } pub(crate) fn convert_site(site: Site) -> SiteV3 { let Site { id, name, sidebar, published_at, updated_at, icon, banner, summary: description, ap_id, last_refreshed_at, public_key, content_warning, .. } = site; SiteV3 { id: SiteIdV3(id.0), name, sidebar, published: published_at, updated: updated_at, icon: icon.map(convert_db_url), banner: banner.map(convert_db_url), description, last_refreshed_at, actor_id: convert_db_url(ap_id), inbox_url: DUMMY_URL.clone(), private_key: Default::default(), public_key, instance_id: Default::default(), content_warning, } } pub(crate) fn convert_db_url(db_url: DbUrl) -> DbUrlV3 { let url: Url = db_url.into(); url.into() } pub(crate) fn convert_local_site(local_site: LocalSite) -> LocalSiteV3 { let LocalSite { site_id, site_setup, community_creation_admin_only, require_email_verification, application_question, private_instance, default_theme, legal_information, application_email_admins, slur_filter_regex, federation_enabled, published_at, updated_at, reports_email_admins, federation_signed_fetch, registration_mode, .. } = local_site; let registration_mode = match registration_mode { RegistrationMode::Closed => RegistrationModeV3::Closed, RegistrationMode::RequireApplication => RegistrationModeV3::RequireApplication, RegistrationMode::Open => RegistrationModeV3::Open, }; LocalSiteV3 { id: Default::default(), site_id: SiteIdV3(site_id.0), site_setup, enable_downvotes: true, enable_nsfw: true, community_creation_admin_only, require_email_verification, application_question, private_instance, default_theme, default_post_listing_type: Default::default(), legal_information, hide_modlog_mod_names: true, application_email_admins, slur_filter_regex, actor_name_max_length: 20, federation_enabled, captcha_enabled: is_captcha_plugin_loaded(), captcha_difficulty: String::new(), published: published_at, updated: updated_at, registration_mode, reports_email_admins, federation_signed_fetch, default_post_listing_mode: Default::default(), default_sort_type: Default::default(), } } fn dummy_local_site_rate_limit() -> LocalSiteRateLimitV3 { LocalSiteRateLimitV3 { local_site_id: Default::default(), message: 0, message_per_second: 0, post: 0, post_per_second: 0, register: 0, register_per_second: 0, image: 0, image_per_second: 0, comment: 0, comment_per_second: 0, search: 0, search_per_second: 0, published: Utc::now(), updated: None, import_user_settings: 0, import_user_settings_per_second: 0, } } pub(crate) fn convert_person_view(person_view: PersonView) -> PersonViewV3 { let PersonView { person, .. } = person_view; let (person, counts) = convert_person(person); PersonViewV3 { person, counts, // explicitly set to false to hide all admin options from ui is_admin: false, } } pub(crate) fn convert_sensitive(s: SensitiveString) -> SensitiveStringV3 { SensitiveStringV3::from(s.into_inner()) } pub(crate) fn convert_score(score: i16) -> Option { if score <= -1 { Some(false) } else if score >= 1 { Some(true) } else { None } } pub(crate) fn convert_search_response( views: Vec, type_: Option, ) -> SearchResponseV3 { let mut res = SearchResponseV3 { type_: type_.unwrap_or(SearchTypeV3::All), comments: vec![], posts: vec![], communities: vec![], users: vec![], }; for v in views { match v { SearchCombinedView::Post(p) => res.posts.push(convert_post_view(p)), SearchCombinedView::Comment(c) => res.comments.push(convert_comment_view(c)), SearchCombinedView::Community(c) => res.communities.push(convert_community_view(c)), SearchCombinedView::Person(p) => res.users.push(convert_person_view(p)), SearchCombinedView::MultiCommunity(_) => continue, } } res } pub(crate) fn convert_post_listing_sort( sort_type: Option, ) -> (Option, Option) { const HOUR: i32 = 60 * 60; const DAY: i32 = 24 * HOUR; const WEEK: i32 = 7 * DAY; const MONTH: i32 = 30 * DAY; const YEAR: i32 = 365 * DAY; let Some(sort_type) = sort_type else { return (None, None); }; let max = |s| (Some(s), Some(i32::MAX)); let top = |t| (Some(PostSortType::Top), Some(t)); match sort_type { SortTypeV3::Active => max(PostSortType::Active), SortTypeV3::Hot => max(PostSortType::Hot), SortTypeV3::New => max(PostSortType::New), SortTypeV3::Old => max(PostSortType::Old), SortTypeV3::Controversial => max(PostSortType::Controversial), SortTypeV3::MostComments => max(PostSortType::MostComments), SortTypeV3::NewComments => max(PostSortType::NewComments), SortTypeV3::Scaled => max(PostSortType::Scaled), SortTypeV3::TopHour => top(HOUR), SortTypeV3::TopSixHour => top(6 * HOUR), SortTypeV3::TopTwelveHour => top(12 * HOUR), SortTypeV3::TopDay => top(DAY), SortTypeV3::TopWeek => top(WEEK), SortTypeV3::TopAll => top(i32::MAX), SortTypeV3::TopMonth => top(MONTH), SortTypeV3::TopThreeMonths => top(3 * MONTH), SortTypeV3::TopSixMonths => top(6 * MONTH), SortTypeV3::TopNineMonths => top(9 * MONTH), SortTypeV3::TopYear => top(YEAR), } } pub(crate) fn convert_comment_listing_sort(sort_type: CommentSortTypeV3) -> CommentSortType { match sort_type { CommentSortTypeV3::Hot => CommentSortType::Hot, CommentSortTypeV3::Top => CommentSortType::Top, CommentSortTypeV3::New => CommentSortType::New, CommentSortTypeV3::Old => CommentSortType::Old, CommentSortTypeV3::Controversial => CommentSortType::Controversial, } } pub(crate) fn convert_community_listing_sort( sort_type: Option, ) -> (Option, Option) { const HOUR: i32 = 60 * 60; const DAY: i32 = 24 * HOUR; const WEEK: i32 = 7 * DAY; const MONTH: i32 = 30 * DAY; const YEAR: i32 = 365 * DAY; let Some(sort_type) = sort_type else { return (Some(CommunitySortType::default()), Some(i32::MAX)); }; let max = |s| (Some(s), Some(i32::MAX)); let top = |t| (Some(CommunitySortType::Hot), Some(t)); match sort_type { SortTypeV3::Active | SortTypeV3::Hot | SortTypeV3::MostComments | SortTypeV3::NewComments | SortTypeV3::Controversial | SortTypeV3::Scaled => max(CommunitySortType::Hot), SortTypeV3::New => max(CommunitySortType::New), SortTypeV3::Old => max(CommunitySortType::Old), SortTypeV3::TopHour => top(HOUR), SortTypeV3::TopSixHour => top(6 * HOUR), SortTypeV3::TopTwelveHour => top(12 * HOUR), SortTypeV3::TopDay => top(DAY), SortTypeV3::TopWeek => top(WEEK), SortTypeV3::TopAll => top(i32::MAX), SortTypeV3::TopMonth => top(MONTH), SortTypeV3::TopThreeMonths => top(3 * MONTH), SortTypeV3::TopSixMonths => top(6 * MONTH), SortTypeV3::TopNineMonths => top(9 * MONTH), SortTypeV3::TopYear => top(YEAR), } } pub(crate) fn convert_listing_type(listing_type: ListingTypeV3) -> ListingType { match listing_type { ListingTypeV3::All => ListingType::All, ListingTypeV3::Local => ListingType::Local, ListingTypeV3::Subscribed => ListingType::Subscribed, ListingTypeV3::ModeratorView => ListingType::ModeratorView, } } pub(crate) fn convert_post_response(res: Json) -> LemmyResult> { Ok(Json(PostResponseV3 { post_view: convert_post_view(res.0.post_view), })) } pub(crate) fn convert_comment_response( res: Json, ) -> LemmyResult> { Ok(Json(CommentResponseV3 { comment_view: convert_comment_view(res.0.comment_view), recipient_ids: vec![], })) } pub(crate) fn convert_language_ids(data: Vec) -> Vec { data.into_iter().map(|l| LanguageIdV3(l.0)).collect() } pub(crate) fn convert_login_response(res: LoginResponse) -> LemmyResult> { let LoginResponse { jwt, registration_created, verify_email_sent, } = res; Ok(Json(LoginResponseV3 { jwt: jwt.map(convert_sensitive), registration_created, verify_email_sent, })) } ================================================ FILE: crates/api/routes_v3/src/handlers.rs ================================================ use crate::convert::{ convert_comment, convert_comment_listing_sort, convert_comment_response, convert_comment_view, convert_community, convert_community_listing_sort, convert_community_view, convert_language_ids, convert_listing_type, convert_login_response, convert_my_user, convert_person, convert_person_view, convert_post, convert_post_listing_sort, convert_post_response, convert_post_view, convert_score, convert_search_response, convert_site, convert_site_view, }; use activitypub_federation::config::Data as ApubData; use actix_web::{HttpRequest, HttpResponse, web::*}; use lemmy_api::{ comment::{like::like_comment, save::save_comment}, community::{block::user_block_community, follow::follow_community}, federation::{ list_comments::list_comments, list_posts::list_posts, read_community::get_community, resolve_object::resolve_object, search::search, }, local_user::{ block::user_block_person, login::login, logout::logout, notifications::mark_all_read::mark_all_notifications_read, }, post::{like::like_post, save::save_post}, reports::{ comment_report::create::create_comment_report, post_report::create::create_post_report, }, }; use lemmy_api_019::{ comment::{ CommentReportResponse as CommentReportResponseV3, CommentResponse as CommentResponseV3, CreateCommentLike as CreateCommentLikeV3, GetComments as GetCommentsV3, GetCommentsResponse as GetCommentsResponseV3, }, community::{ BlockCommunityResponse as BlockCommunityResponseV3, CommunityResponse as CommunityResponseV3, GetCommunityResponse as GetCommunityResponseV3, ListCommunities as ListCommunitiesV3, ListCommunitiesResponse as ListCommunitiesResponseV3, }, lemmy_db_schema::{ SubscribedType as SubscribedTypeV3, newtypes::LanguageId as LanguageIdV3, source::{ comment_report::CommentReport as CommentReportV3, language::Language as LanguageV3, local_site_url_blocklist::LocalSiteUrlBlocklist as LocalSiteUrlBlocklistV3, post_report::PostReport as PostReportV3, tagline::Tagline as TaglineV3, }, }, lemmy_db_views::structs::{ CommentReportView as CommentReportViewV3, PostReportView as PostReportViewV3, }, lemmy_db_views_actor::structs::CommunityModeratorView as CommunityModeratorViewV3, person::{ BlockPersonResponse as BlockPersonResponseV3, GetRepliesResponse as GetRepliesResponseV3, GetUnreadCountResponse as GetUnreadCountResponseV3, LoginResponse as LoginResponseV3, }, post::{ CreatePost as CreatePostV3, CreatePostLike as CreatePostLikeV3, GetPostResponse as GetPostResponseV3, GetPosts as GetPostsV3, GetPostsResponse as GetPostsResponseV3, PostReportResponse as PostReportResponseV3, PostResponse as PostResponseV3, }, site::{ GetSiteResponse as GetSiteResponseV3, ResolveObjectResponse as ResolveObjectResponseV3, Search as SearchV3, SearchResponse as SearchResponseV3, }, }; use lemmy_api_crud::{ comment::{create::create_comment, delete::delete_comment, update::edit_comment}, community::list::list_communities, post::{create::create_post, delete::delete_post, read::get_post, update::edit_post}, site::read::get_site, user::{create::register, my_user::get_my_user}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::newtypes::{CommentId, CommunityId, LanguageId, PostId}; use lemmy_db_schema_file::PersonId; use lemmy_db_views_comment::api::{ CreateComment, CreateCommentLike, DeleteComment, EditComment, GetComments, SaveComment, }; use lemmy_db_views_community::api::{ BlockCommunity, FollowCommunity, GetCommunity, ListCommunities, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::api::BlockPerson; use lemmy_db_views_post::api::{ CreatePost, CreatePostLike, DeletePost, EditPost, GetPosts, SavePost, }; use lemmy_db_views_registration_applications::api::Register; use lemmy_db_views_report_combined::api::{CreateCommentReport, CreatePostReport}; use lemmy_db_views_search_combined::{Search, api::GetPost}; use lemmy_db_views_site::api::{GetSiteResponse, Login, ResolveObject}; use lemmy_utils::error::LemmyResult; pub(crate) async fn get_post_v3( data: Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let post = get_post(data, context, local_user_view).await?.0; Ok(Json(GetPostResponseV3 { post_view: convert_post_view(post.post_view), community_view: convert_community_view(post.community_view), moderators: vec![], cross_posts: post .cross_posts .into_iter() .map(convert_post_view) .collect(), })) } pub(crate) async fn list_posts_v3( datav3: Query, context: ApubData, local_user_view: Option, ) -> LemmyResult> { let GetPostsV3 { limit, community_id, community_name, show_hidden, show_read, show_nsfw, type_, sort, page, .. } = datav3.0; let (sort, time_range_seconds) = convert_post_listing_sort(sort); let data = GetPosts { type_: type_.map(convert_listing_type), sort, time_range_seconds, community_id: community_id.map(|id| CommunityId(id.0)), community_name, show_hidden, show_read, show_nsfw, page, limit, ..Default::default() }; let res = list_posts(Query(data), context, local_user_view).await?.0; Ok(Json(GetPostsResponseV3 { posts: res.into_iter().map(convert_post_view).collect(), next_page: None, })) } pub(crate) async fn list_comments_v3( Query(data): Query, context: ApubData, local_user_view: Option, ) -> LemmyResult> { let GetCommentsV3 { max_depth, limit, community_id, community_name, post_id, parent_id, type_, sort, .. } = data; let sort = sort.map(convert_comment_listing_sort); let data = GetComments { type_: type_.map(convert_listing_type), sort, max_depth, page_cursor: None, limit, community_id: community_id.map(|c| CommunityId(c.0)), community_name, post_id: post_id.map(|p| PostId(p.0)), parent_id: parent_id.map(|p| CommentId(p.0)), time_range_seconds: None, }; let comments = list_comments(Query(data), context, local_user_view) .await? .0; Ok(Json(GetCommentsResponseV3 { comments: comments.into_iter().map(convert_comment_view).collect(), })) } pub(crate) async fn logout_v3( req: HttpRequest, local_user_view: LocalUserView, context: ApubData, ) -> LemmyResult { logout(req, local_user_view, context).await } pub(crate) async fn get_site_v3( local_user_view: Option, context: Data, ) -> LemmyResult> { let GetSiteResponse { site_view, admins, version, all_languages, discussion_languages, tagline, blocked_urls, .. } = get_site(local_user_view.clone(), context.clone()).await?.0; let my_user = if let Some(local_user_view) = local_user_view { Some(get_my_user(local_user_view, context).await?.0) } else { None }; Ok(Json(GetSiteResponseV3 { site_view: convert_site_view(site_view), admins: admins.into_iter().map(convert_person_view).collect(), version, my_user: convert_my_user(my_user), all_languages: all_languages .into_iter() .map(|l| LanguageV3 { id: LanguageIdV3(l.id.0), code: l.code, name: l.name, }) .collect(), discussion_languages: convert_language_ids(discussion_languages), taglines: tagline .into_iter() .map(|t| TaglineV3 { id: t.id.0, local_site_id: Default::default(), content: t.content, published: t.published_at, updated: t.updated_at, }) .collect(), custom_emojis: vec![], blocked_urls: blocked_urls .into_iter() .map(|b| LocalSiteUrlBlocklistV3 { id: b.id, url: b.url, published: b.published_at, updated: b.updated_at, }) .collect(), })) } pub(crate) async fn login_v3( data: Json, req: HttpRequest, context: Data, ) -> LemmyResult> { let res = login(data, req, context).await?.0; convert_login_response(res) } pub(crate) async fn like_comment_v3( Json(data): Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let CreateCommentLikeV3 { comment_id, score } = data; let data = CreateCommentLike { comment_id: CommentId(comment_id.0), is_upvote: convert_score(score), }; let res = like_comment(Json(data), context, local_user_view).await?; convert_comment_response(res) } pub(crate) async fn like_post_v3( Json(data): Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let CreatePostLikeV3 { post_id, score } = data; let data = CreatePostLike { post_id: PostId(post_id.0), is_upvote: convert_score(score), }; let res = like_post(Json(data), context, local_user_view).await?; convert_post_response(res) } pub(crate) async fn create_comment_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = Box::pin(create_comment(data, context, local_user_view)).await?; convert_comment_response(res) } pub(crate) async fn create_post_v3( Json(data): Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let CreatePostV3 { name, community_id, url, body, alt_text, honeypot, nsfw, language_id, custom_thumbnail, } = data; let data = CreatePost { name, community_id: CommunityId(community_id.0), url, body, alt_text, honeypot, nsfw, language_id: language_id.map(|l| LanguageId(l.0)), custom_thumbnail, tags: None, scheduled_publish_time_at: None, }; let res = Box::pin(create_post(Json(data), context, local_user_view)).await?; convert_post_response(res) } pub(crate) async fn search_v3( Query(data): Query, context: ApubData, local_user_view: Option, ) -> LemmyResult> { let SearchV3 { q, community_id, community_name, creator_id, limit, type_, .. } = data; let data = Search { q, community_id: community_id.map(|i| CommunityId(i.0)), community_name, creator_id: creator_id.map(|i| PersonId(i.0)), limit, ..Default::default() }; let res = search(Query(data), context, local_user_view).await?; Ok(Json(convert_search_response(res.0.search, type_))) } pub(crate) async fn resolve_object_v3( data: Query, context: ApubData, local_user_view: Option, ) -> LemmyResult> { let res = resolve_object(data, context, local_user_view).await?; let mut conv = convert_search_response(res.0.resolve.into_iter().collect(), None); Ok(Json(ResolveObjectResponseV3 { comment: conv.comments.pop(), post: conv.posts.pop(), community: conv.communities.pop(), person: conv.users.pop(), })) } pub(crate) async fn save_post_v3( data: Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let res = save_post(data, context, local_user_view).await?; convert_post_response(res) } pub(crate) async fn save_comment_v3( data: Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let res = save_comment(data, context, local_user_view).await?; convert_comment_response(res) } pub async fn unread_count_v3( _context: Data, _local_user_view: LocalUserView, ) -> LemmyResult> { // Hardcoded to 0 because new notifications cant be returned via old api. Ok(Json(GetUnreadCountResponseV3 { replies: 0, mentions: 0, private_messages: 0, })) } pub async fn mark_all_notifications_read_v3( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { mark_all_notifications_read(context, local_user_view).await?; Ok(Json(GetRepliesResponseV3 { replies: vec![] })) } pub async fn create_post_report_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = Box::pin(create_post_report(data, context, local_user_view)) .await? .0 .post_report_view; let (post, counts) = convert_post(res.post); let post_report = PostReportV3 { id: Default::default(), creator_id: Default::default(), post_id: Default::default(), original_post_name: Default::default(), original_post_url: Default::default(), original_post_body: Default::default(), reason: Default::default(), resolved: Default::default(), resolver_id: Default::default(), published: Default::default(), updated: Default::default(), }; Ok(Json(PostReportResponseV3 { post_report_view: PostReportViewV3 { post_report, post, community: convert_community(res.community), creator: convert_person(res.creator).0, post_creator: convert_person(res.post_creator).0, creator_banned_from_community: false, creator_is_moderator: false, creator_is_admin: false, subscribed: SubscribedTypeV3::NotSubscribed, saved: false, read: false, hidden: false, creator_blocked: false, my_vote: None, unread_comments: 0, counts, resolver: None, }, })) } pub async fn create_comment_report_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = Box::pin(create_comment_report(data, context, local_user_view)) .await? .0 .comment_report_view; let (comment, counts) = convert_comment(res.comment); let comment_report = CommentReportV3 { id: Default::default(), creator_id: Default::default(), comment_id: Default::default(), original_comment_text: Default::default(), reason: Default::default(), resolved: Default::default(), resolver_id: Default::default(), published: Default::default(), updated: Default::default(), }; Ok(Json(CommentReportResponseV3 { comment_report_view: CommentReportViewV3 { comment_report, comment, post: convert_post(res.post).0, community: convert_community(res.community), creator: convert_person(res.creator).0, comment_creator: convert_person(res.comment_creator).0, creator_banned_from_community: false, creator_is_moderator: false, creator_is_admin: false, subscribed: SubscribedTypeV3::NotSubscribed, saved: false, creator_blocked: false, my_vote: None, counts, resolver: None, }, })) } pub(crate) async fn get_community_v3( data: Query, context: ApubData, local_user_view: Option, ) -> LemmyResult> { let res = get_community(data, context, local_user_view).await?.0; Ok(Json(GetCommunityResponseV3 { community_view: convert_community_view(res.community_view), site: res.site.map(convert_site), moderators: res .moderators .into_iter() .map(|m| CommunityModeratorViewV3 { community: convert_community(m.community), moderator: convert_person(m.moderator).0, }) .collect(), discussion_languages: convert_language_ids(res.discussion_languages), })) } pub(crate) async fn follow_community_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = follow_community(data, context, local_user_view).await?.0; Ok(Json(CommunityResponseV3 { community_view: convert_community_view(res.community_view), discussion_languages: convert_language_ids(res.discussion_languages), })) } pub(crate) async fn block_community_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let blocked = data.block; let res = user_block_community(data, context, local_user_view) .await? .0; Ok(Json(BlockCommunityResponseV3 { community_view: convert_community_view(res.community_view), blocked, })) } pub(crate) async fn delete_post_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = delete_post(data, context, local_user_view).await?; convert_post_response(res) } pub(crate) async fn update_post_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = Box::pin(edit_post(data, context, local_user_view)).await?; convert_post_response(res) } pub(crate) async fn delete_comment_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = delete_comment(data, context, local_user_view).await?; convert_comment_response(res) } pub(crate) async fn update_comment_v3( data: Json, context: ApubData, local_user_view: LocalUserView, ) -> LemmyResult> { let res = Box::pin(edit_comment(data, context, local_user_view)).await?; convert_comment_response(res) } pub(crate) async fn list_communities_v3( Query(data): Query, context: Data, local_user_view: Option, ) -> LemmyResult> { let ListCommunitiesV3 { type_, sort, show_nsfw, limit, .. } = data; let (sort, time_range_seconds) = convert_community_listing_sort(sort); let data = ListCommunities { type_: type_.map(convert_listing_type), sort, time_range_seconds, show_nsfw, page_cursor: None, limit, }; let res = list_communities(Query(data), context, local_user_view) .await? .0; Ok(Json(ListCommunitiesResponseV3 { communities: res.into_iter().map(convert_community_view).collect(), })) } pub(crate) async fn register_v3( data: Json, req: HttpRequest, context: ApubData, ) -> LemmyResult> { let res = Box::pin(register(data, req, context)).await?.0; convert_login_response(res) } pub(crate) async fn block_person_v3( data: Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let blocked = data.block; let res = user_block_person(data, context, local_user_view).await?.0; Ok(Json(BlockPersonResponseV3 { person_view: convert_person_view(res.person_view), blocked, })) } ================================================ FILE: crates/api/routes_v3/src/lib.rs ================================================ use crate::handlers::{ block_community_v3, block_person_v3, create_comment_report_v3, create_comment_v3, create_post_report_v3, create_post_v3, delete_comment_v3, delete_post_v3, follow_community_v3, get_community_v3, get_post_v3, get_site_v3, like_comment_v3, like_post_v3, list_comments_v3, list_communities_v3, list_posts_v3, login_v3, logout_v3, mark_all_notifications_read_v3, register_v3, resolve_object_v3, save_comment_v3, save_post_v3, search_v3, unread_count_v3, update_comment_v3, update_post_v3, }; use actix_web::{guard, web::*}; use lemmy_api::local_user::donation_dialog_shown::donation_dialog_shown; use lemmy_utils::rate_limit::RateLimit; mod convert; mod handlers; pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { cfg.service( scope("/api/v3") .wrap(rate_limit.message()) // Site .service(scope("/site").route("", get().to(get_site_v3))) .service( resource("/search") .wrap(rate_limit.search()) .route(get().to(search_v3)), ) .service( resource("/resolve_object") .wrap(rate_limit.message()) .route(get().to(resolve_object_v3)), ) .service( scope("/community") .wrap(rate_limit.message()) .route("", get().to(get_community_v3)) .route("/list", get().to(list_communities_v3)) .route("/follow", post().to(follow_community_v3)) .route("/block", post().to(block_community_v3)), ) .service( resource("/post") .guard(guard::Post()) .wrap(rate_limit.post()) .route(post().to(create_post_v3)), ) .service( scope("/post") .wrap(rate_limit.message()) .route("", get().to(get_post_v3)) .route("", put().to(update_post_v3)) .route("/delete", post().to(delete_post_v3)) .route("/list", get().to(list_posts_v3)) .route("/like", post().to(like_post_v3)) .route("/save", put().to(save_post_v3)) .route("/report", post().to(create_post_report_v3)), ) .service( resource("/comment") .guard(guard::Post()) .wrap(rate_limit.comment()) .route(post().to(create_comment_v3)), ) .service( scope("/comment") .wrap(rate_limit.message()) .route("", put().to(update_comment_v3)) .route("/delete", post().to(delete_comment_v3)) .route("/like", post().to(like_comment_v3)) .route("/list", get().to(list_comments_v3)) .route("/save", put().to(save_comment_v3)) .route("/report", post().to(create_comment_report_v3)), ) .service( resource("/user/login") .guard(guard::Post()) .wrap(rate_limit.register()) .route(post().to(login_v3)), ) .service( resource("/user/register") .guard(guard::Post()) .wrap(rate_limit.register()) .route(post().to(register_v3)), ) .service( scope("/user") .wrap(rate_limit.message()) .route("/logout", post().to(logout_v3)) .route("/unread_count", get().to(unread_count_v3)) .route("/block", post().to(block_person_v3)) .route( "/mark_all_as_read", post().to(mark_all_notifications_read_v3), ) .route("/donation_dialog_shown", post().to(donation_dialog_shown)), ), ); } ================================================ FILE: crates/apub/activities/Cargo.toml ================================================ [package] name = "lemmy_apub_activities" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_apub_activities" path = "src/lib.rs" doctest = false [features] full = [] [lints] workspace = true [dependencies] lemmy_db_views_community = { workspace = true, features = ["full"] } lemmy_db_views_community_moderator = { workspace = true, features = ["full"] } lemmy_db_views_post = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_private_message = { workspace = true, features = ["full"] } lemmy_db_views_site = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true, features = ["full"] } lemmy_apub_objects = { workspace = true } activitypub_federation = { workspace = true } lemmy_db_schema_file = { workspace = true } diesel = { workspace = true } chrono = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } tracing = { workspace = true } strum = { workspace = true } url = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } uuid = { workspace = true } async-trait = { workspace = true } anyhow = { workspace = true } serde_with.workspace = true enum_delegate = "0.2.0" either = { workspace = true } lemmy_diesel_utils = { workspace = true } [dev-dependencies] [package.metadata.cargo-shear] ignored = ["futures", "futures-util"] ================================================ FILE: crates/apub/activities/src/activity_lists.rs ================================================ use crate::protocol::{ block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, community::{ announce::{AnnounceActivity, RawAnnouncableActivities}, collection_add::CollectionAdd, collection_remove::CollectionRemove, lock::{LockPageOrNote, UndoLockPageOrNote}, report::Report, resolve_report::ResolveReport, update::Update, }, create_or_update::{note_wrapper::CreateOrUpdateNoteWrapper, page::CreateOrUpdatePage}, deletion::{delete::Delete, undo_delete::UndoDelete}, following::{ accept::AcceptFollow, follow::Follow, reject::RejectFollow, undo_follow::UndoFollow, }, voting::{undo_vote::UndoVote, vote::Vote}, }; use activitypub_federation::{config::Data, traits::Activity}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::community::ApubCommunity, protocol::page::Page, utils::protocol::InCommunity, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; use url::Url; /// List of activities which the shared inbox can handle. /// /// This could theoretically be defined as an enum with variants `GroupInboxActivities` and /// `PersonInboxActivities`. In practice we need to write it out manually so that priorities /// are handled correctly. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] #[enum_delegate::implement(Activity)] pub enum SharedInboxActivities { Follow(Follow), AcceptFollow(AcceptFollow), RejectFollow(RejectFollow), UndoFollow(UndoFollow), Report(Report), ResolveReport(ResolveReport), AnnounceActivity(AnnounceActivity), /// This is a catch-all and needs to be last RawAnnouncableActivities(RawAnnouncableActivities), } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] #[enum_delegate::implement(Activity)] pub enum AnnouncableActivities { CreateOrUpdateNoteWrapper(CreateOrUpdateNoteWrapper), CreateOrUpdatePost(CreateOrUpdatePage), Vote(Vote), UndoVote(UndoVote), Delete(Delete), UndoDelete(UndoDelete), UpdateCommunity(Box), BlockUser(BlockUser), UndoBlockUser(UndoBlockUser), CollectionAdd(CollectionAdd), CollectionRemove(CollectionRemove), Lock(LockPageOrNote), UndoLock(UndoLockPageOrNote), Report(Report), ResolveReport(ResolveReport), // For compatibility with Pleroma/Mastodon (send only) Page(Page), } impl InCommunity for AnnouncableActivities { async fn community(&self, context: &Data) -> LemmyResult { use AnnouncableActivities::*; match self { CreateOrUpdateNoteWrapper(a) => a.community(context).await, CreateOrUpdatePost(a) => a.community(context).await, Vote(a) => a.community(context).await, UndoVote(a) => a.object.community(context).await, Delete(a) => a.community(context).await, UndoDelete(a) => a.object.community(context).await, UpdateCommunity(a) => a.community(context).await, BlockUser(a) => a.community(context).await, UndoBlockUser(a) => a.object.community(context).await, CollectionAdd(a) => a.community(context).await, CollectionRemove(a) => a.community(context).await, Lock(a) => a.community(context).await, UndoLock(a) => a.object.community(context).await, Report(a) => a.community(context).await, ResolveReport(a) => a.object.community(context).await, Page(_) => Err(LemmyErrorType::NotFound.into()), } } } #[cfg(test)] mod tests { use crate::activity_lists::SharedInboxActivities; use lemmy_apub_objects::utils::test::{test_json, test_parse_lemmy_item}; use lemmy_utils::error::LemmyResult; #[test] fn test_shared_inbox() -> LemmyResult<()> { test_parse_lemmy_item::( "../apub/assets/lemmy/activities/deletion/delete_user.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/following/accept.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_comment.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_private_message.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/following/follow.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_comment.json", )?; test_json::("../apub/assets/mastodon/activities/follow.json")?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/block/block_user.rs ================================================ use super::{to, update_removed_for_instance}; use crate::{ MOD_ACTION_DEFAULT_REASON, activity_lists::AnnouncableActivities, block::{SiteOrCommunity, generate_cc}, check_community_deleted_or_removed, community::send_activity_in_community, generate_activity_id, protocol::block::block_user::BlockUser, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::activity::BlockType, traits::{Activity, Actor, Object}, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, utils::{remove_or_restore_user_data, remove_or_restore_user_data_in_community}, }; use lemmy_apub_objects::{ objects::person::ApubPerson, utils::functions::{verify_is_public, verify_mod_action, verify_visibility}, }; use lemmy_db_schema::{ source::{ activity::ActivitySendTargets, community::{CommunityActions, CommunityPersonBanForm}, instance::{InstanceActions, InstanceBanForm}, modlog::{Modlog, ModlogInsertForm}, }, traits::Bannable, }; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult}; use url::Url; impl BlockUser { pub(in crate::block) async fn new( target: &SiteOrCommunity, user: &ApubPerson, mod_: &ApubPerson, remove_data: Option, reason: String, expires: Option>, context: &Data, ) -> LemmyResult { let to = to(target)?; Ok(BlockUser { actor: mod_.id().clone().into(), to, object: user.id().clone().into(), cc: generate_cc(target, &mut context.pool()).await?, target: target.id().clone().into(), kind: BlockType::Block, remove_data, summary: Some(reason), id: generate_activity_id(BlockType::Block, context)?, end_time: expires, audience: target.as_ref().right().map(|c| c.ap_id.clone().into()), }) } pub async fn send( target: &SiteOrCommunity, user: &ApubPerson, mod_: &ApubPerson, remove_data: bool, reason: String, expires: Option>, context: &Data, ) -> LemmyResult<()> { let block = BlockUser::new( target, user, mod_, Some(remove_data), reason, expires, context, ) .await?; match target { SiteOrCommunity::Left(_) => { let inboxes = ActivitySendTargets::to_all_instances(); send_lemmy_activity(context, block, mod_, inboxes, false).await } SiteOrCommunity::Right(c) => { let activity = AnnouncableActivities::BlockUser(block); let inboxes = ActivitySendTargets::to_inbox(user.shared_inbox_or_inbox()); send_activity_in_community(activity, mod_, c, inboxes, true, context).await } } } } #[async_trait::async_trait] impl Activity for BlockUser { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { match self.target.dereference(context).await? { SiteOrCommunity::Left(_site) => { verify_is_public(&self.to, &self.cc)?; } SiteOrCommunity::Right(community) => { verify_visibility(&self.to, &self.cc, &community)?; verify_mod_action(&self.actor, &community, context).await?; check_community_deleted_or_removed(&community)?; } } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let expires_at = self.end_time; let mod_person = self.actor.dereference(context).await?; // Dereference local here so that deleted users can be banned as well. let blocked_person = self.object.dereference_local(context).await?; let target = self.target.dereference(context).await?; let reason = self .summary .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string()); let pool = &mut context.pool(); match target { SiteOrCommunity::Left(site) => { let form = InstanceBanForm::new(blocked_person.id, site.instance_id, expires_at); InstanceActions::ban(pool, &form).await?; // Mod tables - create ban entry first so bulk actions can reference it as parent let form = ModlogInsertForm::admin_ban(&mod_person, blocked_person.id, true, expires_at, &reason); let action = Modlog::create(&mut context.pool(), &[form]).await?; let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id; notify_mod_action(action, context); if self.remove_data.unwrap_or(false) { if blocked_person.instance_id == site.instance_id { // user banned from home instance, remove all content remove_or_restore_user_data( mod_person.id, blocked_person.id, true, &reason, parent_id, context, ) .await?; } else { update_removed_for_instance(&blocked_person, &site, true, pool).await?; } } } SiteOrCommunity::Right(community) => { let community_user_ban_form = CommunityPersonBanForm { ban_expires_at: Some(expires_at), ..CommunityPersonBanForm::new(community.id, blocked_person.id) }; CommunityActions::ban(&mut context.pool(), &community_user_ban_form).await?; // Dont unsubscribe the user so that we can receive a potential unban activity. // If we unfollowed the community here, activities from the community would be rejected // in [[can_accept_activity_in_community]] in case are no other local followers. // Mod tables - create ban entry first so bulk actions can reference it as parent let form = ModlogInsertForm::mod_ban_from_community( mod_person.id, community.id, blocked_person.id, true, expires_at, &reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id; notify_mod_action(action, context); if self.remove_data.unwrap_or(false) { remove_or_restore_user_data_in_community( community.id, mod_person.id, blocked_person.id, true, &reason, parent_id, &mut context.pool(), ) .await?; } } } Ok(()) } } ================================================ FILE: crates/apub/activities/src/block/mod.rs ================================================ use crate::protocol::block::{block_user::BlockUser, undo_block_user::UndoBlockUser}; use activitypub_federation::{config::Data, kinds::public, traits::Object}; use either::Either; use lemmy_api_utils::{context::LemmyContext, utils::check_expire_time}; use lemmy_apub_objects::{ objects::{community::ApubCommunity, instance::ApubSite}, utils::functions::generate_to, }; use lemmy_db_schema::{ newtypes::CommunityId, source::{comment::Comment, community::Community, person::Person, post::Post, site::Site}, }; use lemmy_db_views_community::api::BanFromCommunity; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{connection::DbPool, traits::Crud}; use lemmy_utils::error::LemmyResult; use url::Url; pub mod block_user; pub mod undo_block_user; pub type SiteOrCommunity = Either; async fn generate_cc(target: &SiteOrCommunity, pool: &mut DbPool<'_>) -> LemmyResult> { Ok(match target { SiteOrCommunity::Left(_) => Site::read_remote_sites(pool) .await? .into_iter() .map(|s| s.ap_id.into()) .collect(), SiteOrCommunity::Right(c) => vec![c.id().clone()], }) } pub(crate) async fn send_ban_from_site( moderator: Person, banned_user: Person, reason: String, remove_or_restore_data: Option, ban: bool, expires: Option, context: Data, ) -> LemmyResult<()> { let site = SiteOrCommunity::Left(SiteView::read_local(&mut context.pool()).await?.site.into()); let expires = check_expire_time(expires)?; if ban { BlockUser::send( &site, &banned_user.into(), &moderator.into(), remove_or_restore_data.unwrap_or(false), reason.clone(), expires, &context, ) .await } else { UndoBlockUser::send( &site, &banned_user.into(), &moderator.into(), remove_or_restore_data.unwrap_or(false), reason.clone(), &context, ) .await } } pub(crate) async fn send_ban_from_community( mod_: Person, community_id: CommunityId, banned_person: Person, data: BanFromCommunity, context: Data, ) -> LemmyResult<()> { let community: ApubCommunity = Community::read(&mut context.pool(), community_id) .await? .into(); let expires_at = check_expire_time(data.expires_at)?; if data.ban { BlockUser::send( &SiteOrCommunity::Right(community), &banned_person.into(), &mod_.into(), data.remove_or_restore_data.unwrap_or(false), data.reason.clone(), expires_at, &context, ) .await } else { UndoBlockUser::send( &SiteOrCommunity::Right(community), &banned_person.into(), &mod_.into(), data.remove_or_restore_data.unwrap_or(false), data.reason.clone(), &context, ) .await } } fn to(target: &SiteOrCommunity) -> LemmyResult> { Ok(if let SiteOrCommunity::Right(c) = target { generate_to(c)? } else { vec![public()] }) } // user banned from remote instance, remove content only in communities from that // instance async fn update_removed_for_instance( blocked_person: &Person, site: &ApubSite, removed: bool, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { Post::update_removed_for_creator_and_instance(pool, blocked_person.id, site.instance_id, removed) .await?; Comment::update_removed_for_creator_and_instance( pool, blocked_person.id, site.instance_id, removed, ) .await?; Ok(()) } ================================================ FILE: crates/apub/activities/src/block/undo_block_user.rs ================================================ use super::{to, update_removed_for_instance}; use crate::{ MOD_ACTION_DEFAULT_REASON, activity_lists::AnnouncableActivities, block::{SiteOrCommunity, generate_cc}, community::send_activity_in_community, generate_activity_id, protocol::block::{block_user::BlockUser, undo_block_user::UndoBlockUser}, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::activity::UndoType, protocol::verification::verify_domains_match, traits::{Activity, Actor, Object}, }; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, utils::{remove_or_restore_user_data, remove_or_restore_user_data_in_community}, }; use lemmy_apub_objects::{ objects::person::ApubPerson, utils::functions::{verify_is_public, verify_visibility}, }; use lemmy_db_schema::{ source::{ activity::ActivitySendTargets, community::{CommunityActions, CommunityPersonBanForm}, instance::{InstanceActions, InstanceBanForm}, modlog::{Modlog, ModlogInsertForm}, }, traits::Bannable, }; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult}; use url::Url; impl UndoBlockUser { pub async fn send( target: &SiteOrCommunity, user: &ApubPerson, mod_: &ApubPerson, restore_data: bool, reason: String, context: &Data, ) -> LemmyResult<()> { let block = BlockUser::new(target, user, mod_, None, reason, None, context).await?; let to = to(target)?; let id = generate_activity_id(UndoType::Undo, context)?; let undo = UndoBlockUser { actor: mod_.id().clone().into(), to, object: block, cc: generate_cc(target, &mut context.pool()).await?, kind: UndoType::Undo, id: id.clone(), restore_data: Some(restore_data), audience: target.as_ref().right().map(|c| c.ap_id.clone().into()), }; let mut inboxes = ActivitySendTargets::to_inbox(user.shared_inbox_or_inbox()); match target { SiteOrCommunity::Left(_) => { inboxes.set_all_instances(); send_lemmy_activity(context, undo, mod_, inboxes, false).await } SiteOrCommunity::Right(c) => { let activity = AnnouncableActivities::UndoBlockUser(undo); send_activity_in_community(activity, mod_, c, inboxes, true, context).await } } } } #[async_trait::async_trait] impl Activity for UndoBlockUser { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { verify_domains_match(self.actor.inner(), self.object.actor.inner())?; self.object.verify(context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let expires_at = self.object.end_time; let mod_person = self.actor.dereference(context).await?; let blocked_person = self.object.object.dereference_local(context).await?; let reason = self .object .summary .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string()); let pool = &mut context.pool(); match self.object.target.dereference(context).await? { SiteOrCommunity::Left(site) => { verify_is_public(&self.to, &self.cc)?; let form = InstanceBanForm::new(blocked_person.id, site.instance_id, expires_at); InstanceActions::unban(pool, &form).await?; // Mod tables - create unban entry first so bulk actions can reference it as parent let form = ModlogInsertForm::admin_ban(&mod_person, blocked_person.id, false, expires_at, &reason); let action = Modlog::create(&mut context.pool(), &[form]).await?; let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id; notify_mod_action(action, context.app_data()); if self.restore_data.unwrap_or(false) { if blocked_person.instance_id == site.instance_id { // user unbanned from home instance, restore all content remove_or_restore_user_data( mod_person.id, blocked_person.id, false, &reason, parent_id, context, ) .await?; } else { update_removed_for_instance(&blocked_person, &site, false, pool).await?; } } } SiteOrCommunity::Right(community) => { verify_visibility(&self.to, &self.cc, &community)?; let community_user_ban_form = CommunityPersonBanForm::new(community.id, blocked_person.id); CommunityActions::unban(&mut context.pool(), &community_user_ban_form).await?; // Mod tables - create unban entry first so bulk actions can reference it as parent let form = ModlogInsertForm::mod_ban_from_community( mod_person.id, community.id, blocked_person.id, false, expires_at, &reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; let parent_id = action.first().ok_or(LemmyErrorType::NotFound)?.id; notify_mod_action(action, context.app_data()); if self.restore_data.unwrap_or(false) { remove_or_restore_user_data_in_community( community.id, mod_person.id, blocked_person.id, false, &reason, parent_id, &mut context.pool(), ) .await?; } } } Ok(()) } } ================================================ FILE: crates/apub/activities/src/community/announce.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, generate_activity_id, generate_announce_activity_id, protocol::{ IdOrNestedObject, community::announce::{AnnounceActivity, RawAnnouncableActivities}, }, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::activity::AnnounceType, traits::{Activity, Object}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::community::ApubCommunity, utils::{ functions::{generate_to, verify_person_in_community, verify_visibility}, protocol::{Id, InCommunity}, }, }; use lemmy_db_schema::source::{activity::ActivitySendTargets, community::CommunityActions}; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError}; use serde_json::Value; use url::Url; #[async_trait::async_trait] impl Activity for RawAnnouncableActivities { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { &self.actor } async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { Ok(()) } async fn receive(self, context: &Data) -> Result<(), Self::Error> { let activity: AnnouncableActivities = self.clone().try_into()?; // This is only for sending, not receiving so we reject it. if let AnnouncableActivities::Page(_) = activity { return Err(UntranslatedError::CannotReceivePage.into()); } // Need to treat community as optional here because `Delete/PrivateMessage` gets routed through let community = activity.community(context).await.ok(); can_accept_activity_in_community(&community, context).await?; // verify and receive activity activity.verify(context).await?; let ap_id = activity.actor().clone().into(); activity.receive(context).await?; // if community is local, send activity to followers if let Some(community) = community && community.local { verify_person_in_community(&ap_id, &community, context).await?; AnnounceActivity::send(self, &community, context).await?; } Ok(()) } } impl Id for RawAnnouncableActivities { fn id(&self) -> &Url { &self.id } } impl AnnounceActivity { pub fn new( object: RawAnnouncableActivities, community: &ApubCommunity, context: &Data, ) -> LemmyResult { let inner_kind = object .other .get("type") .and_then(serde_json::Value::as_str) .unwrap_or("other"); let id = generate_announce_activity_id(inner_kind, &context.settings().get_protocol_and_hostname())?; Ok(AnnounceActivity { actor: community.id().clone().into(), to: generate_to(community)?, object: IdOrNestedObject::NestedObject(object), cc: community .followers_url .clone() .map(Into::into) .into_iter() .collect(), kind: AnnounceType::Announce, id, }) } pub async fn send( object: RawAnnouncableActivities, community: &ApubCommunity, context: &Data, ) -> LemmyResult<()> { let announce = AnnounceActivity::new(object.clone(), community, context)?; let inboxes = ActivitySendTargets::to_local_community_followers(community.id); send_lemmy_activity(context, announce, community, inboxes.clone(), false).await?; // Pleroma and Mastodon can't handle activities like Announce/Create/Page. So for // compatibility, we also send Announce/Page so that they can follow Lemmy communities. let object_parsed = object.try_into()?; if let AnnouncableActivities::CreateOrUpdatePost(c) = object_parsed { // Hack: need to convert Page into a format which can be sent as activity, which requires // adding actor field. let announcable_page = RawAnnouncableActivities { id: generate_activity_id(AnnounceType::Announce, context)?, actor: c.actor.clone().into_inner(), other: serde_json::to_value(c.object)? .as_object() .ok_or(UntranslatedError::Unreachable)? .clone(), }; let announce_compat = AnnounceActivity::new(announcable_page, community, context)?; send_lemmy_activity(context, announce_compat, community, inboxes, false).await?; } Ok(()) } } #[async_trait::async_trait] impl Activity for AnnounceActivity { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, _context: &Data) -> LemmyResult<()> { Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let object: AnnouncableActivities = self.object.object(context).await?.try_into()?; // This is only for sending, not receiving so we reject it. if let AnnouncableActivities::Page(_) = object { return Err(UntranslatedError::CannotReceivePage.into()); } let community = object.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; can_accept_activity_in_community(&Some(community), context).await?; // verify here in order to avoid fetching the object twice over http object.verify(context).await?; object.receive(context).await } } impl TryFrom for AnnouncableActivities { type Error = serde_json::error::Error; fn try_from(value: RawAnnouncableActivities) -> Result { let mut map = value.other.clone(); map.insert("id".to_string(), Value::String(value.id.to_string())); map.insert("actor".to_string(), Value::String(value.actor.to_string())); serde_json::from_value(Value::Object(map)) } } impl TryFrom for RawAnnouncableActivities { type Error = serde_json::error::Error; fn try_from(value: AnnouncableActivities) -> Result { serde_json::from_value(serde_json::to_value(value)?) } } /// Check if an activity in the given community can be accepted. To return true, the community must /// either be local to this instance, or it must have at least one local follower. /// /// TODO: This means mentions dont work if the community has no local followers. Can be fixed /// by checking if any local user is in to/cc fields of activity. Anyway this is a minor /// problem compared to receiving unsolicited posts. async fn can_accept_activity_in_community( community: &Option, context: &Data, ) -> LemmyResult<()> { if let Some(community) = community { // Local only community can't federate if !community.visibility.can_federate() { return Err(LemmyErrorType::NotFound.into()); } if !community.local { CommunityActions::check_accept_activity_in_community(&mut context.pool(), community).await? } } Ok(()) } ================================================ FILE: crates/apub/activities/src/community/collection_add.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, check_community_deleted_or_removed, community::send_activity_in_community, generate_activity_id, protocol::community::{collection_add::CollectionAdd, collection_remove::CollectionRemove}, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::AddType, traits::{Activity, Actor, Object}, }; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, utils::{generate_featured_url, generate_moderators_url}, }; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, utils::{ functions::{generate_to, verify_mod_action, verify_visibility}, protocol::InCommunity, }, }; use lemmy_db_schema::{ impls::community::CollectionType, newtypes::CommunityId, source::{ activity::ActivitySendTargets, community::{Community, CommunityActions, CommunityModeratorForm}, modlog::{Modlog, ModlogInsertForm}, person::Person, post::{Post, PostUpdateForm}, }, }; use lemmy_db_schema_file::PersonId; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl CollectionAdd { async fn send_add_mod( community: &ApubCommunity, added_mod: &ApubPerson, actor: &ApubPerson, context: &Data, ) -> LemmyResult<()> { let id = generate_activity_id(AddType::Add, context)?; let add = CollectionAdd { actor: actor.id().clone().into(), to: generate_to(community)?, object: added_mod.id().clone(), target: generate_moderators_url(&community.ap_id)?.into(), cc: vec![community.id().clone()], kind: AddType::Add, id: id.clone(), audience: Some(community.ap_id.clone().into()), }; let activity = AnnouncableActivities::CollectionAdd(add); let inboxes = ActivitySendTargets::to_inbox(added_mod.shared_inbox_or_inbox()); send_activity_in_community(activity, actor, community, inboxes, true, context).await } async fn send_add_featured_post( community: &ApubCommunity, featured_post: &ApubPost, actor: &ApubPerson, context: &Data, ) -> LemmyResult<()> { let id = generate_activity_id(AddType::Add, context)?; let add = CollectionAdd { actor: actor.id().clone().into(), to: generate_to(community)?, object: featured_post.ap_id.clone().into(), target: generate_featured_url(&community.ap_id)?.into(), cc: vec![community.id().clone()], kind: AddType::Add, id: id.clone(), audience: Some(community.ap_id.clone().into()), }; let activity = AnnouncableActivities::CollectionAdd(add); send_activity_in_community( activity, actor, community, ActivitySendTargets::empty(), true, context, ) .await } } #[async_trait::async_trait] impl Activity for CollectionAdd { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let community = self.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; verify_mod_action(&self.actor, &community, context).await?; check_community_deleted_or_removed(&community)?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let (community, collection_type) = Community::get_by_collection_url(&mut context.pool(), &self.target.clone().into()).await?; match collection_type { CollectionType::Moderators => { let new_mod = ObjectId::::from(self.object) .dereference(context) .await?; // If we had to refetch the community while parsing the activity, then the new mod has // already been added. Skip it here as it would result in a duplicate key error. let new_mod_id = new_mod.id; let moderated_communities = CommunityActions::get_person_moderated_communities(&mut context.pool(), new_mod_id) .await?; if !moderated_communities.contains(&community.id) { let form = CommunityModeratorForm::new(community.id, new_mod.id); CommunityActions::join(&mut context.pool(), &form).await?; // write mod log let actor = self.actor.dereference(context).await?; let form = ModlogInsertForm::mod_add_to_community(actor.id, community.id, new_mod.id, false); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context); } } CollectionType::Featured => { let post = ObjectId::::from(self.object) .dereference(context) .await?; let form = PostUpdateForm { featured_community: Some(true), ..Default::default() }; Post::update(&mut context.pool(), post.id, &form).await?; } } Ok(()) } } pub(crate) async fn send_add_mod_to_community( actor: Person, community_id: CommunityId, updated_mod_id: PersonId, added: bool, context: Data, ) -> LemmyResult<()> { let actor: ApubPerson = actor.into(); let community: ApubCommunity = Community::read(&mut context.pool(), community_id) .await? .into(); let updated_mod: ApubPerson = Person::read(&mut context.pool(), updated_mod_id) .await? .into(); if added { CollectionAdd::send_add_mod(&community, &updated_mod, &actor, &context).await } else { CollectionRemove::send_remove_mod(&community, &updated_mod, &actor, &context).await } } pub(crate) async fn send_feature_post( post: Post, actor: Person, featured: bool, context: Data, ) -> LemmyResult<()> { let actor: ApubPerson = actor.into(); let post: ApubPost = post.into(); let community = Community::read(&mut context.pool(), post.community_id) .await? .into(); if featured { CollectionAdd::send_add_featured_post(&community, &post, &actor, &context).await } else { CollectionRemove::send_remove_featured_post(&community, &post, &actor, &context).await } } ================================================ FILE: crates/apub/activities/src/community/collection_remove.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, check_community_deleted_or_removed, community::send_activity_in_community, generate_activity_id, protocol::community::collection_remove::CollectionRemove, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::RemoveType, traits::{Activity, Actor, Object}, }; use lemmy_api_utils::{ context::LemmyContext, notify::notify_mod_action, utils::{generate_featured_url, generate_moderators_url}, }; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, utils::{ functions::{generate_to, verify_mod_action, verify_visibility}, protocol::InCommunity, }, }; use lemmy_db_schema::{ impls::community::CollectionType, source::{ activity::ActivitySendTargets, community::{Community, CommunityActions, CommunityModeratorForm}, modlog::{Modlog, ModlogInsertForm}, post::{Post, PostUpdateForm}, }, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl CollectionRemove { pub(super) async fn send_remove_mod( community: &ApubCommunity, removed_mod: &ApubPerson, actor: &ApubPerson, context: &Data, ) -> LemmyResult<()> { let id = generate_activity_id(RemoveType::Remove, context)?; let remove = CollectionRemove { actor: actor.id().clone().into(), to: generate_to(community)?, object: removed_mod.id().clone(), target: generate_moderators_url(&community.ap_id)?.into(), id: id.clone(), cc: vec![community.id().clone()], kind: RemoveType::Remove, audience: Some(community.ap_id.clone().into()), }; let activity = AnnouncableActivities::CollectionRemove(remove); let inboxes = ActivitySendTargets::to_inbox(removed_mod.shared_inbox_or_inbox()); send_activity_in_community(activity, actor, community, inboxes, true, context).await } pub(super) async fn send_remove_featured_post( community: &ApubCommunity, featured_post: &ApubPost, actor: &ApubPerson, context: &Data, ) -> LemmyResult<()> { let id = generate_activity_id(RemoveType::Remove, context)?; let remove = CollectionRemove { actor: actor.id().clone().into(), to: generate_to(community)?, object: featured_post.ap_id.clone().into(), target: generate_featured_url(&community.ap_id)?.into(), cc: vec![community.id().clone()], kind: RemoveType::Remove, id: id.clone(), audience: Some(community.ap_id.clone().into()), }; let activity = AnnouncableActivities::CollectionRemove(remove); send_activity_in_community( activity, actor, community, ActivitySendTargets::empty(), true, context, ) .await } } #[async_trait::async_trait] impl Activity for CollectionRemove { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let community = self.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; verify_mod_action(&self.actor, &community, context).await?; check_community_deleted_or_removed(&community)?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let (community, collection_type) = Community::get_by_collection_url(&mut context.pool(), &self.target.into()).await?; match collection_type { CollectionType::Moderators => { let remove_mod = ObjectId::::from(self.object) .dereference(context) .await?; let form = CommunityModeratorForm::new(community.id, remove_mod.id); CommunityActions::leave(&mut context.pool(), &form).await?; // write mod log let actor = self.actor.dereference(context).await?; let form = ModlogInsertForm::mod_add_to_community(actor.id, community.id, remove_mod.id, true); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context); } CollectionType::Featured => { let post = ObjectId::::from(self.object) .dereference(context) .await?; let form = PostUpdateForm { featured_community: Some(false), ..Default::default() }; Post::update(&mut context.pool(), post.id, &form).await?; } } Ok(()) } } ================================================ FILE: crates/apub/activities/src/community/lock.rs ================================================ use crate::{ MOD_ACTION_DEFAULT_REASON, activity_lists::AnnouncableActivities, check_community_deleted_or_removed, community::send_activity_in_community, generate_activity_id, post_or_comment_community, protocol::community::lock::{LockPageOrNote, LockType, UndoLockPageOrNote}, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::UndoType, traits::Activity, }; use lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action}; use lemmy_apub_objects::{ objects::{PostOrComment, community::ApubCommunity}, utils::{ functions::{generate_to, verify_mod_action, verify_visibility}, protocol::InCommunity, }, }; use lemmy_db_schema::source::{ activity::ActivitySendTargets, comment::Comment, modlog::{Modlog, ModlogInsertForm}, person::Person, post::{Post, PostUpdateForm}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; #[async_trait::async_trait] impl Activity for LockPageOrNote { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> Result<(), Self::Error> { let community = self.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; check_community_deleted_or_removed(&community)?; verify_mod_action(&self.actor, &community, context).await?; Ok(()) } async fn receive(self, context: &Data) -> Result<(), Self::Error> { let reason = self .summary .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string()); let actor = self.actor.dereference(context).await?; match self.object.dereference(context).await? { PostOrComment::Left(post) => { let form = PostUpdateForm { locked: Some(true), ..Default::default() }; Post::update(&mut context.pool(), post.id, &form).await?; let form = ModlogInsertForm::mod_lock_post(actor.id, &post, true, &reason); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context); } PostOrComment::Right(comment) => { Comment::update_locked_for_comment_and_children(&mut context.pool(), &comment.path, true) .await?; let community_id = Post::read(&mut context.pool(), comment.post_id) .await? .community_id; let form = ModlogInsertForm::mod_lock_comment(actor.id, &comment, community_id, true, &reason); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context); } } Ok(()) } } #[async_trait::async_trait] impl Activity for UndoLockPageOrNote { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> Result<(), Self::Error> { let community = self.object.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; check_community_deleted_or_removed(&community)?; verify_mod_action(&self.actor, &community, context).await?; Ok(()) } async fn receive(self, context: &Data) -> Result<(), Self::Error> { let reason = self .summary .unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string()); let actor = self.actor.dereference(context).await?; match self.object.object.dereference(context).await? { PostOrComment::Left(post) => { let form = PostUpdateForm { locked: Some(false), ..Default::default() }; Post::update(&mut context.pool(), post.id, &form).await?; let form = ModlogInsertForm::mod_lock_post(actor.id, &post, false, &reason); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context); } PostOrComment::Right(comment) => { Comment::update_locked_for_comment_and_children(&mut context.pool(), &comment.path, false) .await?; let community_id = Post::read(&mut context.pool(), comment.post_id) .await? .community_id; let form = ModlogInsertForm::mod_lock_comment(actor.id, &comment, community_id, false, &reason); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context); } } Ok(()) } } pub(crate) async fn send_lock( object: PostOrComment, actor: Person, locked: bool, reason: String, context: Data, ) -> LemmyResult<()> { let community: ApubCommunity = post_or_comment_community(&object, &context).await?.into(); let id = generate_activity_id(LockType::Lock, &context)?; let community_id = community.ap_id.inner().clone(); let ap_id = match object { PostOrComment::Left(p) => p.ap_id.clone(), PostOrComment::Right(c) => c.ap_id.clone(), }; let lock = LockPageOrNote { actor: actor.ap_id.clone().into(), to: generate_to(&community)?, object: ObjectId::from(ap_id), cc: vec![community_id.clone()], kind: LockType::Lock, id, summary: Some(reason.clone()), audience: Some(community.ap_id.clone().into()), }; let activity = if locked { AnnouncableActivities::Lock(lock) } else { let id = generate_activity_id(UndoType::Undo, &context)?; let undo = UndoLockPageOrNote { actor: lock.actor.clone(), to: generate_to(&community)?, cc: lock.cc.clone(), kind: UndoType::Undo, id, object: lock, summary: Some(reason), audience: Some(community.ap_id.clone().into()), }; AnnouncableActivities::UndoLock(undo) }; send_activity_in_community( activity, &actor.into(), &community, ActivitySendTargets::empty(), true, &context, ) .await?; Ok(()) } ================================================ FILE: crates/apub/activities/src/community/mod.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, protocol::community::announce::AnnounceActivity, send_lemmy_activity, }; use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Actor}; use either::Either; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; use lemmy_apub_objects::{ objects::{ PostOrComment, ReportableObjects, community::ApubCommunity, instance::ApubSite, person::ApubPerson, }, utils::functions::verify_mod_action, }; use lemmy_db_schema::source::{ activity::ActivitySendTargets, person::{Person, PersonActions}, site::Site, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub mod announce; pub mod collection_add; pub mod collection_remove; pub mod lock; pub mod report; pub mod resolve_report; pub mod update; /// This function sends all activities which are happening in a community to the right inboxes. /// For example Create/Page, Add/Mod etc, but not private messages. /// /// Activities are sent to the community itself if it lives on another instance. If the community /// is local, the activity is directly wrapped into Announce and sent to community followers. /// Activities are also sent to those who follow the actor (with exception of moderation /// activities). /// /// * `activity` - The activity which is being sent /// * `actor` - The user who is sending the activity /// * `community` - Community inside which the activity is sent /// * `inboxes` - Any additional inboxes the activity should be sent to (for example, to the user /// who is being promoted to moderator) /// * `is_mod_activity` - True for things like Add/Mod, these are not sent to user followers pub(crate) async fn send_activity_in_community( activity: AnnouncableActivities, actor: &ApubPerson, community: &ApubCommunity, extra_inboxes: ActivitySendTargets, is_mod_action: bool, context: &Data, ) -> LemmyResult<()> { // If community is local only, don't send anything out if !community.visibility.can_federate() { return Ok(()); } // send to any users which are mentioned or affected directly let mut inboxes = extra_inboxes; // send to user followers if !is_mod_action { inboxes.add_inboxes(PersonActions::follower_inboxes(&mut context.pool(), actor.id).await?); } if community.local { // send directly to community followers AnnounceActivity::send(activity.clone().try_into()?, community, context).await?; } else { // send to the community, which will then forward to followers inboxes.add_inbox(community.shared_inbox_or_inbox()); } send_lemmy_activity(context, activity.clone(), actor, inboxes, false).await?; Ok(()) } async fn report_inboxes( object_id: ObjectId, receiver: &Either, report_creator: &ApubPerson, context: &Data, ) -> LemmyResult { // send report to the community where object was posted let mut inboxes = ActivitySendTargets::to_inbox(receiver.shared_inbox_or_inbox()); // report is stored on the creator's instance, and sometimes listed there, so updates should be // sent there let report_creator_site = Site::read_from_instance_id(&mut context.pool(), report_creator.0.instance_id).await?; inboxes.add_inbox(report_creator_site.inbox_url.into()); if let Some(community) = local_community(receiver) { // send to all moderators let moderators = CommunityModeratorView::for_community(&mut context.pool(), community.id).await?; for m in moderators { inboxes.add_inbox(m.moderator.inbox_url.into()); } // also send report to user's home instance if possible let object_creator_id = match object_id.dereference_local(context).await? { ReportableObjects::Left(PostOrComment::Left(p)) => p.creator_id, ReportableObjects::Left(PostOrComment::Right(c)) => c.creator_id, _ => return Ok(inboxes), }; let object_creator = Person::read(&mut context.pool(), object_creator_id).await?; let object_creator_site: Option = Site::read_from_instance_id(&mut context.pool(), object_creator.instance_id) .await .ok() .map(Into::into); if let Some(inbox) = object_creator_site.map(|s| s.shared_inbox_or_inbox()) { inboxes.add_inbox(inbox); } } Ok(inboxes) } fn local_community(site_or_community: &Either) -> Option<&ApubCommunity> { site_or_community.as_ref().right().filter(|c| c.local) } async fn verify_mod_or_admin_action( person_id: &ObjectId, site_or_community: &Either, context: &Data, ) -> LemmyResult<()> { match site_or_community { Either::Left(site) => { // admin action comes from the correct instance, so it was presumably done // by an instance admin. // TODO: federate instance admin status and check it here if person_id.inner().domain() == site.ap_id.domain() { return Ok(()); } let admin = person_id.dereference(context).await?; let local_user_view = LocalUserView::read_person(&mut context.pool(), admin.id).await?; is_admin(&local_user_view) } Either::Right(community) => verify_mod_action(person_id, community, context).await, } } ================================================ FILE: crates/apub/activities/src/community/report.rs ================================================ use super::{local_community, report_inboxes}; use crate::{ activity_lists::AnnouncableActivities, check_community_deleted_or_removed, generate_activity_id, protocol::community::{ announce::AnnounceActivity, report::{Report, ReportObject}, }, send_lemmy_activity, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::FlagType, traits::{Activity, Object}, }; use either::Either; use lemmy_api_utils::{ context::LemmyContext, utils::{ check_comment_deleted_or_removed, check_community_deleted_removed, check_post_deleted_or_removed, }, }; use lemmy_apub_objects::{ objects::{ PostOrComment, ReportableObjects, community::ApubCommunity, instance::ApubSite, person::ApubPerson, }, utils::functions::{verify_person_in_community, verify_person_in_site_or_community}, }; use lemmy_db_schema::{ source::{ comment_report::{CommentReport, CommentReportForm}, community::Community, community_report::{CommunityReport, CommunityReportForm}, post::Post, post_report::{PostReport, PostReportForm}, }, traits::Reportable, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl Report { pub(crate) fn new( object_id: &ObjectId, actor: &ApubPerson, receiver: &Either, reason: Option, context: &Data, ) -> LemmyResult { let kind = FlagType::Flag; let id = generate_activity_id(kind.clone(), context)?; Ok(Report { actor: actor.id().clone().into(), to: [receiver.id().clone().into()], object: ReportObject::Lemmy(object_id.clone()), summary: reason, content: None, kind, id: id.clone(), audience: receiver.as_ref().right().map(|c| c.ap_id.clone().into()), }) } pub(crate) async fn send( object_id: ObjectId, actor: &ApubPerson, receiver: &Either, reason: String, context: Data, ) -> LemmyResult<()> { let report = Self::new(&object_id, actor, receiver, Some(reason), &context)?; let inboxes = report_inboxes(object_id, receiver, actor, &context).await?; send_lemmy_activity(&context, report, actor, inboxes, false).await } } #[async_trait::async_trait] impl Activity for Report { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let receiver = self.to[0].dereference(context).await?; verify_person_in_site_or_community(&self.actor, &receiver, context).await?; match self.object.dereference(context).await? { ReportableObjects::Left(PostOrComment::Left(post)) => { let community: ApubCommunity = Community::read(&mut context.pool(), post.community_id) .await? .into(); check_community_deleted_or_removed(&community)?; verify_person_in_community(&self.actor, &community, context).await?; check_post_deleted_or_removed(&post)?; } ReportableObjects::Left(PostOrComment::Right(comment)) => { let post = Post::read(&mut context.pool(), comment.post_id).await?; let community: ApubCommunity = Community::read(&mut context.pool(), post.community_id) .await? .into(); verify_person_in_community(&self.actor, &community, context).await?; check_community_deleted_or_removed(&community)?; check_comment_deleted_or_removed(&comment)?; } ReportableObjects::Right(community) => { check_community_deleted_removed(&community)?; } } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let actor = self.actor.dereference(context).await?; let reason = self.reason()?; match self.object.dereference(context).await? { ReportableObjects::Left(PostOrComment::Left(post)) => { let report_form = PostReportForm { creator_id: actor.id, post_id: post.id, original_post_name: post.name.clone(), original_post_url: post.url.clone(), reason, original_post_body: post.body.clone(), violates_instance_rules: false, }; PostReport::report(&mut context.pool(), &report_form).await?; } ReportableObjects::Left(PostOrComment::Right(comment)) => { let report_form = CommentReportForm { creator_id: actor.id, comment_id: comment.id, original_comment_text: comment.content.clone(), reason, violates_instance_rules: false, }; CommentReport::report(&mut context.pool(), &report_form).await?; } ReportableObjects::Right(community) => { let report_form = CommunityReportForm { creator_id: actor.id, community_id: community.id, reason, original_community_name: community.name.clone(), original_community_title: community.title.clone(), original_community_banner: community.banner.clone(), original_community_icon: community.icon.clone(), original_community_summary: community.summary.clone(), original_community_sidebar: community.sidebar.clone(), }; CommunityReport::report(&mut context.pool(), &report_form).await?; } }; let receiver = self.to[0].dereference(context).await?; if let Some(community) = local_community(&receiver) { // forward to remote mods let object_id = self.object.object_id(context).await?; let announce = AnnouncableActivities::Report(self); let announce = AnnounceActivity::new(announce.try_into()?, community, context)?; let inboxes = report_inboxes(object_id, &receiver, &actor, context).await?; send_lemmy_activity(context, announce, community, inboxes.clone(), false).await?; } Ok(()) } } ================================================ FILE: crates/apub/activities/src/community/resolve_report.rs ================================================ use super::{local_community, report_inboxes, verify_mod_or_admin_action}; use crate::{ activity_lists::AnnouncableActivities, generate_activity_id, protocol::community::{ announce::AnnounceActivity, report::Report, resolve_report::{ResolveReport, ResolveType}, }, send_lemmy_activity, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, protocol::verification::verify_urls_match, traits::{Activity, Object}, }; use either::Either; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{ PostOrComment, ReportableObjects, community::ApubCommunity, instance::ApubSite, person::ApubPerson, }, utils::functions::verify_person_in_site_or_community, }; use lemmy_db_schema::{ source::{ comment_report::CommentReport, community_report::CommunityReport, post_report::PostReport, }, traits::Reportable, }; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl ResolveReport { pub(crate) async fn send( object_id: ObjectId, actor: &ApubPerson, report_creator: &ApubPerson, receiver: &Either, context: Data, ) -> LemmyResult<()> { let kind = ResolveType::Resolve; let id = generate_activity_id(kind.clone(), &context)?; let object = Report::new(&object_id, report_creator, receiver, None, &context)?; let resolve = ResolveReport { actor: actor.id().clone().into(), to: [receiver.id().clone().into()], object, kind, id: id.clone(), audience: receiver.as_ref().right().map(|c| c.ap_id.clone().into()), }; let inboxes = report_inboxes(object_id, receiver, report_creator, &context).await?; send_lemmy_activity(&context, resolve, actor, inboxes, false).await } } #[async_trait::async_trait] impl Activity for ResolveReport { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { self.object.verify(context).await?; let receiver = self.object.to[0].dereference(context).await?; verify_person_in_site_or_community(&self.actor, &receiver, context).await?; verify_urls_match(self.to[0].inner(), self.object.to[0].inner())?; verify_mod_or_admin_action(&self.actor, &receiver, context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let reporter = self.object.actor.dereference(context).await?; let actor = self.actor.dereference(context).await?; match self.object.object.dereference(context).await? { ReportableObjects::Left(PostOrComment::Left(post)) => { PostReport::resolve_apub(&mut context.pool(), post.id, reporter.id, actor.id).await?; } ReportableObjects::Left(PostOrComment::Right(comment)) => { CommentReport::resolve_apub(&mut context.pool(), comment.id, reporter.id, actor.id).await?; } ReportableObjects::Right(community) => { CommunityReport::resolve_apub(&mut context.pool(), community.id, reporter.id, actor.id) .await?; } }; let receiver = self.object.to[0].dereference(context).await?; if let Some(community) = local_community(&receiver) { // forward to remote mods let object_id = self.object.object.object_id(context).await?; let announce = AnnouncableActivities::ResolveReport(self); let announce = AnnounceActivity::new(announce.try_into()?, community, context)?; let inboxes = report_inboxes(object_id, &receiver, &reporter, context).await?; send_lemmy_activity(context, announce, community, inboxes.clone(), false).await?; } Ok(()) } } ================================================ FILE: crates/apub/activities/src/community/update.rs ================================================ use crate::{ check_community_deleted_or_removed, community::{AnnouncableActivities, send_activity_in_community}, generate_activity_id, protocol::community::update::Update, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::{activity::UpdateType, public}, traits::{Activity, Object}, }; use either::Either; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, multi_community::ApubMultiCommunity, person::ApubPerson}, utils::{ functions::{generate_to, verify_mod_action, verify_visibility}, protocol::InCommunity, }, }; use lemmy_db_schema::source::{ activity::ActivitySendTargets, community::Community, modlog::{Modlog, ModlogInsertForm}, multi_community::MultiCommunity, person::Person, }; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; pub(crate) async fn send_update_community( community: Community, actor: Person, context: Data, ) -> LemmyResult<()> { let community: ApubCommunity = community.into(); let actor: ApubPerson = actor.into(); let id = generate_activity_id(UpdateType::Update, &context)?; let update = Update { actor: actor.id().clone().into(), to: generate_to(&community)?, object: Either::Left(community.clone().into_json(&context).await?), cc: vec![community.id().clone()], kind: UpdateType::Update, id: id.clone(), audience: Some(community.ap_id.clone().into()), }; let activity = AnnouncableActivities::UpdateCommunity(Box::new(update)); send_activity_in_community( activity, &actor, &community, ActivitySendTargets::empty(), true, &context, ) .await } pub(crate) async fn send_update_multi_community( multi: MultiCommunity, actor: Person, context: Data, ) -> LemmyResult<()> { let multi: ApubMultiCommunity = multi.into(); let actor: ApubPerson = actor.into(); let id = generate_activity_id(UpdateType::Update, &context)?; let update = Update { actor: actor.id().clone().into(), to: vec![multi.ap_id.clone().into(), public()], object: Either::Right(multi.clone().into_json(&context).await?), cc: vec![], kind: UpdateType::Update, id: id.clone(), audience: Some(multi.ap_id.clone().into()), }; let activity = AnnouncableActivities::UpdateCommunity(Box::new(update)); let mut inboxes = ActivitySendTargets::empty(); inboxes.add_inboxes(MultiCommunity::follower_inboxes(&mut context.pool(), multi.id).await?); send_lemmy_activity(&context, activity, &actor, inboxes, false).await } #[async_trait::async_trait] impl Activity for Update { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { match &self.object { Either::Left(c) => { let community = self.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; verify_mod_action(&self.actor, &community, context).await?; check_community_deleted_or_removed(&community)?; ApubCommunity::verify(c, &community.ap_id.clone().into(), context).await?; } Either::Right(m) => ApubMultiCommunity::verify(m, &self.id, context).await?, } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { match &self.object { Either::Left(c) => { let old_community = self.community(context).await?; let community = ApubCommunity::from_json(c.clone(), context).await?; if old_community.visibility != community.visibility { let actor = self.actor.dereference(context).await?; let form = ModlogInsertForm::mod_change_community_visibility(actor.id, old_community.id); Modlog::create(&mut context.pool(), &[form]).await?; } } Either::Right(m) => { ApubMultiCommunity::from_json(m.clone(), context).await?; } } Ok(()) } } ================================================ FILE: crates/apub/activities/src/create_or_update/comment.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, check_community_deleted_or_removed, community::send_activity_in_community, create_or_update::{parse_apub_mentions, tagged_user_inboxes}, generate_activity_id, protocol::{CreateOrUpdateType, create_or_update::note::CreateOrUpdateNote}, }; use activitypub_federation::{ config::Data, protocol::verification::{verify_domains_match, verify_urls_match}, traits::{Activity, Object}, }; use lemmy_api_utils::{ context::LemmyContext, notify::NotifyData, utils::{check_is_mod_or_admin, check_post_deleted_or_removed}, }; use lemmy_apub_objects::{ objects::{comment::ApubComment, community::ApubCommunity, person::ApubPerson}, utils::{ functions::{generate_to, verify_person_in_community, verify_visibility}, protocol::InCommunity, }, }; use lemmy_db_schema::{ source::{ comment::{Comment, CommentActions, CommentLikeForm}, community::Community, person::Person, post::Post, }, traits::Likeable, }; use lemmy_db_schema_file::PersonId; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult}; use serde_json::{from_value, to_value}; use url::Url; impl CreateOrUpdateNote { pub(crate) async fn send( comment: Comment, person_id: PersonId, kind: CreateOrUpdateType, context: Data, ) -> LemmyResult<()> { // TODO: might be helpful to add a comment method to retrieve community directly let post_id = comment.post_id; let post = Post::read(&mut context.pool(), post_id).await?; let community_id = post.community_id; let person: ApubPerson = Person::read(&mut context.pool(), person_id).await?.into(); let community: ApubCommunity = Community::read(&mut context.pool(), community_id) .await? .into(); let id = generate_activity_id(kind.clone(), &context)?; let note = ApubComment(comment).into_json(&context).await?; let create_or_update = CreateOrUpdateNote { actor: person.id().clone().into(), to: generate_to(&community)?, cc: note.cc.clone(), tag: note.tag.clone(), object: note, kind, id: id.clone(), audience: Some(community.ap_id.clone().into()), }; let inboxes = tagged_user_inboxes(&create_or_update.tag, &context).await?; // AnnouncableActivities doesnt contain Comment activity but only NoteWrapper, // to be able to handle both comment and private message. So to send this out we need // to convert this to NoteWrapper, by serializing and then deserializing again. let converted = from_value(to_value(create_or_update)?)?; let activity = AnnouncableActivities::CreateOrUpdateNoteWrapper(converted); send_activity_in_community(activity, &person, &community, inboxes, false, &context).await } } #[async_trait::async_trait] impl Activity for CreateOrUpdateNote { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let post = self.object.get_parents(context).await?.0; let community = self.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; verify_person_in_community(&self.actor, &community, context).await?; verify_domains_match(self.actor.inner(), self.object.id.inner())?; check_community_deleted_or_removed(&community)?; check_post_deleted_or_removed(&post)?; verify_urls_match(self.actor.inner(), self.object.attributed_to.inner())?; ApubComment::verify(&self.object, self.actor.inner(), context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let site_view = SiteView::read_local(&mut context.pool()).await?; // Need to do this check here instead of Note::from_json because we need the person who // send the activity, not the comment author. let existing_comment = self.object.id.dereference_local(context).await.ok(); let (post, _) = self.object.get_parents(context).await?; if let (Some(distinguished), Some(existing_comment)) = (self.object.distinguished, existing_comment) && distinguished != existing_comment.distinguished { let creator = self.actor.dereference(context).await?; check_is_mod_or_admin(&mut context.pool(), creator.id, post.community_id).await?; } let comment = ApubComment::from_json(self.object, context).await?; // author likes their own comment by default let like_form = CommentLikeForm::new(comment.id, comment.creator_id, Some(true)); CommentActions::like(&mut context.pool(), &like_form).await?; // Calculate initial hot_rank Comment::update_hot_rank(&mut context.pool(), comment.id).await?; let do_send_email = self.kind == CreateOrUpdateType::Create && !site_view.local_site.disable_email_notifications; let actor = self.actor.dereference(context).await?; let community = Community::read(&mut context.pool(), post.community_id).await?; NotifyData { comment: Some(comment.0), do_send_email, apub_mentions: Some(parse_apub_mentions(&self.tag, context).await?), ..NotifyData::new(post.0, actor.0, community) } .send(context); Ok(()) } } ================================================ FILE: crates/apub/activities/src/create_or_update/mod.rs ================================================ use activitypub_federation::{config::Data, traits::Actor}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::protocol::tags::ApubTag; use lemmy_db_schema::source::{activity::ActivitySendTargets, person::Person}; use lemmy_utils::error::LemmyResult; pub mod comment; pub(crate) mod note_wrapper; pub mod post; pub mod private_message; /// From Activitypub `tag` field extract the mentions, and return the inboxes for these users. /// Used when sending out activity to ensure the mentioned users see it. async fn tagged_user_inboxes( tagged_users: &[ApubTag], context: &Data, ) -> LemmyResult { let tagged_users: Vec<_> = tagged_users.iter().flat_map(ApubTag::mention_id).collect(); let mut inboxes = ActivitySendTargets::empty(); for t in tagged_users { let person = t.dereference(context).await?; inboxes.add_inbox(person.shared_inbox_or_inbox()); } Ok(inboxes) } /// Extracts the users who are mentioned in a received, federated post. async fn parse_apub_mentions( tags: &[ApubTag], context: &Data, ) -> LemmyResult> { let mentions: Vec<_> = tags.iter().filter_map(ApubTag::mention_id).collect(); let mut res = vec![]; for m in mentions { let person = m.dereference(context).await?.0; if person.local { res.push(person); } } Ok(res) } ================================================ FILE: crates/apub/activities/src/create_or_update/note_wrapper.rs ================================================ use crate::protocol::create_or_update::{ note::CreateOrUpdateNote, note_wrapper::CreateOrUpdateNoteWrapper, private_message::CreateOrUpdatePrivateMessage, }; use activitypub_federation::{config::Data, traits::Activity}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{objects::community::ApubCommunity, utils::protocol::InCommunity}; use lemmy_utils::error::{LemmyError, LemmyResult}; use serde_json::{from_value, to_value}; use url::Url; /// In Activitypub, both private messages and comments are represented by `type: Note` which /// makes it difficult to distinguish them. This wrapper handles receiving of both types, and /// routes them to the correct handler. #[async_trait::async_trait] impl Activity for CreateOrUpdateNoteWrapper { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { &self.actor } async fn verify(&self, _context: &Data) -> LemmyResult<()> { // Do everything in receive to avoid extra checks. Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { // Use serde to convert NoteWrapper either into Comment or PrivateMessage, // depending on conditions below. This works because NoteWrapper keeps all // additional data in field `other: Map`. let val = to_value(self)?; // Convert self to a comment and get the community. If the conversion is // successful and a community is returned, this is a comment. let comment = from_value::(val.clone()); if let Ok(comment) = comment && comment.community(context).await.is_ok() { CreateOrUpdateNote::verify(&comment, context).await?; CreateOrUpdateNote::receive(comment, context).await?; return Ok(()); } // If any of the previous checks failed, we are dealing with a private message. let private_message = from_value(val)?; CreateOrUpdatePrivateMessage::verify(&private_message, context).await?; CreateOrUpdatePrivateMessage::receive(private_message, context).await?; Ok(()) } } impl InCommunity for CreateOrUpdateNoteWrapper { async fn community(&self, context: &Data) -> LemmyResult { // Same logic as in receive. In case this is a private message, an error is returned. let val = to_value(self)?; let comment: CreateOrUpdateNote = from_value(val.clone())?; comment.community(context).await } } ================================================ FILE: crates/apub/activities/src/create_or_update/post.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, check_community_deleted_or_removed, community::send_activity_in_community, create_or_update::{parse_apub_mentions, tagged_user_inboxes}, generate_activity_id, protocol::{CreateOrUpdateType, create_or_update::page::CreateOrUpdatePage}, }; use activitypub_federation::{ config::Data, protocol::verification::{verify_domains_match, verify_urls_match}, traits::{Activity, Object}, }; use chrono::Utc; use lemmy_api_utils::{context::LemmyContext, notify::NotifyData}; use lemmy_apub_objects::{ objects::{ community::ApubCommunity, person::ApubPerson, post::{ApubPost, post_nsfw, update_apub_post_tags}, }, utils::{ functions::{generate_to, verify_mod_action, verify_person_in_community, verify_visibility}, protocol::InCommunity, }, }; use lemmy_db_schema::{ source::{ community::Community, person::Person, post::{Post, PostActions, PostLikeForm, PostUpdateForm}, }, traits::Likeable, }; use lemmy_db_schema_file::PersonId; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult}; use url::Url; impl CreateOrUpdatePage { pub async fn new( post: ApubPost, actor: &ApubPerson, community: &ApubCommunity, kind: CreateOrUpdateType, context: &Data, ) -> LemmyResult { let id = generate_activity_id(kind.clone(), context)?; Ok(CreateOrUpdatePage { actor: actor.id().clone().into(), to: generate_to(community)?, object: post.into_json(context).await?, cc: vec![community.id().clone()], kind, id: id.clone(), audience: Some(community.ap_id.clone().into()), }) } pub(crate) async fn send( post: Post, person_id: PersonId, kind: CreateOrUpdateType, context: Data, ) -> LemmyResult<()> { let community_id = post.community_id; let person: ApubPerson = Person::read(&mut context.pool(), person_id).await?.into(); let community: ApubCommunity = Community::read(&mut context.pool(), community_id) .await? .into(); let create_or_update = CreateOrUpdatePage::new(post.into(), &person, &community, kind, &context).await?; let inboxes = tagged_user_inboxes(&create_or_update.object.tag, &context).await?; let activity = AnnouncableActivities::CreateOrUpdatePost(create_or_update); send_activity_in_community(activity, &person, &community, inboxes, false, &context).await?; Ok(()) } } #[async_trait::async_trait] impl Activity for CreateOrUpdatePage { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let community = self.community(context).await?; verify_visibility(&self.to, &self.cc, &community)?; check_community_deleted_or_removed(&community)?; verify_domains_match(self.actor.inner(), self.object.id.inner())?; ApubPost::verify(&self.object, self.actor.inner(), context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let community = self.community(context).await?; let is_same_actor = verify_urls_match(self.actor.inner(), self.object.creator()?.inner()).is_ok(); let original_post = Post::read_from_apub_id(&mut context.pool(), self.object.id.clone().into()).await; let is_mod_action = verify_mod_action(&self.actor, &community, context) .await .is_ok(); // allow mods to edit the post if !is_same_actor && let Ok(Some(post)) = original_post { if is_mod_action { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let form = PostUpdateForm { updated_at: Some(Some(Utc::now())), nsfw: post_nsfw(&self.object, &community, Some(&local_site), context).await?, ..Default::default() }; Post::update(&mut context.pool(), post.id, &form).await?; update_apub_post_tags(&self.object, &post, context).await?; return Ok(()); } else { return Err(LemmyErrorType::NotAModerator.into()); } } if !is_mod_action { verify_person_in_community(&self.actor, &community, context).await?; } verify_urls_match(self.actor.inner(), self.object.creator()?.inner())?; let site_view = SiteView::read_local(&mut context.pool()).await?; let post = ApubPost::from_json(self.object.clone(), context).await?; // author likes their own post by default let like_form = PostLikeForm::new(post.id, post.creator_id, Some(true)); PostActions::like(&mut context.pool(), &like_form).await?; // Calculate initial hot_rank for post Post::update_ranks(&mut context.pool(), post.id).await?; let do_send_email = self.kind == CreateOrUpdateType::Create && !site_view.local_site.disable_email_notifications; let actor = self.actor.dereference(context).await?; NotifyData { apub_mentions: Some(parse_apub_mentions(&self.object.tag, context).await?), do_send_email, ..NotifyData::new(post.0, actor.0, community.0) } .send(context); Ok(()) } } ================================================ FILE: crates/apub/activities/src/create_or_update/private_message.rs ================================================ use crate::{ generate_activity_id, protocol::{CreateOrUpdateType, create_or_update::private_message::CreateOrUpdatePrivateMessage}, send_lemmy_activity, verify_person, }; use activitypub_federation::{ config::Data, protocol::verification::{verify_domains_match, verify_urls_match}, traits::{Activity, Actor, Object}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::objects::{person::ApubPerson, private_message::ApubPrivateMessage}; use lemmy_db_schema::source::activity::ActivitySendTargets; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; pub(crate) async fn send_create_or_update_pm( pm_view: PrivateMessageView, kind: CreateOrUpdateType, context: Data, ) -> LemmyResult<()> { let actor: ApubPerson = pm_view.creator.into(); let recipient: ApubPerson = pm_view.recipient.into(); let id = generate_activity_id(kind.clone(), &context)?; let create_or_update = CreateOrUpdatePrivateMessage { id: id.clone(), actor: actor.id().clone().into(), to: [recipient.id().clone().into()], object: ApubPrivateMessage(pm_view.private_message.clone()) .into_json(&context) .await?, kind, }; let inbox = ActivitySendTargets::to_inbox(recipient.shared_inbox_or_inbox()); send_lemmy_activity(&context, create_or_update, &actor, inbox, true).await } #[async_trait::async_trait] impl Activity for CreateOrUpdatePrivateMessage { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { verify_person(&self.actor, context).await?; verify_domains_match(self.actor.inner(), self.object.id.inner())?; verify_domains_match(self.to[0].inner(), self.object.to[0].inner())?; verify_urls_match(self.actor.inner(), self.object.attributed_to.inner())?; ApubPrivateMessage::verify(&self.object, self.actor.inner(), context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { ApubPrivateMessage::from_json(self.object, context).await?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/deletion/delete.rs ================================================ use crate::{ MOD_ACTION_DEFAULT_REASON, deletion::{DeletableObjects, receive_delete_action, verify_delete_activity}, generate_activity_id, protocol::{IdOrNestedObject, deletion::delete::Delete}, }; use activitypub_federation::{config::Data, kinds::activity::DeleteType, traits::Activity}; use lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action}; use lemmy_apub_objects::objects::person::ApubPerson; use lemmy_db_schema::{ source::{ comment::{Comment, CommentUpdateForm}, comment_report::CommentReport, community::{Community, CommunityUpdateForm}, community_report::CommunityReport, modlog::{Modlog, ModlogInsertForm}, post::{Post, PostUpdateForm}, post_report::PostReport, }, traits::Reportable, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError}; use url::Url; #[async_trait::async_trait] impl Activity for Delete { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { verify_delete_activity(self, self.summary.is_some(), context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { if let Some(reason) = self.summary { // We set reason to empty string if it doesn't exist, to distinguish between delete and // remove. Here we change it back to option, so we don't write it to db. let reason = if reason.is_empty() { None } else { Some(reason) }; receive_remove_action( &self.actor.dereference(context).await?, self.object.id(), reason, self.with_replies, context, ) .await } else { receive_delete_action( self.object.id(), &self.actor, true, self.remove_data, context, ) .await } } } impl Delete { pub(in crate::deletion) fn new( actor: &ApubPerson, object: DeletableObjects, to: Vec, community: Option<&Community>, summary: Option, with_replies: Option, context: &Data, ) -> LemmyResult { let id = generate_activity_id(DeleteType::Delete, context)?; let cc: Option = community.map(|c| c.ap_id.clone().into()); Ok(Delete { actor: actor.ap_id.clone().into(), to, object: IdOrNestedObject::Id(object.id().clone()), cc: cc.into_iter().collect(), kind: DeleteType::Delete, summary, id, audience: community.map(|c| c.ap_id.clone().into()), remove_data: None, with_replies, }) } } pub(crate) async fn receive_remove_action( actor: &ApubPerson, object: &Url, reason: Option, with_replies: Option, context: &Data, ) -> LemmyResult<()> { let reason = reason.unwrap_or_else(|| MOD_ACTION_DEFAULT_REASON.to_string()); match DeletableObjects::read_from_db(object, context).await? { DeletableObjects::Community(community) => { if community.local { return Err(UntranslatedError::OnlyLocalAdminCanRemoveCommunity.into()); } CommunityReport::resolve_all_for_object(&mut context.pool(), community.id, actor.id).await?; let community_owner = CommunityModeratorView::top_mod_for_community(&mut context.pool(), community.id).await?; let form = ModlogInsertForm::admin_remove_community( actor, community.id, community_owner, true, &reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), context.app_data()); Community::update( &mut context.pool(), community.id, &CommunityUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; } DeletableObjects::Post(post) => { PostReport::resolve_all_for_object(&mut context.pool(), post.id, actor.id).await?; let form = ModlogInsertForm::mod_remove_post(actor.id, &post, true, &reason, None); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context.app_data()); let post = Post::update( &mut context.pool(), post.id, &PostUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; let remove_children = with_replies.unwrap_or_default(); if remove_children { CommentReport::resolve_all_for_post(&mut context.pool(), post.id, actor.id).await?; let updated_comments: Vec = Comment::update_removed_for_post(&mut context.pool(), post.id, true).await?; let forms: Vec<_> = updated_comments .iter() // Filter out deleted comments here so their content doesn't show up in the modlog. .filter(|c| !c.deleted) .map(|comment| { ModlogInsertForm::mod_remove_comment( actor.id, comment, post.community_id, true, &reason, None, ) }) .collect(); let actions = Modlog::create(&mut context.pool(), &forms).await?; notify_mod_action(actions, context); } } DeletableObjects::Comment(comment) => { let remove_children = with_replies.unwrap_or_default(); // Read the post to get the community_id let community_id = Post::read(&mut context.pool(), comment.post_id) .await? .community_id; if remove_children { CommentReport::resolve_all_for_thread(&mut context.pool(), &comment.path, actor.id).await?; let updated_comments: Vec = Comment::update_removed_for_comment_and_children( &mut context.pool(), &comment.path, true, ) .await?; let forms: Vec<_> = updated_comments .iter() // Filter out deleted comments here so their content doesn't show up in the modlog. .filter(|c| !c.deleted) .map(|comment| { ModlogInsertForm::mod_remove_comment( actor.id, comment, community_id, true, &reason, None, ) }) .collect(); let actions = Modlog::create(&mut context.pool(), &forms).await?; notify_mod_action(actions, context); } else { CommentReport::resolve_all_for_object(&mut context.pool(), comment.id, actor.id).await?; let form = ModlogInsertForm::mod_remove_comment( actor.id, &comment, community_id, true, &reason, None, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context.app_data()); Comment::update( &mut context.pool(), comment.id, &CommentUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; } } // TODO these need to be implemented yet, for now, return errors DeletableObjects::PrivateMessage(_) => return Err(LemmyErrorType::NotFound.into()), DeletableObjects::Person(_) => return Err(LemmyErrorType::NotFound.into()), } Ok(()) } ================================================ FILE: crates/apub/activities/src/deletion/mod.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, check_community_deleted_or_removed, community::send_activity_in_community, protocol::deletion::{delete::Delete, undo_delete::UndoDelete}, send_lemmy_activity, verify_person, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::public, protocol::verification::{verify_domains_match, verify_urls_match}, traits::{Actor, Object}, }; use lemmy_api_utils::{context::LemmyContext, utils::purge_user_account}; use lemmy_apub_objects::{ objects::{ comment::ApubComment, community::ApubCommunity, person::ApubPerson, post::ApubPost, private_message::ApubPrivateMessage, }, utils::{ functions::{ generate_to, verify_is_public, verify_mod_action, verify_person_in_community, verify_visibility, }, protocol::InCommunity, }, }; use lemmy_db_schema::source::{ activity::ActivitySendTargets, comment::{Comment, CommentUpdateForm}, community::{Community, CommunityUpdateForm}, person::Person, post::{Post, PostUpdateForm}, private_message::{PrivateMessage as DbPrivateMessage, PrivateMessageUpdateForm}, }; use lemmy_db_schema_file::enums::CommunityVisibility; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use std::ops::Deref; use url::Url; pub mod delete; pub mod undo_delete; /// Parameter `reason` being set indicates that this is a removal by a mod. If its unset, this /// action was done by a normal user. pub(crate) async fn send_apub_delete_in_community( actor: Person, mut community: Community, object: DeletableObjects, reason: Option, deleted: bool, with_replies: Option, context: &Data, ) -> LemmyResult<()> { // Bypass visibility check for sending this activity type community.visibility = CommunityVisibility::Public; let actor = ApubPerson::from(actor); let is_mod_action = reason.is_some(); let to = generate_to(&community)?; let activity = if deleted { let delete = Delete::new( &actor, object, to, Some(&community), reason, with_replies, context, )?; AnnouncableActivities::Delete(delete) } else { let undo = UndoDelete::new( &actor, object, to, Some(&community), reason, with_replies, context, )?; AnnouncableActivities::UndoDelete(undo) }; send_activity_in_community( activity, &actor, &community.into(), ActivitySendTargets::empty(), is_mod_action, context, ) .await } pub(crate) async fn send_apub_delete_private_message( actor: &ApubPerson, pm: DbPrivateMessage, deleted: bool, context: Data, ) -> LemmyResult<()> { let recipient_id = pm.recipient_id; let recipient: ApubPerson = Person::read(&mut context.pool(), recipient_id) .await? .into(); let deletable = DeletableObjects::PrivateMessage(pm.into()); let inbox = ActivitySendTargets::to_inbox(recipient.shared_inbox_or_inbox()); if deleted { let delete: Delete = Delete::new( actor, deletable, vec![recipient.id().clone()], None, None, None, &context, )?; send_lemmy_activity(&context, delete, actor, inbox, true).await?; } else { let undo = UndoDelete::new( actor, deletable, vec![recipient.id().clone()], None, None, None, &context, )?; send_lemmy_activity(&context, undo, actor, inbox, true).await?; }; Ok(()) } pub async fn send_apub_delete_user( person: Person, remove_data: bool, context: Data, ) -> LemmyResult<()> { let person: ApubPerson = person.into(); let deletable = DeletableObjects::Person(person.clone()); let mut delete: Delete = Delete::new( &person, deletable, vec![public()], None, None, None, &context, )?; delete.remove_data = Some(remove_data); let inboxes = ActivitySendTargets::to_all_instances(); send_lemmy_activity(&context, delete, &person, inboxes, true).await?; Ok(()) } pub enum DeletableObjects { Community(ApubCommunity), Person(ApubPerson), Comment(ApubComment), Post(ApubPost), PrivateMessage(ApubPrivateMessage), } impl DeletableObjects { pub(crate) async fn read_from_db( ap_id: &Url, context: &Data, ) -> LemmyResult { if let Some(c) = ApubCommunity::read_from_id(ap_id.clone(), context).await? { return Ok(DeletableObjects::Community(c)); } if let Some(p) = ApubPerson::read_from_id(ap_id.clone(), context).await? { return Ok(DeletableObjects::Person(p)); } if let Some(p) = ApubPost::read_from_id(ap_id.clone(), context).await? { return Ok(DeletableObjects::Post(p)); } if let Some(c) = ApubComment::read_from_id(ap_id.clone(), context).await? { return Ok(DeletableObjects::Comment(c)); } if let Some(p) = ApubPrivateMessage::read_from_id(ap_id.clone(), context).await? { return Ok(DeletableObjects::PrivateMessage(p)); } Err(diesel::NotFound.into()) } pub(crate) fn id(&self) -> &Url { match self { DeletableObjects::Community(c) => c.id(), DeletableObjects::Person(p) => p.id(), DeletableObjects::Comment(c) => c.ap_id.inner(), DeletableObjects::Post(p) => p.ap_id.inner(), DeletableObjects::PrivateMessage(p) => p.ap_id.inner(), } } } pub(crate) async fn verify_delete_activity( activity: &Delete, is_mod_action: bool, context: &Data, ) -> LemmyResult<()> { let object = DeletableObjects::read_from_db(activity.object.id(), context).await?; match object { DeletableObjects::Community(community) => { verify_visibility(&activity.to, &[], &community)?; if community.local { // can only do this check for local community, in remote case it would try to fetch the // deleted community (which fails) verify_person_in_community(&activity.actor, &community, context).await?; } // community deletion is always a mod (or admin) action verify_mod_action(&activity.actor, &community, context).await?; } DeletableObjects::Person(person) => { verify_is_public(&activity.to, &[])?; verify_person(&activity.actor, context).await?; verify_urls_match(person.ap_id.inner(), activity.object.id())?; } DeletableObjects::Post(p) => { let community = activity.community(context).await?; verify_visibility(&activity.to, &[], &community)?; verify_delete_post_or_comment( &activity.actor, &p.ap_id.clone().into(), &community, is_mod_action, context, ) .await?; } DeletableObjects::Comment(c) => { let community = activity.community(context).await?; verify_visibility(&activity.to, &[], &community)?; verify_delete_post_or_comment( &activity.actor, &c.ap_id.clone().into(), &community, is_mod_action, context, ) .await?; } DeletableObjects::PrivateMessage(_) => { verify_person(&activity.actor, context).await?; verify_domains_match(activity.actor.inner(), activity.object.id())?; } } Ok(()) } async fn verify_delete_post_or_comment( actor: &ObjectId, object_id: &Url, community: &ApubCommunity, is_mod_action: bool, context: &Data, ) -> LemmyResult<()> { check_community_deleted_or_removed(community)?; if is_mod_action { verify_mod_action(actor, community, context).await?; } else { verify_person_in_community(actor, community, context).await?; // domain of post ap_id and post.creator ap_id are identical, so we just check the former verify_domains_match(actor.inner(), object_id)?; } Ok(()) } /// Write deletion or restoring of an object to the database, and send websocket message. async fn receive_delete_action( object: &Url, actor: &ObjectId, deleted: bool, do_purge_user_account: Option, context: &Data, ) -> LemmyResult<()> { match DeletableObjects::read_from_db(object, context).await? { DeletableObjects::Community(community) => { if community.local { let mod_: Person = actor.dereference(context).await?.deref().clone(); let object = DeletableObjects::Community(community.clone()); let c: Community = community.deref().clone(); send_apub_delete_in_community(mod_, c, object, None, true, None, context).await?; } Community::update( &mut context.pool(), community.id, &CommunityUpdateForm { deleted: Some(deleted), ..Default::default() }, ) .await?; } DeletableObjects::Person(person) => { let site_view = SiteView::read_local(&mut context.pool()).await?; let local_instance_id = site_view.site.instance_id; if do_purge_user_account.unwrap_or(false) { purge_user_account(person.id, local_instance_id, context).await?; } else { Person::delete_account(&mut context.pool(), person.id, local_instance_id).await?; } } DeletableObjects::Post(post) => { if deleted != post.deleted { Post::update( &mut context.pool(), post.id, &PostUpdateForm { deleted: Some(deleted), ..Default::default() }, ) .await?; } } DeletableObjects::Comment(comment) => { if deleted != comment.deleted { Comment::update( &mut context.pool(), comment.id, &CommentUpdateForm { deleted: Some(deleted), ..Default::default() }, ) .await?; } } DeletableObjects::PrivateMessage(pm) => { DbPrivateMessage::update( &mut context.pool(), pm.id, &PrivateMessageUpdateForm { deleted: Some(deleted), ..Default::default() }, ) .await?; } } Ok(()) } ================================================ FILE: crates/apub/activities/src/deletion/undo_delete.rs ================================================ use crate::{ deletion::{DeletableObjects, receive_delete_action, verify_delete_activity}, generate_activity_id, protocol::deletion::{delete::Delete, undo_delete::UndoDelete}, }; use activitypub_federation::{config::Data, kinds::activity::UndoType, traits::Activity}; use lemmy_api_utils::{context::LemmyContext, notify::notify_mod_action}; use lemmy_apub_objects::objects::person::ApubPerson; use lemmy_db_schema::source::{ comment::{Comment, CommentUpdateForm}, community::{Community, CommunityUpdateForm}, modlog::{Modlog, ModlogInsertForm}, post::{Post, PostUpdateForm}, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError}; use url::Url; #[async_trait::async_trait] impl Activity for UndoDelete { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, data: &Data) -> Result<(), Self::Error> { self.object.verify(data).await?; verify_delete_activity(&self.object, self.object.summary.is_some(), data).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { if let Some(reason) = self.object.summary { UndoDelete::receive_undo_remove_action( &self.actor.dereference(context).await?, self.object.object.id(), reason, self.object.with_replies, context, ) .await } else { receive_delete_action(self.object.object.id(), &self.actor, false, None, context).await } } } impl UndoDelete { pub(in crate::deletion) fn new( actor: &ApubPerson, object: DeletableObjects, to: Vec, community: Option<&Community>, summary: Option, with_replies: Option, context: &Data, ) -> LemmyResult { let object = Delete::new( actor, object, to.clone(), community, summary, with_replies, context, )?; let id = generate_activity_id(UndoType::Undo, context)?; let cc: Option = community.map(|c| c.ap_id.clone().into()); Ok(UndoDelete { actor: actor.ap_id.clone().into(), to, object, cc: cc.into_iter().collect(), kind: UndoType::Undo, id, audience: community.map(|c| c.ap_id.clone().into()), }) } pub(crate) async fn receive_undo_remove_action( actor: &ApubPerson, object: &Url, reason: String, with_replies: Option, context: &Data, ) -> LemmyResult<()> { match DeletableObjects::read_from_db(object, context).await? { DeletableObjects::Community(community) => { if community.local { return Err(UntranslatedError::OnlyLocalAdminCanRestoreCommunity.into()); } let community_owner = CommunityModeratorView::top_mod_for_community(&mut context.pool(), community.id).await?; let form = ModlogInsertForm::admin_remove_community( actor, community.id, community_owner, false, &reason, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action.clone(), context.app_data()); Community::update( &mut context.pool(), community.id, &CommunityUpdateForm { removed: Some(false), ..Default::default() }, ) .await?; } DeletableObjects::Post(post) => { let form = ModlogInsertForm::mod_remove_post(actor.id, &post, false, &reason, None); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context.app_data()); Post::update( &mut context.pool(), post.id, &PostUpdateForm { removed: Some(false), ..Default::default() }, ) .await?; let restore_children = with_replies.unwrap_or_default(); if restore_children { let updated_comments: Vec = Comment::update_removed_for_post(&mut context.pool(), post.id, false).await?; let forms: Vec<_> = updated_comments .iter() // Filter out deleted comments here so their content doesn't show up in the modlog. .filter(|c| !c.deleted) .map(|comment| { ModlogInsertForm::mod_remove_comment( actor.id, comment, post.community_id, false, &reason, None, ) }) .collect(); let actions = Modlog::create(&mut context.pool(), &forms).await?; notify_mod_action(actions, context); } } DeletableObjects::Comment(comment) => { let restore_children = with_replies.unwrap_or_default(); if restore_children { let updated_comments: Vec = Comment::update_removed_for_comment_and_children( &mut context.pool(), &comment.path, false, ) .await?; let mut forms: Vec = Vec::new(); // Filter out deleted comments here so their content doesn't show up in the modlog. // Unfortunate, but you need to loop over these to get the community_id for the modlog. for comment in updated_comments.iter().filter(|c| !c.deleted) { let community_id = Post::read(&mut context.pool(), comment.post_id) .await? .community_id; let form = ModlogInsertForm::mod_remove_comment( actor.id, comment, community_id, false, &reason, None, ); forms.push(form); } let actions = Modlog::create(&mut context.pool(), &forms).await?; notify_mod_action(actions, context); } else { let community_id = Post::read(&mut context.pool(), comment.post_id) .await? .community_id; let form = ModlogInsertForm::mod_remove_comment( actor.id, &comment, community_id, false, &reason, None, ); let action = Modlog::create(&mut context.pool(), &[form]).await?; notify_mod_action(action, context.app_data()); Comment::update( &mut context.pool(), comment.id, &CommentUpdateForm { removed: Some(false), ..Default::default() }, ) .await?; } } // TODO these need to be implemented yet, for now, return errors DeletableObjects::PrivateMessage(_) => return Err(LemmyErrorType::NotFound.into()), DeletableObjects::Person(_) => return Err(LemmyErrorType::NotFound.into()), } Ok(()) } } ================================================ FILE: crates/apub/activities/src/following/accept.rs ================================================ use crate::{ check_community_deleted_or_removed, generate_activity_id, protocol::following::{accept::AcceptFollow, follow::Follow}, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::activity::AcceptType, protocol::verification::verify_urls_match, traits::{Activity, Actor, Object}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ source::{activity::ActivitySendTargets, community::CommunityActions}, traits::Followable, }; use lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError}; use url::Url; impl AcceptFollow { pub async fn send(follow: Follow, context: &Data) -> LemmyResult<()> { let target = follow.object.dereference_local(context).await?; let person = follow.actor.clone().dereference(context).await?; let accept = AcceptFollow { actor: target.id().clone().into(), to: Some([person.id().clone().into()]), object: follow, kind: AcceptType::Accept, id: generate_activity_id(AcceptType::Accept, context)?, }; let inbox = ActivitySendTargets::to_inbox(person.shared_inbox_or_inbox()); send_lemmy_activity(context, accept, &target, inbox, true).await } } /// Handle accepted follows #[async_trait::async_trait] impl Activity for AcceptFollow { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { verify_urls_match(self.actor.inner(), self.object.object.inner())?; self.object.verify(context).await?; if let Some(to) = &self.to { verify_urls_match(to[0].inner(), self.object.actor.inner())?; } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let community = self.actor.dereference(context).await?; check_community_deleted_or_removed(&community)?; let actor = self.object.actor.dereference(context).await?; let person = actor.left().ok_or(UntranslatedError::Unreachable)?; // This will throw an error if no follow was requested let community_id = community.id; let person_id = person.id; CommunityActions::follow_accepted(&mut context.pool(), community_id, person_id).await?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/following/follow.rs ================================================ use crate::{ check_community_deleted_or_removed, generate_activity_id, protocol::following::{accept::AcceptFollow, follow::Follow}, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::activity::FollowType, protocol::verification::verify_urls_match, traits::{Activity, Actor, Object}, }; use either::Either::*; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::objects::{CommunityOrMulti, person::ApubPerson}; use lemmy_db_schema::{ source::{ activity::ActivitySendTargets, community::{CommunityActions, CommunityFollowerForm}, community_community_follow::CommunityCommunityFollow, instance::{Instance, InstanceActions}, multi_community::{MultiCommunity, MultiCommunityFollowForm}, person::{PersonActions, PersonFollowerForm}, }, traits::Followable, }; use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility}; use lemmy_db_views_community_moderator::CommunityPersonBanView; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError}; use url::Url; impl Follow { pub(in crate::following) fn new( actor: &ApubPerson, target: &CommunityOrMulti, context: &Data, ) -> LemmyResult { Ok(Follow { actor: actor.id().clone().into(), object: target.id().clone().into(), to: Some([target.id().clone().into()]), kind: FollowType::Follow, id: generate_activity_id(FollowType::Follow, context)?, }) } pub async fn send( actor: &ApubPerson, target: &CommunityOrMulti, context: &Data, ) -> LemmyResult<()> { let follow = Follow::new(actor, target, context)?; let inbox = ActivitySendTargets::to_inbox(target.shared_inbox_or_inbox()); send_lemmy_activity(context, follow, actor, inbox, true).await } } #[async_trait::async_trait] impl Activity for Follow { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, _context: &Data) -> LemmyResult<()> { if let Some(to) = &self.to { verify_urls_match(to[0].inner(), self.object.inner())?; } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { use CommunityVisibility::*; let actor = self.actor.dereference(context).await?; let object = self.object.dereference(context).await?; let object_local = match &object { Left(u) => u.local, Right(Left(c)) => c.local, Right(Right(m)) => m.local, }; if !object_local { return Err(UntranslatedError::InvalidFollow("Not a local object".to_string()).into()); } // Handle remote community following a local community if let (Right(community), Right(Left(follower))) = (&actor, &object) && (community.visibility == Public || community.visibility == Unlisted) { check_community_deleted_or_removed(community)?; CommunityCommunityFollow::follow(&mut context.pool(), community.id, follower.id).await?; AcceptFollow::send(self, context).await?; return Ok(()); } let person = actor.left().ok_or(UntranslatedError::InvalidFollow( "Groups can only follow public groups".to_string(), ))?; InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?; match object { Left(u) => { let form = PersonFollowerForm::new(u.id, person.id, false); PersonActions::follow(&mut context.pool(), &form).await?; AcceptFollow::send(self, context).await?; } Right(Left(c)) => { check_community_deleted_or_removed(&c)?; CommunityPersonBanView::check(&mut context.pool(), person.id, c.id).await?; if c.visibility == CommunityVisibility::Private { let instance = Instance::read(&mut context.pool(), person.instance_id).await?; if [Some("kbin"), Some("mbin")].contains(&instance.software.as_deref()) { // TODO: change this to a minimum version check once private communities are supported return Err( UntranslatedError::InvalidFollow("No private community support".to_string()).into(), ); } } let follow_state = match c.visibility { Public | Unlisted => CommunityFollowerState::Accepted, Private => CommunityFollowerState::ApprovalRequired, // Dont allow following local-only community via federation. LocalOnlyPrivate | LocalOnlyPublic => return Err(LemmyErrorType::NotFound.into()), }; let form = CommunityFollowerForm::new(c.id, person.id, follow_state); CommunityActions::follow(&mut context.pool(), &form).await?; if c.visibility == CommunityVisibility::Public { AcceptFollow::send(self, context).await?; } } Right(Right(m)) => { let form = MultiCommunityFollowForm { multi_community_id: m.id, person_id: person.id, follow_state: CommunityFollowerState::Accepted, }; MultiCommunity::follow(&mut context.pool(), &form).await?; AcceptFollow::send(self, context).await?; } } Ok(()) } } ================================================ FILE: crates/apub/activities/src/following/mod.rs ================================================ use super::{generate_activity_id, send_lemmy_activity}; use crate::protocol::following::{ accept::AcceptFollow, follow::Follow, reject::RejectFollow, undo_follow::UndoFollow, }; use activitypub_federation::{config::Data, kinds::activity::FollowType, traits::Activity}; use either::Either::*; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::objects::{CommunityOrMulti, UserOrCommunityOrMulti, person::ApubPerson}; use lemmy_db_schema::{ newtypes::CommunityId, source::{activity::ActivitySendTargets, community::Community, person::Person}, }; use lemmy_db_schema_file::PersonId; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult}; use serde::Serialize; pub(crate) mod accept; pub(crate) mod follow; pub(crate) mod reject; pub(crate) mod undo_follow; pub async fn send_follow( target: CommunityOrMulti, person: Person, follow: bool, context: &Data, ) -> LemmyResult<()> { let actor: ApubPerson = person.into(); if follow { Follow::send(&actor, &target, context).await } else { UndoFollow::send(&actor, &target, context).await } } pub async fn send_accept_or_reject_follow( community_id: CommunityId, person_id: PersonId, accepted: bool, context: &Data, ) -> LemmyResult<()> { let community = Community::read(&mut context.pool(), community_id).await?; let person = Person::read(&mut context.pool(), person_id).await?; let follow = Follow { actor: person.ap_id.into(), to: Some([community.ap_id.clone().into()]), object: community.ap_id.into(), kind: FollowType::Follow, id: generate_activity_id(FollowType::Follow, context)?, }; if accepted { AcceptFollow::send(follow, context).await } else { RejectFollow::send(follow, context).await } } /// Wrapper type which is needed because we cant implement ActorT for Either. async fn send_activity_from_user_or_community_or_multi( context: &Data, activity: A, target: UserOrCommunityOrMulti, send_targets: ActivitySendTargets, ) -> LemmyResult<()> where A: Activity + Serialize + Send + Sync + Clone + Activity, { match target { Left(user) => send_lemmy_activity(context, activity, &user, send_targets, true).await, Right(Left(community)) => { send_lemmy_activity(context, activity, &community, send_targets, true).await } Right(Right(multi)) => send_lemmy_activity(context, activity, &multi, send_targets, true).await, } } ================================================ FILE: crates/apub/activities/src/following/reject.rs ================================================ use super::send_activity_from_user_or_community_or_multi; use crate::{ check_community_deleted_or_removed, generate_activity_id, protocol::following::{follow::Follow, reject::RejectFollow}, }; use activitypub_federation::{ config::Data, kinds::activity::RejectType, protocol::verification::verify_urls_match, traits::{Activity, Actor, Object}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ source::{activity::ActivitySendTargets, community::CommunityActions}, traits::Followable, }; use lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError}; use url::Url; impl RejectFollow { pub async fn send(follow: Follow, context: &Data) -> LemmyResult<()> { let user_or_community = follow.object.dereference_local(context).await?; let person = follow.actor.clone().dereference(context).await?; let reject = RejectFollow { actor: user_or_community.id().clone().into(), to: Some([person.id().clone().into()]), object: follow, kind: RejectType::Reject, id: generate_activity_id(RejectType::Reject, context)?, }; let inbox = ActivitySendTargets::to_inbox(person.shared_inbox_or_inbox()); send_activity_from_user_or_community_or_multi(context, reject, user_or_community, inbox).await } } /// Handle rejected follows #[async_trait::async_trait] impl Activity for RejectFollow { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { verify_urls_match(self.actor.inner(), self.object.object.inner())?; self.object.verify(context).await?; if let Some(to) = &self.to { verify_urls_match(to[0].inner(), self.object.actor.inner())?; } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let community = self.actor.dereference(context).await?; check_community_deleted_or_removed(&community)?; let actor = self.object.actor.dereference(context).await?; let person = actor.left().ok_or(UntranslatedError::Unreachable)?; // remove the follow CommunityActions::unfollow(&mut context.pool(), person.id, community.id).await?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/following/undo_follow.rs ================================================ use crate::{ check_community_deleted_or_removed, generate_activity_id, protocol::following::{follow::Follow, undo_follow::UndoFollow}, send_lemmy_activity, }; use activitypub_federation::{ config::Data, kinds::activity::UndoType, protocol::verification::verify_urls_match, traits::{Activity, Actor, Object}, }; use either::Either::*; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::objects::{CommunityOrMulti, person::ApubPerson}; use lemmy_db_schema::{ source::{ activity::ActivitySendTargets, community::CommunityActions, community_community_follow::CommunityCommunityFollow, instance::InstanceActions, multi_community::MultiCommunity, person::PersonActions, }, traits::Followable, }; use lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError}; use url::Url; impl UndoFollow { pub async fn send( actor: &ApubPerson, target: &CommunityOrMulti, context: &Data, ) -> LemmyResult<()> { let object = Follow::new(actor, target, context)?; let undo = UndoFollow { actor: actor.id().clone().into(), to: Some([target.id().clone().into()]), object, kind: UndoType::Undo, id: generate_activity_id(UndoType::Undo, context)?, }; let inbox = ActivitySendTargets::to_inbox(target.shared_inbox_or_inbox()); send_lemmy_activity(context, undo, actor, inbox, true).await } } #[async_trait::async_trait] impl Activity for UndoFollow { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { verify_urls_match(self.actor.inner(), self.object.actor.inner())?; self.object.verify(context).await?; if let Some(to) = &self.to { verify_urls_match(to[0].inner(), self.object.object.inner())?; } Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let actor = self.actor.dereference(context).await?; let object = self.object.object.dereference(context).await?; // Handle remote community unfollowing a local community if let (Right(community), Right(Left(follower))) = (&actor, &object) { check_community_deleted_or_removed(community)?; CommunityCommunityFollow::unfollow(&mut context.pool(), community.id, follower.id).await?; return Ok(()); } let person = actor.left().ok_or(UntranslatedError::InvalidFollow( "Groups can only follow public groups".to_string(), ))?; InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?; match object { Left(u) => { PersonActions::unfollow(&mut context.pool(), person.id, u.id).await?; } Right(Left(c)) => { CommunityActions::unfollow(&mut context.pool(), person.id, c.id).await?; } Right(Right(m)) => MultiCommunity::unfollow(&mut context.pool(), person.id, m.id).await?, } Ok(()) } } ================================================ FILE: crates/apub/activities/src/lib.rs ================================================ use crate::{ block::{send_ban_from_community, send_ban_from_site}, community::{ collection_add::{send_add_mod_to_community, send_feature_post}, lock::send_lock, update::{send_update_community, send_update_multi_community}, }, create_or_update::private_message::send_create_or_update_pm, deletion::{ DeletableObjects, send_apub_delete_in_community, send_apub_delete_private_message, send_apub_delete_user, }, following::send_follow, protocol::{ CreateOrUpdateType, community::{report::Report, resolve_report::ResolveReport}, create_or_update::{note::CreateOrUpdateNote, page::CreateOrUpdatePage}, }, voting::send_like_activity, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::AnnounceType, traits::{Activity, Actor}, }; use either::Either; use following::send_accept_or_reject_follow; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, }; use lemmy_apub_objects::{ objects::{PostOrComment, person::ApubPerson}, utils::functions::GetActorType, }; use lemmy_db_schema::source::{ activity::{ActivitySendTargets, SentActivity, SentActivityForm}, community::Community, instance::InstanceActions, }; use lemmy_db_views_post::PostView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyError, LemmyResult, UntranslatedError}; use serde::Serialize; use tracing::info; use url::{ParseError, Url}; use uuid::Uuid; pub mod activity_lists; pub mod block; pub mod community; pub mod create_or_update; pub mod deletion; pub mod following; pub mod protocol; pub mod voting; const MOD_ACTION_DEFAULT_REASON: &str = "No reason provided"; /// Checks that the specified Url actually identifies a Person (by fetching it), and that the person /// doesn't have a site ban. async fn verify_person( person_id: &ObjectId, context: &Data, ) -> LemmyResult<()> { let person = person_id.dereference(context).await?; InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?; Ok(()) } pub(crate) fn check_community_deleted_or_removed(community: &Community) -> LemmyResult<()> { if community.deleted || community.removed { Err(UntranslatedError::CannotCreatePostOrCommentInDeletedOrRemovedCommunity.into()) } else { Ok(()) } } /// Generate a unique ID for an activity, in the format: /// `http(s)://example.com/receive/create/202daf0a-1489-45df-8d2e-c8a3173fed36` fn generate_activity_id(kind: T, context: &LemmyContext) -> Result where T: ToString, { let id = format!( "{}/activities/{}/{}", &context.settings().get_protocol_and_hostname(), kind.to_string().to_lowercase(), Uuid::new_v4() ); Url::parse(&id) } /// like generate_activity_id but also add the inner kind for easier debugging fn generate_announce_activity_id( inner_kind: &str, protocol_and_hostname: &str, ) -> Result { let id = format!( "{}/activities/{}/{}/{}", protocol_and_hostname, AnnounceType::Announce.to_string().to_lowercase(), inner_kind.to_lowercase(), Uuid::new_v4() ); Url::parse(&id) } async fn send_lemmy_activity( data: &Data, activity: A, actor: &ActorT, send_targets: ActivitySendTargets, sensitive: bool, ) -> LemmyResult<()> where A: Activity + Serialize + Send + Sync + Clone + Activity, ActorT: Actor + GetActorType, { info!("Saving outgoing activity to queue {}", activity.id()); let form = SentActivityForm { ap_id: activity.id().clone().into(), data: serde_json::to_value(activity)?, sensitive, send_inboxes: send_targets .inboxes .into_iter() .map(|e| Some(e.into())) .collect(), send_all_instances: send_targets.all_instances, send_community_followers_of: send_targets.community_followers_of.map(|e| e.0), actor_type: actor.actor_type(), actor_apub_id: actor.id().clone().into(), }; SentActivity::create(&mut data.pool(), form).await?; Ok(()) } pub async fn handle_outgoing_activities(context: Data) { while let Some(data) = ActivityChannel::retrieve_activity().await { if let Err(e) = match_outgoing_activities(data, &context).await { tracing::warn!("error while saving outgoing activity to db: {e}"); } } } pub async fn match_outgoing_activities( data: SendActivityData, context: &Data, ) -> LemmyResult<()> { let context = context.clone(); Box::pin(async { use SendActivityData::*; match data { CreatePost(post) => { let creator_id = post.creator_id; CreateOrUpdatePage::send(post, creator_id, CreateOrUpdateType::Create, context).await } UpdatePost(post) => { let creator_id = post.creator_id; CreateOrUpdatePage::send(post, creator_id, CreateOrUpdateType::Update, context).await } DeletePost(post, person, community) => { let is_deleted = post.deleted; send_apub_delete_in_community( person, community, DeletableObjects::Post(post.into()), None, is_deleted, None, &context, ) .await } RemovePost { post, moderator, reason, removed, with_replies, } => { let community = Community::read(&mut context.pool(), post.community_id).await?; send_apub_delete_in_community( moderator, community, DeletableObjects::Post(post.into()), Some(reason), removed, Some(with_replies), &context, ) .await } LockPost(post, actor, locked, reason) => { send_lock( PostOrComment::Left(post.into()), actor, locked, reason, context, ) .await } FeaturePost(post, actor, featured) => send_feature_post(post, actor, featured, context).await, CreateComment(comment) => { let creator_id = comment.creator_id; CreateOrUpdateNote::send(comment, creator_id, CreateOrUpdateType::Create, context).await } UpdateComment(comment) => { let creator_id = comment.creator_id; CreateOrUpdateNote::send(comment, creator_id, CreateOrUpdateType::Update, context).await } DeleteComment(comment, actor, community) => { let is_deleted = comment.deleted; let deletable = DeletableObjects::Comment(comment.into()); send_apub_delete_in_community( actor, community, deletable, None, is_deleted, None, &context, ) .await } RemoveComment { comment, moderator, community, reason, with_replies, } => { let is_removed = comment.removed; let deletable = DeletableObjects::Comment(comment.into()); send_apub_delete_in_community( moderator, community, deletable, Some(reason), is_removed, Some(with_replies), &context, ) .await } LockComment(comment, actor, locked, reason) => { send_lock( PostOrComment::Right(comment.into()), actor, locked, reason, context, ) .await } LikePostOrComment { object_id, actor, community, previous_is_upvote, new_is_upvote, } => { send_like_activity( object_id, actor, community, previous_is_upvote, new_is_upvote, context, ) .await } FollowCommunity(community, person, follow) => { send_follow(Either::Left(community.into()), person, follow, &context).await } FollowMultiCommunity(multi, person, follow) => { send_follow(Either::Right(multi.into()), person, follow, &context).await } UpdateCommunity(actor, community) => send_update_community(community, actor, context).await, DeleteCommunity(actor, community, removed) => { let deletable = DeletableObjects::Community(community.clone().into()); send_apub_delete_in_community(actor, community, deletable, None, removed, None, &context) .await } RemoveCommunity { moderator, community, reason, removed, } => { let deletable = DeletableObjects::Community(community.clone().into()); send_apub_delete_in_community( moderator, community, deletable, Some(reason), removed, None, &context, ) .await } AddModToCommunity { moderator, community_id, target, added, } => send_add_mod_to_community(moderator, community_id, target, added, context).await, BanFromCommunity { moderator, community_id, target, data, } => send_ban_from_community(moderator, community_id, target, data, context).await, BanFromSite { moderator, banned_user, reason, remove_or_restore_data, ban, expires_at, } => { send_ban_from_site( moderator, banned_user, reason, remove_or_restore_data, ban, expires_at, context, ) .await } CreatePrivateMessage(pm) => { send_create_or_update_pm(pm, CreateOrUpdateType::Create, context).await } UpdatePrivateMessage(pm) => { send_create_or_update_pm(pm, CreateOrUpdateType::Update, context).await } DeletePrivateMessage(person, pm, deleted) => { send_apub_delete_private_message(&person.into(), pm, deleted, context).await } DeleteUser(person, remove_data) => send_apub_delete_user(person, remove_data, context).await, CreateReport { object_id, actor, receiver, reason, } => { Report::send( ObjectId::from(object_id), &actor.into(), &receiver.map_either(Into::into, Into::into), reason, context, ) .await } SendResolveReport { object_id, actor, report_creator, receiver, } => { ResolveReport::send( ObjectId::from(object_id), &actor.into(), &report_creator.into(), &receiver.map_either(Into::into, Into::into), context, ) .await } AcceptFollower(community_id, person_id) => { send_accept_or_reject_follow(community_id, person_id, true, &context).await } RejectFollower(community_id, person_id) => { send_accept_or_reject_follow(community_id, person_id, false, &context).await } UpdateMultiCommunity(multi, actor) => { send_update_multi_community(multi, actor, context).await } } }) .await?; Ok(()) } pub(crate) async fn post_or_comment_community( post_or_comment: &PostOrComment, context: &Data, ) -> LemmyResult { match post_or_comment { PostOrComment::Left(p) => Community::read(&mut context.pool(), p.community_id).await, PostOrComment::Right(c) => { let site_view = SiteView::read_local(&mut context.pool()).await?; Ok( PostView::read( &mut context.pool(), c.post_id, None, site_view.instance.id, false, ) .await? .community, ) } } } ================================================ FILE: crates/apub/activities/src/protocol/block/block_user.rs ================================================ use crate::block::SiteOrCommunity; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::BlockType, protocol::helpers::deserialize_one_or_many, }; use anyhow::anyhow; use chrono::{DateTime, Utc}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct BlockUser { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, pub(crate) target: ObjectId, #[serde(rename = "type")] pub(crate) kind: BlockType, pub(crate) id: Url, /// Quick and dirty solution. /// TODO: send a separate Delete activity instead pub(crate) remove_data: Option, /// block reason, written to mod log pub(crate) summary: Option, pub(crate) end_time: Option>, pub(crate) audience: Option>, } impl InCommunity for BlockUser { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let target = self.target.dereference(context).await?; let community = match target { SiteOrCommunity::Right(c) => c, SiteOrCommunity::Left(_) => return Err(anyhow!("activity is not in community").into()), }; Ok(community) } } ================================================ FILE: crates/apub/activities/src/protocol/block/mod.rs ================================================ pub mod block_user; pub mod undo_block_user; #[cfg(test)] mod tests { use crate::protocol::block::{block_user::BlockUser, undo_block_user::UndoBlockUser}; use lemmy_apub_objects::utils::test::test_parse_lemmy_item; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_lemmy_block() -> LemmyResult<()> { test_parse_lemmy_item::("../apub/assets/lemmy/activities/block/block_user.json")?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/block/undo_block_user.json", )?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/block/undo_block_user.rs ================================================ use super::block_user::BlockUser; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::UndoType, protocol::helpers::deserialize_one_or_many, }; use lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UndoBlockUser { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: BlockUser, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: UndoType, pub(crate) id: Url, pub(crate) audience: Option>, /// Quick and dirty solution. /// TODO: send a separate Delete activity instead pub(crate) restore_data: Option, } ================================================ FILE: crates/apub/activities/src/protocol/community/announce.rs ================================================ use crate::protocol::IdOrNestedObject; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::AnnounceType, protocol::helpers::deserialize_one_or_many, }; use lemmy_apub_objects::objects::community::ApubCommunity; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AnnounceActivity { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub object: IdOrNestedObject, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: AnnounceType, pub(crate) id: Url, } /// Use this to receive community inbox activities, and then announce them if valid. This /// ensures that all json fields are kept, even if Lemmy doesn't understand them. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct RawAnnouncableActivities { pub(crate) id: Url, pub(crate) actor: Url, #[serde(flatten)] pub(crate) other: Map, } ================================================ FILE: crates/apub/activities/src/protocol/community/collection_add.rs ================================================ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::AddType, protocol::helpers::deserialize_one_or_many, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_db_schema::source::community::Community; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CollectionAdd { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: Url, pub(crate) target: Url, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: AddType, pub(crate) id: Url, pub(crate) audience: Option>, } impl InCommunity for CollectionAdd { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let (community, _) = Community::get_by_collection_url(&mut context.pool(), &self.clone().target.into()).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/activities/src/protocol/community/collection_remove.rs ================================================ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::RemoveType, protocol::helpers::deserialize_one_or_many, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_db_schema::source::community::Community; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CollectionRemove { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: Url, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: RemoveType, pub(crate) target: Url, pub(crate) id: Url, pub(crate) audience: Option>, } impl InCommunity for CollectionRemove { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let (community, _) = Community::get_by_collection_url(&mut context.pool(), &self.clone().target.into()).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/activities/src/protocol/community/lock.rs ================================================ use crate::post_or_comment_community; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::UndoType, protocol::helpers::deserialize_one_or_many, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{PostOrComment, community::ApubCommunity, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use strum::Display; use url::Url; #[derive(Clone, Debug, Display, Deserialize, Serialize)] pub enum LockType { Lock, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LockPageOrNote { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: LockType, pub(crate) id: Url, /// Summary is the reason for the lock. pub(crate) summary: Option, pub(crate) audience: Option>, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UndoLockPageOrNote { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: LockPageOrNote, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: UndoType, pub(crate) id: Url, /// Summary is the reason for the lock. pub(crate) summary: Option, pub(crate) audience: Option>, } impl InCommunity for LockPageOrNote { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let post_or_comment = self.object.dereference(context).await?; let community = post_or_comment_community(&post_or_comment, context).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/activities/src/protocol/community/mod.rs ================================================ pub mod announce; pub mod collection_add; pub mod collection_remove; pub mod lock; pub mod report; pub mod resolve_report; pub mod update; #[cfg(test)] mod tests { use super::resolve_report::ResolveReport; use crate::protocol::community::{ announce::AnnounceActivity, collection_add::CollectionAdd, collection_remove::CollectionRemove, lock::{LockPageOrNote, UndoLockPageOrNote}, report::Report, update::Update, }; use lemmy_apub_objects::utils::test::test_parse_lemmy_item; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_lemmy_community_activities() -> LemmyResult<()> { test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/announce_create_page.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/add_mod.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/remove_mod.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/add_featured_post.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/remove_featured_post.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/lock_page.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/undo_lock_page.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/lock_note.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/undo_lock_note.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/update_community.json", )?; test_parse_lemmy_item::("../apub/assets/lemmy/activities/community/report_page.json")?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/community/resolve_report_page.json", )?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/community/report.rs ================================================ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::FlagType, protocol::helpers::deserialize_one, }; use either::Either; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{ReportableObjects, community::ApubCommunity, instance::ApubSite, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Report { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one")] pub(crate) to: [ObjectId>; 1], pub(crate) object: ReportObject, /// Report reason as sent by Lemmy pub(crate) summary: Option, /// Report reason as sent by Mastodon pub(crate) content: Option, #[serde(rename = "type")] pub(crate) kind: FlagType, pub(crate) id: Url, pub(crate) audience: Option>, } impl Report { pub fn reason(&self) -> LemmyResult { self .summary .clone() .or(self.content.clone()) .ok_or(LemmyErrorType::NotFound.into()) } } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] pub(crate) enum ReportObject { Lemmy(ObjectId), /// Mastodon sends an array containing user id and one or more post ids Mastodon(Vec), } impl ReportObject { pub(crate) async fn dereference( &self, context: &Data, ) -> LemmyResult { match self { ReportObject::Lemmy(l) => l.dereference(context).await, ReportObject::Mastodon(objects) => { for o in objects { // Find the first reported item which can be dereferenced as post or comment (Lemmy can // only handle one item per report). let deref = ObjectId::from(o.clone()).dereference(context).await; if deref.is_ok() { return deref; } } Err(LemmyErrorType::NotFound.into()) } } } pub(crate) async fn object_id( &self, context: &Data, ) -> LemmyResult> { match self { ReportObject::Lemmy(l) => Ok(l.clone()), ReportObject::Mastodon(objects) => { for o in objects { // Same logic as above, but return the ID and not the object itself. let deref = ObjectId::::from(o.clone()) .dereference(context) .await; if deref.is_ok() { return Ok(o.clone().into()); } } Err(LemmyErrorType::NotFound.into()) } } } } impl InCommunity for Report { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } match self.to[0].dereference(context).await? { Either::Left(_) => Err(LemmyErrorType::NotFound.into()), Either::Right(c) => Ok(c), } } } ================================================ FILE: crates/apub/activities/src/protocol/community/resolve_report.rs ================================================ use super::report::Report; use activitypub_federation::{fetch::object_id::ObjectId, protocol::helpers::deserialize_one}; use either::Either; use lemmy_apub_objects::objects::{ community::ApubCommunity, instance::ApubSite, person::ApubPerson, }; use serde::{Deserialize, Serialize}; use strum::Display; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Display)] pub enum ResolveType { Resolve, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ResolveReport { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one")] pub(crate) to: [ObjectId>; 1], pub(crate) object: Report, #[serde(rename = "type")] pub(crate) kind: ResolveType, pub(crate) id: Url, pub(crate) audience: Option>, } ================================================ FILE: crates/apub/activities/src/protocol/community/update.rs ================================================ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::UpdateType, protocol::helpers::deserialize_one_or_many, }; use either::Either; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, protocol::{group::Group, multi_community::Feed}, utils::protocol::InCommunity, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; use url::Url; /// This activity is received from a remote community mod, and updates the description or other /// fields of a local community. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Update { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, #[serde(with = "either::serde_untagged")] pub(crate) object: Either, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: UpdateType, pub(crate) id: Url, pub(crate) audience: Option>, } impl InCommunity for Update { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } match &self.object { Either::Left(c) => { let community: ApubCommunity = c.id.clone().dereference(context).await?; Ok(community) } Either::Right(_) => Err(LemmyErrorType::NotFound.into()), } } } ================================================ FILE: crates/apub/activities/src/protocol/create_or_update/mod.rs ================================================ pub mod note; pub(crate) mod note_wrapper; pub mod page; pub mod private_message; #[cfg(test)] mod tests { use super::note_wrapper::{CreateOrUpdateNoteWrapper, NoteWrapper}; use crate::protocol::create_or_update::{ note::CreateOrUpdateNote, page::CreateOrUpdatePage, private_message::CreateOrUpdatePrivateMessage, }; use lemmy_apub_objects::utils::test::test_parse_lemmy_item; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_lemmy_create_or_update() -> LemmyResult<()> { test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_page.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/update_page.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_comment.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_private_message.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_comment.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/create_or_update/create_private_message.json", )?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/comment.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/private_message.json")?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/create_or_update/note.rs ================================================ use crate::protocol::CreateOrUpdateType; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, protocol::helpers::deserialize_one_or_many, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, protocol::{note::Note, tags::ApubTag}, utils::protocol::InCommunity, }; use lemmy_db_schema::source::community::Community; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateOrUpdateNote { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: Note, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(default)] pub(crate) tag: Vec, #[serde(rename = "type")] pub(crate) kind: CreateOrUpdateType, pub(crate) id: Url, pub(crate) audience: Option>, } impl InCommunity for CreateOrUpdateNote { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let post = self.object.get_parents(context).await?.0; let community = Community::read(&mut context.pool(), post.community_id).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/activities/src/protocol/create_or_update/note_wrapper.rs ================================================ use activitypub_federation::kinds::object::NoteType; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateOrUpdateNoteWrapper { pub(crate) object: NoteWrapper, pub(crate) id: Url, #[serde(default)] pub(crate) to: Vec, #[serde(default)] pub(crate) cc: Vec, pub(crate) actor: Url, #[serde(flatten)] other: Map, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct NoteWrapper { pub(crate) r#type: NoteType, #[serde(flatten)] other: Map, } ================================================ FILE: crates/apub/activities/src/protocol/create_or_update/page.rs ================================================ use crate::protocol::CreateOrUpdateType; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, protocol::helpers::deserialize_one_or_many, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, protocol::page::Page, utils::protocol::InCommunity, }; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateOrUpdatePage { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: Page, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) cc: Vec, #[serde(rename = "type")] pub(crate) kind: CreateOrUpdateType, pub(crate) id: Url, pub(crate) audience: Option>, } impl InCommunity for CreateOrUpdatePage { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let community = self.object.community(context).await?; Ok(community) } } ================================================ FILE: crates/apub/activities/src/protocol/create_or_update/private_message.rs ================================================ use crate::protocol::CreateOrUpdateType; use activitypub_federation::{fetch::object_id::ObjectId, protocol::helpers::deserialize_one}; use lemmy_apub_objects::{objects::person::ApubPerson, protocol::private_message::PrivateMessage}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateOrUpdatePrivateMessage { pub(crate) id: Url, pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one")] pub(crate) to: [ObjectId; 1], pub(crate) object: PrivateMessage, #[serde(rename = "type")] pub(crate) kind: CreateOrUpdateType, } ================================================ FILE: crates/apub/activities/src/protocol/deletion/delete.rs ================================================ use crate::{deletion::DeletableObjects, protocol::IdOrNestedObject}; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::activity::DeleteType, protocol::{helpers::deserialize_one_or_many, tombstone::Tombstone}, }; use anyhow::anyhow; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_db_schema::source::{community::Community, post::Post}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Delete { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: IdOrNestedObject, #[serde(rename = "type")] pub(crate) kind: DeleteType, pub(crate) id: Url, pub(crate) audience: Option>, #[serde(deserialize_with = "deserialize_one_or_many")] #[serde(default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub(crate) cc: Vec, /// If summary is present, this is a mod action (Remove in Lemmy terms). Otherwise, its a user /// deleting their own content. pub(crate) summary: Option, /// Nonstandard field, only valid if object refers to a Person. If present, all content from the /// user should be deleted along with the account pub(crate) remove_data: Option, /// Nonstandard field denoting that the replies to an `Object` should be removed along with the /// `Object`. Only valid for `Pages` and `Notes`. // See here for discussion of this: // https://activitypub.space/topic/78/deleting-a-post-vs-deleting-an-entire-comment-tree pub(crate) with_replies: Option, } impl InCommunity for Delete { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let community_id = match DeletableObjects::read_from_db(self.object.id(), context).await? { DeletableObjects::Community(c) => c.id, DeletableObjects::Comment(c) => { let post = Post::read(&mut context.pool(), c.post_id).await?; post.community_id } DeletableObjects::Post(p) => p.community_id, DeletableObjects::Person(_) => return Err(anyhow!("Person is not part of community").into()), DeletableObjects::PrivateMessage(_) => { return Err(anyhow!("Private message is not part of community").into()); } }; let community = Community::read(&mut context.pool(), community_id).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/activities/src/protocol/deletion/delete_user.rs ================================================ use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::DeleteType, protocol::helpers::deserialize_one_or_many, }; use lemmy_apub_objects::objects::person::ApubPerson; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeleteUser { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: ObjectId, #[serde(rename = "type")] pub(crate) kind: DeleteType, pub(crate) id: Url, #[serde(deserialize_with = "deserialize_one_or_many", default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub(crate) cc: Vec, /// Nonstandard field. If present, all content from the user should be deleted along with the /// account pub(crate) remove_data: Option, } ================================================ FILE: crates/apub/activities/src/protocol/deletion/mod.rs ================================================ pub mod delete; pub mod delete_user; pub mod undo_delete; #[cfg(test)] mod tests { use crate::protocol::deletion::{ delete::Delete, delete_user::DeleteUser, undo_delete::UndoDelete, }; use lemmy_apub_objects::utils::test::test_parse_lemmy_item; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_lemmy_deletion() -> LemmyResult<()> { test_parse_lemmy_item::("../apub/assets/lemmy/activities/deletion/remove_note.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/activities/deletion/delete_page.json")?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/deletion/undo_remove_note.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/deletion/undo_delete_page.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/deletion/delete_private_message.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/deletion/undo_delete_private_message.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/deletion/delete_user.json", )?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/deletion/undo_delete.rs ================================================ use super::delete::Delete; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::UndoType, protocol::helpers::deserialize_one_or_many, }; use lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UndoDelete { pub(crate) actor: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, pub(crate) object: Delete, #[serde(rename = "type")] pub(crate) kind: UndoType, pub(crate) id: Url, pub(crate) audience: Option>, #[serde(deserialize_with = "deserialize_one_or_many", default)] #[serde(skip_serializing_if = "Vec::is_empty")] pub(crate) cc: Vec, } ================================================ FILE: crates/apub/activities/src/protocol/following/accept.rs ================================================ use crate::protocol::following::follow::Follow; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::AcceptType, protocol::helpers::deserialize_skip_error, }; use lemmy_apub_objects::objects::{UserOrCommunity, community::ApubCommunity}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AcceptFollow { pub(crate) actor: ObjectId, /// Optional, for compatibility with platforms that always expect recipient field #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) to: Option<[ObjectId; 1]>, pub(crate) object: Follow, #[serde(rename = "type")] pub(crate) kind: AcceptType, pub(crate) id: Url, } ================================================ FILE: crates/apub/activities/src/protocol/following/follow.rs ================================================ use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::FollowType, protocol::helpers::deserialize_skip_error, }; use lemmy_apub_objects::objects::{UserOrCommunity, UserOrCommunityOrMulti}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Follow { pub(crate) actor: ObjectId, /// Optional, for compatibility with platforms that always expect recipient field #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) to: Option<[ObjectId; 1]>, pub(crate) object: ObjectId, #[serde(rename = "type")] pub(crate) kind: FollowType, pub(crate) id: Url, } ================================================ FILE: crates/apub/activities/src/protocol/following/mod.rs ================================================ pub(crate) mod accept; pub mod follow; pub(crate) mod reject; pub mod undo_follow; #[cfg(test)] mod tests { use crate::protocol::following::{accept::AcceptFollow, follow::Follow, undo_follow::UndoFollow}; use lemmy_apub_objects::utils::test::test_parse_lemmy_item; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_lemmy_accept_follow() -> LemmyResult<()> { test_parse_lemmy_item::("../apub/assets/lemmy/activities/following/follow.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/activities/following/accept.json")?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/following/undo_follow.json", )?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/following/reject.rs ================================================ use crate::protocol::following::follow::Follow; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::RejectType, protocol::helpers::deserialize_skip_error, }; use lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct RejectFollow { pub(crate) actor: ObjectId, /// Optional, for compatibility with platforms that always expect recipient field #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) to: Option<[ObjectId; 1]>, pub(crate) object: Follow, #[serde(rename = "type")] pub(crate) kind: RejectType, pub(crate) id: Url, } ================================================ FILE: crates/apub/activities/src/protocol/following/undo_follow.rs ================================================ use crate::protocol::following::follow::Follow; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::activity::UndoType, protocol::helpers::deserialize_skip_error, }; use lemmy_apub_objects::objects::{UserOrCommunity, person::ApubPerson}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UndoFollow { pub(crate) actor: ObjectId, /// Optional, for compatibility with platforms that always expect recipient field #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) to: Option<[ObjectId; 1]>, pub(crate) object: Follow, #[serde(rename = "type")] pub(crate) kind: UndoType, pub(crate) id: Url, } ================================================ FILE: crates/apub/activities/src/protocol/mod.rs ================================================ use activitypub_federation::{config::Data, fetch::fetch_object_http}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::utils::protocol::Id; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use strum::Display; use url::Url; pub mod block; pub mod community; pub mod create_or_update; pub mod deletion; pub mod following; pub mod voting; #[derive(Clone, Debug, Display, Deserialize, Serialize, PartialEq, Eq)] pub enum CreateOrUpdateType { Create, Update, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum IdOrNestedObject { Id(Url), NestedObject(Kind), } impl IdOrNestedObject { pub(crate) fn id(&self) -> &Url { match self { IdOrNestedObject::Id(i) => i, IdOrNestedObject::NestedObject(n) => n.id(), } } pub async fn object(self, context: &Data) -> LemmyResult { match self { // TODO: move IdOrNestedObject struct to library and make fetch_object_http private IdOrNestedObject::Id(i) => Ok(fetch_object_http(&i, context).await?.object), IdOrNestedObject::NestedObject(o) => Ok(o), } } } #[cfg(test)] mod tests { use crate::protocol::{ community::{announce::AnnounceActivity, report::Report}, create_or_update::{note::CreateOrUpdateNote, page::CreateOrUpdatePage}, deletion::delete::Delete, following::{accept::AcceptFollow, follow::Follow, undo_follow::UndoFollow}, voting::{undo_vote::UndoVote, vote::Vote}, }; use lemmy_apub_objects::utils::test::test_json; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_smithereen_activities() -> LemmyResult<()> { test_json::("../apub/assets/smithereen/activities/create_note.json")?; Ok(()) } #[test] fn test_parse_pleroma_activities() -> LemmyResult<()> { test_json::("../apub/assets/pleroma/activities/create_note.json")?; test_json::("../apub/assets/pleroma/activities/delete.json")?; test_json::("../apub/assets/pleroma/activities/follow.json")?; Ok(()) } #[test] fn test_parse_mastodon_activities() -> LemmyResult<()> { test_json::("../apub/assets/mastodon/activities/create_note.json")?; test_json::("../apub/assets/mastodon/activities/delete.json")?; test_json::("../apub/assets/mastodon/activities/follow.json")?; test_json::("../apub/assets/mastodon/activities/undo_follow.json")?; test_json::("../apub/assets/mastodon/activities/like_page.json")?; test_json::("../apub/assets/mastodon/activities/undo_like_page.json")?; test_json::("../apub/assets/mastodon/activities/flag.json")?; Ok(()) } #[test] fn test_parse_lotide_activities() -> LemmyResult<()> { test_json::("../apub/assets/lotide/activities/follow.json")?; test_json::("../apub/assets/lotide/activities/create_page.json")?; test_json::("../apub/assets/lotide/activities/create_page_image.json")?; test_json::("../apub/assets/lotide/activities/create_note_reply.json")?; Ok(()) } #[test] fn test_parse_friendica_activities() -> LemmyResult<()> { test_json::("../apub/assets/friendica/activities/create_page_1.json")?; test_json::("../apub/assets/friendica/activities/create_page_2.json")?; test_json::("../apub/assets/friendica/activities/create_note.json")?; test_json::("../apub/assets/friendica/activities/update_note.json")?; test_json::("../apub/assets/friendica/activities/delete.json")?; test_json::("../apub/assets/friendica/activities/like_page.json")?; test_json::("../apub/assets/friendica/activities/dislike_page.json")?; test_json::("../apub/assets/friendica/activities/undo_dislike_page.json")?; Ok(()) } #[test] fn test_parse_gnusocial_activities() -> LemmyResult<()> { test_json::("../apub/assets/gnusocial/activities/create_page.json")?; test_json::("../apub/assets/gnusocial/activities/create_note.json")?; test_json::("../apub/assets/gnusocial/activities/like_note.json")?; Ok(()) } #[test] fn test_parse_peertube_activities() -> LemmyResult<()> { test_json::("../apub/assets/peertube/activities/announce_video.json")?; Ok(()) } #[test] fn test_parse_mbin_activities() -> LemmyResult<()> { test_json::("../apub/assets/mbin/activities/accept.json")?; test_json::("../apub/assets/mbin/activities/flag.json")?; Ok(()) } #[test] fn test_parse_wordpress_activities() -> LemmyResult<()> { test_json::("../apub/assets/wordpress/activities/announce.json")?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/voting/mod.rs ================================================ pub mod undo_vote; pub mod vote; #[cfg(test)] mod tests { use crate::protocol::voting::{undo_vote::UndoVote, vote::Vote}; use lemmy_apub_objects::utils::test::test_parse_lemmy_item; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_lemmy_voting() -> LemmyResult<()> { test_parse_lemmy_item::("../apub/assets/lemmy/activities/voting/like_note.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/activities/voting/dislike_page.json")?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/voting/undo_like_note.json", )?; test_parse_lemmy_item::( "../apub/assets/lemmy/activities/voting/undo_dislike_page.json", )?; Ok(()) } } ================================================ FILE: crates/apub/activities/src/protocol/voting/undo_vote.rs ================================================ use super::vote::Vote; use activitypub_federation::{fetch::object_id::ObjectId, kinds::activity::UndoType}; use lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson}; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UndoVote { pub(crate) actor: ObjectId, pub(crate) object: Vote, #[serde(rename = "type")] pub(crate) kind: UndoType, pub(crate) id: Url, pub(crate) audience: Option>, } ================================================ FILE: crates/apub/activities/src/protocol/voting/vote.rs ================================================ use crate::post_or_comment_community; use activitypub_federation::{config::Data, fetch::object_id::ObjectId}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{PostOrComment, community::ApubCommunity, person::ApubPerson}, utils::protocol::InCommunity, }; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use strum::Display; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Vote { pub(crate) actor: ObjectId, pub(crate) object: ObjectId, #[serde(rename = "type")] pub(crate) kind: VoteType, pub(crate) id: Url, pub(crate) audience: Option>, } #[derive(Clone, Debug, Display, Deserialize, Serialize, PartialEq, Eq)] pub enum VoteType { Like, Dislike, } impl From for VoteType { fn from(value: bool) -> Self { if value { VoteType::Like } else { VoteType::Dislike } } } impl From<&VoteType> for bool { fn from(value: &VoteType) -> Self { value == &VoteType::Like } } impl InCommunity for Vote { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let post_or_comment = self.object.dereference(context).await?; let community = post_or_comment_community(&post_or_comment, context).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/activities/src/voting/mod.rs ================================================ use crate::{ activity_lists::AnnouncableActivities, community::send_activity_in_community, protocol::voting::{ undo_vote::UndoVote, vote::{Vote, VoteType}, }, }; use activitypub_federation::{config::Data, fetch::object_id::ObjectId}; use lemmy_api_utils::{ context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, }; use lemmy_apub_objects::objects::{ PostOrComment, comment::ApubComment, community::ApubCommunity, person::ApubPerson, post::ApubPost, }; use lemmy_db_schema::{ source::{ activity::ActivitySendTargets, comment::{CommentActions, CommentLikeForm}, community::Community, person::Person, post::{PostActions, PostLikeForm}, }, traits::Likeable, }; use lemmy_diesel_utils::dburl::DbUrl; use lemmy_utils::error::LemmyResult; pub mod undo_vote; pub mod vote; pub(crate) async fn send_like_activity( object_id: DbUrl, actor: Person, community: Community, previous_is_upvote: Option, new_is_upvote: Option, context: Data, ) -> LemmyResult<()> { let object_id: ObjectId = object_id.into(); let actor: ApubPerson = actor.into(); let community: ApubCommunity = community.into(); let empty = ActivitySendTargets::empty(); if let Some(s) = new_is_upvote { let vote = Vote::new(object_id, &actor, &community, s.into(), &context)?; let activity = AnnouncableActivities::Vote(vote); send_activity_in_community(activity, &actor, &community, empty, false, &context).await } else { // undo a previous vote let previous_vote_type = if previous_is_upvote == Some(true) { VoteType::Like } else { VoteType::Dislike }; let vote = Vote::new(object_id, &actor, &community, previous_vote_type, &context)?; let undo_vote = UndoVote::new(vote, &actor, &community, &context)?; let activity = AnnouncableActivities::UndoVote(undo_vote); send_activity_in_community(activity, &actor, &community, empty, false, &context).await } } async fn vote_comment( vote_type: &VoteType, actor: ApubPerson, comment: &ApubComment, context: &Data, ) -> LemmyResult<()> { let mut like_form = CommentLikeForm::new(comment.id, actor.id, Some(vote_type.into())); comment.set_not_pending(&mut context.pool()).await?; like_form = plugin_hook_before("comment_before_vote", like_form).await?; let like = CommentActions::like(&mut context.pool(), &like_form).await?; plugin_hook_after("comment_after_vote", &like); Ok(()) } async fn vote_post( vote_type: &VoteType, actor: ApubPerson, post: &ApubPost, context: &Data, ) -> LemmyResult<()> { let mut like_form = PostLikeForm::new(post.id, actor.id, Some(vote_type.into())); post.set_not_pending(&mut context.pool()).await?; like_form = plugin_hook_before("post_before_vote", like_form).await?; let like = PostActions::like(&mut context.pool(), &like_form).await?; plugin_hook_after("post_after_vote", &like); Ok(()) } async fn undo_vote_comment( actor: ApubPerson, comment: &ApubComment, context: &Data, ) -> LemmyResult<()> { let form = CommentLikeForm::new(comment.id, actor.id, None); CommentActions::like(&mut context.pool(), &form).await?; Ok(()) } async fn undo_vote_post( actor: ApubPerson, post: &ApubPost, context: &Data, ) -> LemmyResult<()> { let form = PostLikeForm::new(post.id, actor.id, None); PostActions::like(&mut context.pool(), &form).await?; Ok(()) } ================================================ FILE: crates/apub/activities/src/voting/undo_vote.rs ================================================ use crate::{ check_community_deleted_or_removed, generate_activity_id, protocol::voting::{undo_vote::UndoVote, vote::Vote}, voting::{undo_vote_comment, undo_vote_post}, }; use activitypub_federation::{ config::Data, kinds::activity::UndoType, protocol::verification::verify_urls_match, traits::{Activity, Object}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{PostOrComment, community::ApubCommunity, person::ApubPerson}, utils::{functions::verify_person_in_community, protocol::InCommunity}, }; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl UndoVote { pub(in crate::voting) fn new( vote: Vote, actor: &ApubPerson, community: &ApubCommunity, context: &Data, ) -> LemmyResult { Ok(UndoVote { actor: actor.id().clone().into(), object: vote, kind: UndoType::Undo, id: generate_activity_id(UndoType::Undo, context)?, audience: Some(community.ap_id.clone().into()), }) } } #[async_trait::async_trait] impl Activity for UndoVote { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let community = self.object.community(context).await?; check_community_deleted_or_removed(&community)?; verify_person_in_community(&self.actor, &community, context).await?; verify_urls_match(self.actor.inner(), self.object.actor.inner())?; self.object.verify(context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let actor = self.actor.dereference(context).await?; let object = self.object.object.dereference(context).await?; match object { PostOrComment::Left(p) => undo_vote_post(actor, &p, context).await, PostOrComment::Right(c) => undo_vote_comment(actor, &c, context).await, } } } ================================================ FILE: crates/apub/activities/src/voting/vote.rs ================================================ use crate::{ check_community_deleted_or_removed, generate_activity_id, protocol::voting::vote::{Vote, VoteType}, voting::{undo_vote_comment, undo_vote_post, vote_comment, vote_post}, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, traits::{Activity, Object}, }; use lemmy_api_utils::{context::LemmyContext, utils::check_bot_account}; use lemmy_apub_objects::{ objects::{PostOrComment, community::ApubCommunity, person::ApubPerson}, utils::{functions::verify_person_in_community, protocol::InCommunity}, }; use lemmy_db_schema_file::enums::FederationMode; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl Vote { pub(in crate::voting) fn new( object_id: ObjectId, actor: &ApubPerson, community: &ApubCommunity, kind: VoteType, context: &Data, ) -> LemmyResult { Ok(Vote { actor: actor.id().clone().into(), object: object_id, kind: kind.clone(), id: generate_activity_id(kind, context)?, audience: Some(community.ap_id.clone().into()), }) } } #[async_trait::async_trait] impl Activity for Vote { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { self.actor.inner() } async fn verify(&self, context: &Data) -> LemmyResult<()> { let community = self.community(context).await?; check_community_deleted_or_removed(&community)?; verify_person_in_community(&self.actor, &community, context).await?; Ok(()) } async fn receive(self, context: &Data) -> LemmyResult<()> { let actor = self.actor.dereference(context).await?; let object = self.object.dereference(context).await?; check_bot_account(&actor.0)?; // Check for enabled federation votes let local_site = SiteView::read_local(&mut context.pool()) .await .map(|s| s.local_site) .unwrap_or_default(); let (downvote_setting, upvote_setting) = match object { PostOrComment::Left(_) => (local_site.post_downvotes, local_site.post_upvotes), PostOrComment::Right(_) => (local_site.comment_downvotes, local_site.comment_upvotes), }; // Don't allow dislikes for either disabled, or local only votes let downvote_fail = self.kind == VoteType::Dislike && downvote_setting != FederationMode::All; let upvote_fail = self.kind == VoteType::Like && upvote_setting != FederationMode::All; if downvote_fail || upvote_fail { // If this is a rejection, undo the vote match object { PostOrComment::Left(p) => undo_vote_post(actor, &p, context).await, PostOrComment::Right(c) => undo_vote_comment(actor, &c, context).await, } } else { // Otherwise apply the vote normally match object { PostOrComment::Left(p) => vote_post(&self.kind, actor, &p, context).await, PostOrComment::Right(c) => vote_comment(&self.kind, actor, &c, context).await, } } } } ================================================ FILE: crates/apub/apub/Cargo.toml ================================================ [package] name = "lemmy_apub" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_apub" path = "src/lib.rs" doctest = false [features] full = [] [lints] workspace = true [dependencies] lemmy_db_views_community_moderator = { workspace = true, features = ["full"] } lemmy_db_views_community_follower = { workspace = true, features = ["full"] } lemmy_db_views_community_follower_approval = { workspace = true, features = [ "full", ] } lemmy_db_views_post = { workspace = true, features = ["full"] } lemmy_db_views_site = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true, features = ["full"] } lemmy_apub_activities = { workspace = true } lemmy_apub_objects = { workspace = true } lemmy_diesel_utils = { workspace = true } activitypub_federation = { workspace = true } lemmy_db_schema_file = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } actix-web = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } futures = { workspace = true } async-trait = { workspace = true } either = { workspace = true } chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/apub/apub/assets/discourse/objects/group.json ================================================ { "id": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146", "type": "Group", "updated": "2024-04-05T12:49:51Z", "url": "https://socialhub.activitypub.rocks/c/meeting/threadiverse-wg/88", "name": "Threadiverse Working Group (SocialHub)", "inbox": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146/inbox", "outbox": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146/outbox", "followers": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146/followers", "preferredUsername": "threadiverse-wg", "publicKey": { "id": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146#main-key", "owner": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApJi4iAcW6bPiHVCxT9p0\n8DVnrDDO4QtLNy7bpRFdMFifmmmXprsuAi9D2MSwbhH49V54HtIkxBpKd2IR/UD8\nmhMDY4CNI9FHpjqLw0wtkzxcqF9urSqhn0/vWX+9oxyhIgQS5KMiIkYDMJiAc691\niEcZ8LCran23xIGl6Dk54Nr3TqTMLcjDhzQYUJbxMrLq5/knWqOKG3IF5OxK+9ZZ\n1wxDF872eJTxJLkmpag+WYNtHzvB2SGTp8j5IF1/pZ9J1c3cpYfaeolTch/B/GQn\najCB4l27U52rIIObxJqFXSY8wHyd0aAmNmxzPZ7cduRlBDhmI40cAmnCV1YQPvpk\nDwIDAQAB\n-----END PUBLIC KEY-----\n" }, "icon": { "type": "Image", "mediaType": "image/png", "url": "https://socialhub.activitypub.rocks/uploads/default/original/1X/8faac84234dc73d074dadaa2bcf24dc746b8647f.png" }, "@context": "https://www.w3.org/ns/activitystreams" } ================================================ FILE: crates/apub/apub/assets/discourse/objects/page.json ================================================ { "id": "https://socialhub.activitypub.rocks/ap/object/1899f65c062200daec50a4c89ed76dc9", "type": "Note", "audience": "https://socialhub.activitypub.rocks/ap/actor/797217cf18c0e819dfafc52425590146", "published": "2024-04-13T14:36:19Z", "updated": "2024-04-13T14:36:19Z", "url": "https://socialhub.activitypub.rocks/t/our-next-meeting/4079/1", "attributedTo": "https://socialhub.activitypub.rocks/ap/actor/495843076e9e469fbd35ccf467ae9fb1", "name": "Our next meeting", "context": "https://socialhub.activitypub.rocks/ap/collection/8850f6e85b57c490da915a5dfbbd5045", "content": "

Last Meeting

\n

Recording

\n
https://us06web.zoom.us/rec/share/4hGBTvgXJPlu8UkjkkxVARypNg5DH0eeaKlIBv71D4G3lokYyrCrg7cqBCJmL109.FsHYTZDlVvZXrgcn?startTime=1712254114000\nPasscode: z+1*4pUB\n

Minutes

\nTo refresh your memory, you can read the minutes of last week's meeting @jakob test", "contentMap": { "de": "@jakob test" }, "source": { "content": "@[url=https://lemmy.schuerz.at/u/jakob]Jakob[/url] test", "mediaType": "text/bbcode" }, "diaspora:comment": "{\"author\":\"jakob@soc.schuerz.at\",\"guid\":\"4edd2508-4361-edb8-c4d8-b45181083984\",\"created_at\":\"2022-01-23T20:21:24Z\",\"edited_at\":\"2022-01-23T20:21:24Z\",\"parent_guid\":\"ea620d1e-742c8b4d15249a9b-18b5fca3\",\"text\":\"@{Jakob; jakob@lemmy.schuerz.at} test\",\"author_signature\":\"JNCqOui5Cg8\\/Uxw+f0NtGCRjRnhPOrqE6kGJnMkZvOOKhlCdZbCqvyPlNJzEYDa3Z30mOWQKTTNo5BVI+VVZtGrVEqFOdzNog7jOLQoY1dKU9iEQ9vc8USwUCkyJyv48w1iXpfea87KPwv+03DMlftmD6kC7jdUVwhc7+jm0g4fh06tpOcCMQJOZqTTV\\/80EjxIJQ+8eEk5evSw\\/S98ohD1ahcwSomJ9hJUV1H48ucDvMod1FCLcN5h4ALHqubCu4TZIYhGhw9zoCl52GeHhrD3\\/vL6OW4ftZ7UG4rEKQ4HowuXqmNwydrQldtprRtu2UrZBjLqVusPXEs\\/xERQqZnalNXHijyd1TwwCmfTV4YjKwH4BhX\\/p4hdWMqEP4yYXlfA4apalVeAaYZLrNR58kPJjBHad\\/yqH30ziBFheqZ5odFh\\/jnKB4OCFVST3u9b1OKE0jyTrbTepPTaONwc8giQH1sM8koj1gFdulwuJuOTRUKR\\/8ishgHi5SWwbp5YG5Z3YSINkF10IcLiFZAF300AvwgOCdf7ferim4i\\/7TR1D2CBpoNUZnKCKZRymZbE0GuKEE+A6Pk3lk\\/DCsDtmMXpnxlPZ8Nq8OZS\\/olXevAu1y57MNnxBDXtojr4F54MP2fO7E2JwBr7AlwoeSEvtZSAO\\/elzrKfW0eVWOUM2OnI=\"}", "attachment": [], "tag": [ { "type": "Mention", "href": "https://lemmy.schuerz.at/u/jakob", "name": "@jakob@lemmy.schuerz.at" } ], "to": [ "https://lemmy.schuerz.at/u/jakob", "https://www.w3.org/ns/activitystreams#Public", "https://lemmy.schuerz.at/c/test" ], "cc": ["https://soc.schuerz.at/followers/jakob"] }, "signature": { "type": "RsaSignature2017", "nonce": "fe42f1478453c9c5e92efdc8a1b00c7e2dd2ce89501f2437c4438b8add1c8ff7", "creator": "https://soc.schuerz.at/profile/jakob#main-key", "created": "2022-01-23T20:21:25Z", "signatureValue": "iWeNKyfH/d5+f6FDmZIadF4hW7XBliL8w3PQ2QkeKQG7fheqx1MB6825JX+Eaq8C0aNESesTTiDJgy3Xdcw8tgKwAVdji2DNZh7rNbSy57AzXlXOPRDnGJUbXp8gAuW2PJNZx3TTsJ5yM7tKLmHk0PpwsnKbvjFabL5O+htyfRZNVjFAsB9bVym/dBvf4jiTZiLufGDprgsaDVygUi3QrzmwsE41NZtL/MIEtbiC5pROWQvdQBEzeLfMDsnjI4CR+3tnaSlvepipuFxeSFpwl5Ae5+YM6IYRvSDsssjr8kAg1t3XnHUyeBdBdys0A6ryR5t5QuY0ygAHFs+X633JsgHDuCxxHiqNYxFuTs1xO0gmHydFy1iKlEt2rbr9pcX05hSvEFg0bI8HEC5M9GuafpY7sOyLX0jobBUH9CxdHUu0qri4ntORlvvAYsGFNHj+folFlMRBNMkcZ+MbrAxdoXBdjhsAp+tD6nje+PeZy63yJJQmPLQi9E+fHGGe0DAobGrBE/XF8X1ABH+ywyKwVu0t6lkSxu+zdr9+JXKgnf7HaFSsknapumw9aQwC7N/Q0M5KO41fF0R4VL2GtoppyB9Ck9Dg1zwMWjL2KZN3ckbWABb+frWtmKIVQACzupRWzHiHSZjRRNJalK3uugVisHF2PFGkjYoUjHDCNegKHO0=" } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/create_page_1.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount" } ], "id": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/activity", "type": "Create", "actor": "https://masto.qa.urbanwildlife.biz/users/mastodon", "published": "2023-05-26T16:45:48Z", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": [ "https://masto.qa.urbanwildlife.biz/users/mastodon/followers", "https://lemmy.qa.urbanwildlife.biz/c/lemmy_community", "https://lemmy.qa.urbanwildlife.biz/c/lemmy_community/followers" ], "object": { "id": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161", "type": "Note", "summary": null, "inReplyTo": null, "published": "2023-05-26T16:45:48Z", "url": "https://masto.qa.urbanwildlife.biz/@mastodon/110435994705014161", "attributedTo": "https://masto.qa.urbanwildlife.biz/users/mastodon", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": [ "https://masto.qa.urbanwildlife.biz/users/mastodon/followers", "https://lemmy.qa.urbanwildlife.biz/c/lemmy_community", "https://lemmy.qa.urbanwildlife.biz/c/lemmy_community/followers" ], "sensitive": false, "atomUri": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161", "inReplyToAtomUri": null, "conversation": "tag:masto.qa.urbanwildlife.biz,2023-05-26:objectId=61:objectType=Conversation", "content": "

Test post to community

@lemmy_community

", "contentMap": { "fr": "

Test post to community

@lemmy_community

" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://lemmy.qa.urbanwildlife.biz/c/lemmy_community", "name": "@lemmy_community@lemmy.qa.urbanwildlife.biz" } ], "replies": { "id": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/replies?only_other_accounts=true&page=true", "partOf": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110435994705014161/replies", "items": [] } } } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/create_page_2.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850/Create", "type": "Create", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T04:23:44Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1452", "url": "https://pirati.ca" }, "to": ["https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "object": { "id": "https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850", "type": "Article", "summary": "", "inReplyTo": null, "diaspora:guid": "ec054ce7-5162-3bf2-504c-16d024994850", "published": "2022-03-24T04:23:44Z", "url": "https://pirati.ca/display/ec054ce7-5162-3bf2-504c-16d024994850", "attributedTo": "https://pirati.ca/profile/heluecht", "sensitive": false, "context": "https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850#context", "name": "From Friendica to Lemmy", "content": "Hello Lemmy!", "contentMap": { "de": "!testcom Hello Lemmy!" }, "source": { "content": "![url=https://ds9.lemmy.ml/c/testcom]testcom[/url] Hello Lemmy!", "mediaType": "text/bbcode" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://ds9.lemmy.ml/c/testcom", "name": "@testcom@ds9.lemmy.ml" } ], "to": ["https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ] }, "signature": { "type": "RsaSignature2017", "nonce": "d1b75df08009e59510606604758732d499c3c385b4ce6ee374e6d8c2ee86230b", "creator": "https://pirati.ca/profile/heluecht#main-key", "created": "2022-03-24T04:26:55Z", "signatureValue": "nmAyEh/6Zq5Ki0AYtTFDxGom67HOTuWDzToGuEorm0cOzsNv7OIUgGjkOtKOVJA91J7NaS1hCCSMrhM7HCurIQyE3wDa3NAzhDQORVbbRF6+NxpB70nlJaOaInAS4bmVsed0rBg2aYQfrai0QB4F8zhN8JIa6zu0EAtLMh0vkzDFOCeGbvahLkSJO+sZKEqAWsr3VfMmJ8TCd+JWUKyy2/Hd87czj58oMk8yKzKKHlL0z+rbP2LbpaZspHCT+kfkZy0IOjrdcvgENCwCsPA9Y5kJmH08NXkKG6D6CGIti2zhnkNMxBuZ5sPU6PcE535J/UjfrGN3ikGoxjO3bRqFI42TbhTxBRk/yMv0BVIPLzAbPAJdJ6VAwq5UAjI6G4ejjQ9LKRqjxlG96PNo+YVKFhKTmmSMWLmdWckC8PRL2nZvq4UtIf4cd+p1pQ+TnfjD8ZHadb10tHJEXrUIz9Q/pWmfLvTSodbdYRSurWFNYaJ3gCEjSoCoHxA7GeEoivSA0IuuDRKo0tFEv09/BA9m8m04kjQ8q/ZxAb2P4TICcPZrhGKoDkRTc1P5vSNeuw8GFV/5Dy7NWKarJUlzCA1OoHo/cKWhY3fVc/lANssTyxsMakp4ofN0zRoToQAx6hDLgESJcn04tUO4JvluYkGggBEhPfhLNSyc6yUWahjh9Gc=" } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/delete.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210/Delete", "type": "Delete", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T07:22:36Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1453", "url": "https://pirati.ca" }, "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "object": { "id": "https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210", "type": "Tombstone" }, "signature": { "type": "RsaSignature2017", "nonce": "eecfe411c2f5b4a21354f2580593c5d6cbbafef1dfc2266c3a29d3136270e489", "creator": "https://pirati.ca/profile/heluecht#main-key", "created": "2022-03-24T21:43:46Z", "signatureValue": "SCu1Qj4V4JJl+GbJB/Wy//L6DFSKc0T4lTSxI2FWD+2lyeumtDu3raqg2Kfg2uR1abWgf2T0cDLJn31wpjzAQ6QpR3tGgM7o3yHV9KIZZa0QJ7Oa/cGW0ZJijiNAETKw67cthb+hy4z6dx1M+s7wCSEQZoEZqmgn/5BMY8o0NMw/BSV797uF1tJRq29AsdIgJpjX4eX2kVmVTtYMqHc8T5/l1z3FsZFyL0UkW5BypT0T3lhGlKflov47oNSPsadHgL2A8RPdiY59OLbHCJZnQgcHA3BgeMBnwlmtpGqcfsJKUo+43zXkfKikPOO03WQ+w+LzS9UOLWhhP5yfVBLmwhM5oPfps9VUjJ/gOyWtw8pPAK/LL65sUiooxdR3fqskctVRlTDGJ8WTZPsJwsH9zygBiHOmVnkIdHkNdsA80GD9iGmnPTHixEIY124QWu+o53kydEOAbOiZo7vowjSN4ViPzkhh9xI5xkkPLeIQSEgmSsxBN98xpRVBg17qjkhBWVMCwTIqFsmzlp3oWFdp0m0xwhKJLKwlcKReoaWUCUINBgBUIfAQuWHpv7Clv7xw4pcZ1w/hiZU+1ZOnEjwGQILaGpJvK2C80z5RkhCDSamT5vetS2TrkxbjkJIY6ffUzxJQ/Ox/zN4KWyaYV6DfYELfGkybBXKo3EFhLQDARos=" } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/dislike_page.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-5762-3ce2-b3e4-87e268433367", "type": "Dislike", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T21:29:23Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1453", "url": "https://pirati.ca" }, "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "diaspora:guid": "ec054ce7-1862-3ce2-b3e4-870035437794", "diaspora:like": "{\"author\":\"heluecht@pirati.ca\",\"guid\":\"ec054ce7-1862-3ce2-b3e4-870035437794\",\"parent_guid\":\"ec054ce7-2062-3bfa-8687-ca8313624820\",\"parent_type\":\"Post\",\"positive\":\"false\",\"author_signature\":\"KWp5AQ71Tn4kFgGxzgLDLQUvULKMtsb4DYwP\\/Ap9QNGStMQuKvYE2VBthRBaIvX9LmknZ3cBvuqKvNaL2Nj7B2R2Goa7\\/eWYDCogwafbp6Pj93vWvdy2+fGTkHGSxobnvgLvFIqv9IOy2Lk4QjWj7o64dUCiopR0OKjL8+vPM+l8iF+7bYeG+xSqy8SX8Fai5XOoNhy9anaJzK9ASLah8VeXKdfjGrvYsx2X\\/PaP+B8xFySP2XM95kGPKxyExi7Hk0j2igvjHqC2s3Cdg9+nwuUijnUycqGHUq3djMTLoPRjMHOJquZ1t2BNY575iRbYJTlteIgQkPHf50WALkzxn8zY5MkudBzffxm8B1Q6bnwoQHK8TR7KU2gMPwnQm6\\/ncygHuq1flVm3dqrF9xG6Cp2wC2SgTcErhrS\\/6in7FzrgBIOl570cxY0ovFICrE6rinuBdJkjfWYE3CZGCo6fVTAXUmje48c0611JmGD3PM6XQigFXGE32fjjaQoIkXf8TWI01kIqJDmZn80S6NXaYSrf9maWN1CB5gQ2E6B6Zk586sTZ2nnnJNol2KTkM1BPCTSMkrdiLtpjkUEGeo1tTe87oUzFHx++rgSO\\/lM94Dw5oN2jifGQquDBgIHY5ovxXVN3xrTgfrLEx+HWxdsiuIYpPx4lu4Qe0CVgZwPMqR4=\"}", "object": "https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820", "signature": { "type": "RsaSignature2017", "nonce": "02cae41f51765647ceeac26de13e29757cb47b7da1b54703116a7ae185fe967e", "creator": "https://pirati.ca/profile/heluecht#main-key", "created": "2022-03-24T21:32:43Z", "signatureValue": "C27zo2Ks9twwhMs3WndG3c8l6y6PVM/4fUZKIxHuh50eEx2g9hIRUD0GFgxR5Mbx76E0PDePbJtrEaad+H97USmylFYw+Opp12zAkpFMMsKQgZfSG6ARyKfn/AK6qn/+5gmxAvX5qTMw29Qsbhh9w7mlyYfqZb+A5xmdxi/kV7FAa5AEK9UR8wsCb6FeHhpRPBmSGFqluFyPY0m8uYHwLILZac+KkvQz2+dVQkpt5S+C2N4POxFYQwd1mEoone4HQQtSEfnrlbxLbyCgLKM4Vf6SJnzzyufTzurSySp4kmsnTxfUKsR200TUAozwWBde9UyEjdcv/j1m/ZX18YHzwE4OwAGF6G1LCDAXcwzmH3RvjJwGsFuTz68lZJ5qcAbHvsGxymPJf2RTfLS0I7E2JWZ/yStcwtm9siHNBhusUq7lwJWWkwYw4iuoZBHNT24gj0uTbj82jB5UAIlFyIXbIbR8Di70Jg2ucvQK36XKm6/6TGntkoJ3VGa1qIhB/r0KiVTBPwPSrf8v0sHB6zc3osirpyG8Gzd+FVMs11UaGDQOIur4Bu1/StmuWdh3VFwFE9t4XaR1OEIRexYh4717B8ImfPbSTtyqsc8mE0Hvp+3wkLaD4l889Zu0BGNK9KXvT2ifKGhCg/Q8mM89oNnb4Fsl3Uu5fp8GtE3RPBt4a/g=" } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/like_page.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-2062-3ce2-7f10-2b4451595945", "type": "Like", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T21:28:31Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1453", "url": "https://pirati.ca" }, "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "diaspora:guid": "ec054ce7-1062-3ce2-7f10-2a6640956978", "diaspora:like": "{\"author\":\"heluecht@pirati.ca\",\"guid\":\"ec054ce7-1062-3ce2-7f10-2a6640956978\",\"parent_guid\":\"ec054ce7-2062-3bfa-8687-ca8313624820\",\"parent_type\":\"Post\",\"positive\":\"true\",\"author_signature\":\"F7e4x++hte0pSoUwbB6BcK0gl1c4+FjxlhwjTMvmxnB4HL58Kxk8UJ\\/SiTS5g4IoDoRcvQdIgntuHZJfKx3SsIDyWQUP1U9+RrBJh1gskcVmTT15gb2E5qq30PNM8DFV0opewp33KCVWvqqkZ2DIhjiRqqF8eUASUNkdwQci732krkMul\\/B211qBbSndxLPdrqv5Wkl0F2mZJZLIiWRoIjaFNV60lUkOwdqz8p7OuD\\/DdR\\/T8g4s7ofuvKaZ\\/scpxqixYHvv7+0cWXBlsKllW97dH1VHo7SdvJKhApk2IQz9BT8JWcBhBYjkb71olF2gajo6EQxaXt2svEOtRi+mDVQ4Lb6gk\\/Fp5aC5BVAe5Fe4Wr2GUOLmo5P6Fc6IweaoTgcqH2os2OYc\\/isJtQ2jtUtw8smY7kPSa2Qo\\/FMLWyCI8XRrWI9XGo8uSA84E+eiSVuGfqYNQNDkhzr5qCZfiF457SsxFpGa3XI53IA24Iatkj91VGSMQ8OWppK7SERwax2mVc5tn7mSq+2va0k9DiLyLoHVdEdzQcjEGCWEW7rPz7ndKBT3cW\\/jjsww3znAOko7jloXWremEtiqBIZkmHhA+Ec9UnOcShedqz\\/oIB\\/IYVUhwD3STG7EpRjh6J6G0FoU1MieCKjtpSadHN+2GN4COzNTE9iIi9Uqonf4Xio=\"}", "object": "https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820", "signature": { "type": "RsaSignature2017", "nonce": "3913cfa1f27e2e83ef770a414e477d4aac9878d1270e9056fc793fdf0a4e07d5", "creator": "https://pirati.ca/profile/heluecht#main-key", "created": "2022-03-24T21:33:58Z", "signatureValue": "uJBDZ4NIKTmcoh2ONypglOZ0El3RtgGeF5X2XSdy8V6QmLD6meUWVMQlcbs0LosSzXX1wxMJsj6QtnFPWEAneRW/gBa5N+F/+vkrnnodbO9CvDfwRIfmWqCNC5Hg4uGVukHbhCwsQkgKoRj9YjQVt0FSSrC5X/NcwiS7ZJkwsHuzv+ZZZE+GArkSQLcf4eOrK7wT1NY0fGGbjeO7+KpJH75kAGI+Yi1BuiGy7tl3dTIdGYb66j+e5RpeZR1AlueLKBB2lN2eT8DS0PwaGVjrgKQQ+riIUV8pWhrlWsoWj1u9ZELXOYUuFsjqzmNPeSU3cnxGcLSvAKmc7j2dm6ErCLFiadaodamYDR0Fqb9yd/IM3ojyxmAQRAMABCnhIppQWkOw5l1koJ2gHmnljh162sFsnifo7ccNuRHYUJjJxwsdQ9aLaAWHySqXDfRk+Hvf5G67+edlETr3XYmVtW96J0ZyQALp/I0QMbPR2Xa/b8RClms0QxNOBwVn8YswLWRZ2XgjQfxoCkRHSbY5nU6e65I4QBiAlSzHNOffNO4LYxj0GPDbEFZy9dFiv9EC9eN/FtIhkykV46PJTuM+hKneh1PLCmkkICWrj7dvPXT/00eQmvZcQTq/tNKlrbzBWQ8DnG6/3GZMyBPgsWk/E0aUwvwGlcQEwYWmzI1NWBe/XMc=" } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/undo_dislike_page.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-5762-3ce2-b3e4-87e268433367/Undo", "type": "Undo", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T21:29:23Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1453", "url": "https://pirati.ca" }, "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "object": { "id": "https://pirati.ca/objects/ec054ce7-5762-3ce2-b3e4-87e268433367", "type": "Dislike", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T21:29:23Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1453", "url": "https://pirati.ca" }, "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "diaspora:guid": "ec054ce7-1862-3ce2-b3e4-870035437794", "diaspora:like": "{\"author\":\"heluecht@pirati.ca\",\"guid\":\"ec054ce7-1862-3ce2-b3e4-870035437794\",\"parent_guid\":\"ec054ce7-2062-3bfa-8687-ca8313624820\",\"parent_type\":\"Post\",\"positive\":\"false\",\"author_signature\":\"KWp5AQ71Tn4kFgGxzgLDLQUvULKMtsb4DYwP\\/Ap9QNGStMQuKvYE2VBthRBaIvX9LmknZ3cBvuqKvNaL2Nj7B2R2Goa7\\/eWYDCogwafbp6Pj93vWvdy2+fGTkHGSxobnvgLvFIqv9IOy2Lk4QjWj7o64dUCiopR0OKjL8+vPM+l8iF+7bYeG+xSqy8SX8Fai5XOoNhy9anaJzK9ASLah8VeXKdfjGrvYsx2X\\/PaP+B8xFySP2XM95kGPKxyExi7Hk0j2igvjHqC2s3Cdg9+nwuUijnUycqGHUq3djMTLoPRjMHOJquZ1t2BNY575iRbYJTlteIgQkPHf50WALkzxn8zY5MkudBzffxm8B1Q6bnwoQHK8TR7KU2gMPwnQm6\\/ncygHuq1flVm3dqrF9xG6Cp2wC2SgTcErhrS\\/6in7FzrgBIOl570cxY0ovFICrE6rinuBdJkjfWYE3CZGCo6fVTAXUmje48c0611JmGD3PM6XQigFXGE32fjjaQoIkXf8TWI01kIqJDmZn80S6NXaYSrf9maWN1CB5gQ2E6B6Zk586sTZ2nnnJNol2KTkM1BPCTSMkrdiLtpjkUEGeo1tTe87oUzFHx++rgSO\\/lM94Dw5oN2jifGQquDBgIHY5ovxXVN3xrTgfrLEx+HWxdsiuIYpPx4lu4Qe0CVgZwPMqR4=\"}", "object": "https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820" }, "signature": { "type": "RsaSignature2017", "nonce": "1e0f4ed490473423292524d96f3d13f7fb1425599dfadd614b174ad77eb77019", "creator": "https://pirati.ca/profile/heluecht#main-key", "created": "2022-03-24T21:41:48Z", "signatureValue": "PAA2RTSvWKABq84fxlbvR1UZbIvDhoECyP5rSr5mP2nGFMo+BBhOq4Enq7SO/fiNOctILaLP4cdExyHZdYs7J64jCdfScuz6h2WZIlnKGEsR7mfDeUANboXqRbTKoyisA8vS3+BSSi4T2gjiyF3GGJLxUEcpOpD7T6G2BHQCQGbDSfue71Pygs6Z2RjCdLG1NiT6basjCKamrwxC+UYqzN3mCYLqpzBB3YD8/ql+1uqPPo3TI8CyQqq8ThEzYvXOI1eJcn0H9itD3WForGs9EQ/P39YGqrT40kx+mzhMBl16BnSO9sFHwJGd+Udi0DPrwlbCdJTuTEXJyvt6VRsyXYXe50aci+msm7MS2F+WaZZpbkxCbQNkJYfF2+yV/bDmhbvexT3avObytKGZURR+jo1UCRTwD5x4LZU/8Bvg6epsYmIXqLuuifbsrELpk+zhoHZD2drbRLWJM9KGHIK2EYtQlfvk7bDCQ/ukRns9G74JZYykqLxGhLFqd51JW2yUohmv4YoEFStXVCpInQGVQigxxO7qoCTbNiFhO7mTpd0gdx2kR4g83QJpPq6ZPaqag7z+zf3IPxA9WsZgfS66CAl6lqOK5jYkLr7JOejOU7oguHUfF6P89F2MDRoBTp6wVFL1z+rTGozyPr5mpgAAN1ambv/3ouUJdJ0Q9c6vvcE=" } } ================================================ FILE: crates/apub/apub/assets/friendica/activities/update_note.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210/Update", "type": "Update", "actor": "https://pirati.ca/profile/heluecht", "published": "2022-03-24T07:22:36Z", "instrument": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.05-dev-1453", "url": "https://pirati.ca" }, "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ], "object": { "id": "https://pirati.ca/objects/ec054ce7-4762-3c1c-3c25-0cc665717210", "type": "Note", "summary": "", "inReplyTo": "https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820", "diaspora:guid": "ec054ce7-4762-3c1c-3c25-0cc665717210", "published": "2022-03-24T07:22:36Z", "updated": "2022-03-24T21:37:34Z", "url": "https://pirati.ca/display/ec054ce7-4762-3c1c-3c25-0cc665717210", "attributedTo": "https://pirati.ca/profile/heluecht", "sensitive": false, "context": "https://pirati.ca/objects/ec054ce7-2062-3bfa-8687-ca8313624820#context", "content": "@test8 This is an edited comment.", "contentMap": { "de": "This is an edited comment." }, "source": { "content": "This is an edited comment.", "mediaType": "text/bbcode" }, "diaspora:comment": "{\"author\":\"heluecht@pirati.ca\",\"guid\":\"ec054ce7-4762-3c1c-3c25-0cc665717210\",\"created_at\":\"2022-03-24T07:22:36Z\",\"edited_at\":\"2022-03-24T07:22:36Z\",\"parent_guid\":\"ec054ce7-2062-3bfa-8687-ca8313624820\",\"text\":\"This is a comment.\",\"author_signature\":\"oqthcfSIjETYRshGeN0Zq9yGJ9+bbghdzMH4Vfl\\/kxDyNQe7tsvK6M5cQlM46h2+jmpK2Okb4mK7K6Yenh+6aH2sJKIyMUdKIINzhp9Gav31sUtHf4\\/A0x1aqqTp1oLvnc5uKdKdIGaSdODUZY\\/ABmDjin5sE1gjIBlAkAlhvdhy\\/k+4c3UCFtazjawb1oXbh94uSgu4DxseBec4Kn5laWNwLhZLdx9PMSN1mhNqz2rnF6gWAlrlaLLeRDawh2AS5t2TUPH92QY818DW9b0rF9Gz4w1PtEIkzXDd6u\\/VEMMrwmRtd8SSDgnDPFzH4HqZDf1Y4TnQixZIqgUyv9zsiNT0pg0vOXTkuQ7hJ7hj6BI92SISTtQnEVhZBmW+i22roFs87EbSb5e6Yy4+2YphjCUd2NWlyrtG1UTR1hzCN+kzKIQU34zgXTtnwvYhi6wz71Lh3w1VoQbLthxpG1t1WRsXQ\\/QZNUNInyHyIgzWTcWAS6MdzVnmXSV+1080PQ5zFWbR6Tft3YySyk8iIyhdhTAjfEDGTRGHciiPtLBPQFlHMPTiMZjEWFnBZuhDhOrA6OazONXHRO09Xr6S\\/+ZudMvEOAG1FvcBec6gWRZdcma6UBi+2M3ay3dYFJw4+fG4XZh3H\\/sCA\\/q8MUgreP3t9q\\/wzxCW\\/BXZAv4u2FvwKvw=\"}", "attachment": [], "tag": [ { "type": "Mention", "href": "https://pirati.ca/profile/test8", "name": "@test8@pirati.ca" }, { "type": "Mention", "href": "https://ds9.lemmy.ml/c/testcom", "name": "@testcom@ds9.lemmy.ml" } ], "to": ["https://pirati.ca/profile/test8", "https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ] }, "signature": { "type": "RsaSignature2017", "nonce": "534f3e33435fa56911a12094d9918002b2d734794609019793603116b6509a54", "creator": "https://pirati.ca/profile/heluecht#main-key", "created": "2022-03-24T21:37:50Z", "signatureValue": "lhLotVmAv22CmiYCSmQgQA/X38ype7o89iJIC0I2FzIWGQkvZz58YAxpmW047Z2hT2qm15sV02bPgyoOBXAdXd+M8WNwz+cNwU1cE7QNZ7102Y+tQRgpTfHz+e57QwUXESo46xAG0qSVu0UQMm+3uCUFYWKgHEmAXy89sp//3J/vJtI2+3jbaC9YWdsBe8XwcoHeelnX7f8LNniRnZIkKTLfoOhcEIHAJkEV4otSCOfzwGHN0SqbGlK9xWBPQhgtN3GvnOZU9zhNQMsQiX+9Wb2X4NLXs0tTRkDubF78stH+0xbep7ZfyvRNLoebPtecN8dMnfHQs8y0a9iG9tuNjcwht2ezIwf1h140+iB8znav35sA6LzgcfEyzU8O6JYF9p9x3tCw2rcMMiy/f7mvOLCP/05d0GEoUNZXrfuXf/osysMYp1Y05Lkze5WqTMu7sEW6jDx8r1NTE6s88wqZAJa56G5NVxG5vU3Cj7yscI6LQiqaUDilVHLa+DzR0pQEUSSh2J1PgBFT2KKPZIY22UinDgI1QNl+Dhfj2nPzf/xXssuDTvWyU8vJzXc2MZNqFz7ds7tNdea6laLiMl7nOnMo1wz1f+w1bn4Y1YR/iwFgaqo2WEt+cIzAaN4dUw00WlXPCNNhrgaXHxlXI6SeFtviUfwz/dehcfbxMTxX7e0=" } } ================================================ FILE: crates/apub/apub/assets/friendica/objects/note_1.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://soc.schuerz.at/objects/4edd2508-4361-edb8-c4d8-b45181083984", "type": "Note", "summary": "", "inReplyTo": "https://lemmy.schuerz.at/post/25360", "diaspora:guid": "4edd2508-4361-edb8-c4d8-b45181083984", "published": "2022-01-23T20:21:24Z", "url": "https://soc.schuerz.at/display/4edd2508-4361-edb8-c4d8-b45181083984", "attributedTo": "https://soc.schuerz.at/profile/jakob", "sensitive": false, "context": "https://lemmy.schuerz.at/post/25360#context", "content": "@jakob test", "contentMap": { "de": "@jakob test" }, "source": { "content": "@[url=https://lemmy.schuerz.at/u/jakob]Jakob[/url] test", "mediaType": "text/bbcode" }, "diaspora:comment": "{\"author\":\"jakob@soc.schuerz.at\",\"guid\":\"4edd2508-4361-edb8-c4d8-b45181083984\",\"created_at\":\"2022-01-23T20:21:24Z\",\"edited_at\":\"2022-01-23T20:21:24Z\",\"parent_guid\":\"ea620d1e-742c8b4d15249a9b-18b5fca3\",\"text\":\"@{Jakob; jakob@lemmy.schuerz.at} test\",\"author_signature\":\"JNCqOui5Cg8\\/Uxw+f0NtGCRjRnhPOrqE6kGJnMkZvOOKhlCdZbCqvyPlNJzEYDa3Z30mOWQKTTNo5BVI+VVZtGrVEqFOdzNog7jOLQoY1dKU9iEQ9vc8USwUCkyJyv48w1iXpfea87KPwv+03DMlftmD6kC7jdUVwhc7+jm0g4fh06tpOcCMQJOZqTTV\\/80EjxIJQ+8eEk5evSw\\/S98ohD1ahcwSomJ9hJUV1H48ucDvMod1FCLcN5h4ALHqubCu4TZIYhGhw9zoCl52GeHhrD3\\/vL6OW4ftZ7UG4rEKQ4HowuXqmNwydrQldtprRtu2UrZBjLqVusPXEs\\/xERQqZnalNXHijyd1TwwCmfTV4YjKwH4BhX\\/p4hdWMqEP4yYXlfA4apalVeAaYZLrNR58kPJjBHad\\/yqH30ziBFheqZ5odFh\\/jnKB4OCFVST3u9b1OKE0jyTrbTepPTaONwc8giQH1sM8koj1gFdulwuJuOTRUKR\\/8ishgHi5SWwbp5YG5Z3YSINkF10IcLiFZAF300AvwgOCdf7ferim4i\\/7TR1D2CBpoNUZnKCKZRymZbE0GuKEE+A6Pk3lk\\/DCsDtmMXpnxlPZ8Nq8OZS\\/olXevAu1y57MNnxBDXtojr4F54MP2fO7E2JwBr7AlwoeSEvtZSAO\\/elzrKfW0eVWOUM2OnI=\"}", "attachment": [], "tag": [ { "type": "Mention", "href": "https://lemmy.schuerz.at/u/jakob", "name": "@jakob@lemmy.schuerz.at" } ], "to": [ "https://lemmy.schuerz.at/u/jakob", "https://www.w3.org/ns/activitystreams#Public", "https://lemmy.schuerz.at/c/test" ], "cc": ["https://soc.schuerz.at/followers/jakob"] } ================================================ FILE: crates/apub/apub/assets/friendica/objects/note_2.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://nerdica.net/objects/a85d7459-7262-66e9-f901-f05552414769", "type": "Note", "summary": "", "inReplyTo": "https://lemmy.ml/comment/167904", "diaspora:guid": "a85d7459-7262-66e9-f901-f05552414769", "published": "2022-04-25T18:35:37Z", "url": "https://nerdica.net/display/a85d7459-7262-66e9-f901-f05552414769", "attributedTo": "https://nerdica.net/profile/liwott", "sensitive": false, "context": "https://lemmy.ml/post/243881#context", "content": "Note that on #Friendica we canquote-share, and we can also do it in comments. As I discovered recently by playing in the below post
@Liwott@lemmy.ml:

Do your commenting tests here.


While posting tests can obsviously be made on this community without further precision, commenting requires a post to comment on. This is what this post is for.
\nthese make it through to #Lemmy when we do it in a comment, but not in a top-level post (which is already a great start !). So, in Friendica, all that's missing is the backlink !
Also the Linked Data nature of the underlying data would make it possible to create all different kinds of associations, not just a plain cross-ref link.
This seems interesting, but I must say I don't directly see an application of this in the context of microblogging/commenting. Maybe you can inspire us here? 😀", "contentMap": { "en": "Note that on #Friendica we can quote-share, and we can also do it in comments. As I discovered recently by playing in the below post
\n
\n\t
\n\t\t\t\t\t\n\t\t\t\t\"\"\n\t\t\t\n\t\t\t\t
\n\t\t\t

\n\t\t\t\t\n\t\t\t\t\tLiwott\n\t\t\t\t\n\t\t\t

\n\t\t\t

\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t2022-04-23 14:34:19\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t
\n\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t
\n\t
\n\t

Do your commenting tests here.


While posting tests can obsviously be made on this community without further precision, commenting requires a post to comment on. This is what this post is for.
\n
\nthese make it through to #Lemmy when we do it in a comment, but not in a top-level post (which is already a great start !). So, in Friendica, all that's missing is the backlink !
Also the Linked Data nature of the underlying data would make it possible to createall different kinds of associations, not just a plain cross-ref link.
This seems interesting, but I must say I don't directly see an application of this in the context of microblogging/commenting. Maybe you can inspire us here? 😀" }, "source": { "content": "Note that on #[url=https://nerdica.net/search?tag=Friendica]Friendica[/url] we can quote-share, and we can also do it in comments. As I discovered recently by playing in the below post\n[share author='Liwott' profile='https://lemmy.ml/u/Liwott' avatar='' link='https://lemmy.ml/post/241819' posted='2022-04-23 14:34:19' guid='44b525e5-4101b003e005e70a-d472d963'][h3]Do your commenting tests here.[/h3]\nWhile posting tests can obsviously bemade on this community without further precision, commenting requires a post to comment on. This is what this post is for.[/share]\nthese make it through to #[url=https://nerdica.net/search?tag=Lemmy]Lemmy[/url] when we do it in a comment, but not in a top-level post (which is already a great start !).So, in Friendica, all that's missing is the backlink !\n[quote]Also the Linked Data nature of the underlying data would make it possible to create all different kinds of associations, not just a plain cross-ref link.[/quote]\nThis seems interesting, but I must say I don't directly see an application ofthis in the context of microblogging/commenting. Maybe you can inspire us here? :)", "mediaType": "text/bbcode" }, "diaspora:comment": "{\"author\":\"liwott@nerdica.net\",\"guid\":\"a85d7459-7262-66e9-f901-f05552414769\",\"created_at\":\"2022-04-25T18:35:37Z\",\"edited_at\":\"2022-04-25T18:35:37Z\",\"parent_guid\":\"44b525e5-532e8d03ea8f5dff-c45ad734\",\"text\":\"Note that on #Friendica we can quote-share, and we can also do it in comments. As I discovered recently by playing in the below post\\n\\n- - - - - -\\n\\n**\\u2672 [Liwott](https:\\/\\/lemmy.ml\\/u\\/Liwott)** - [2022-04-23 14:34:19 GMT](https:\\/\\/lemmy.ml\\/post\\/241819)\\n\\n> ### Do your commenting tests here.\\n> \\n> \\n> While posting tests can obsviously be made on this community without further precision, commenting requires a post to comment on. This is what this post is for.\\n\\nthese make it through to #Lemmy when we do it in a comment, but not in a top-level post (which is already a great start !). So, in Friendica, all that's missingis the backlink !> Also the Linked Data nature of the underlying data would make it possible to create all different kinds of associations, not just a plain cross-ref link.\\n\\nThis seems interesting, but I must say I don't directly see an application of this in the context of microblogging\\/commenting. Maybe you can inspire us here? \\ud83d\\ude00\",\"author_signature\":\"Ho9NYtWzEkREWyvyjUnUOuYvPBI35I4SGAb+cXBMp\\/n2Tu5gJipmKuIcMpyrxYNtIqXRwr\\/BUOGkd99s5\\/uBWCcL8jCbx3i4wTVYzdgPAZaykd7EqdwULNRTtf8eKL2Wvdo7tYtYm\\/Yo5dajM5HI2NuOgQR8CgLInmEmBlKLZ8EkzAC+z2EwMhx7JBmKzeEabAmclJgR8IfYWX34KPYqBFcZ9w8V\\/D3lcPGs3olJcvwqHSnY7vgL1X9f2XVAQ38pmGg2ggaKhKa5QligOhkPC57NYPh\\/1SR9Plpyf0QPQRCuCs5vkEloe47rxaWZ62gfKqul0dXmGchIcIYhms4DN7DaGapOXaQPuIfh4FIvEb9qh9mJ7haHa9+0uD9TUToG+wilifdtGwZoZnF9zMfGSLiaDlD\\/UZHA1jXMa3uhfGE+MUT1dnJcZqfE+jwJUb4BPuYxTm7UClvERg8sfDFWqlMaNpPtJlay2PL\\/nwCxuQ54M5v6lgyb8NylIrjFyUttiBnNC6HYsy4YoPnN7r\\/0EV3Av1KtnJt84xrJbDo4fvR1TPs4Hmx5BoH1cvHCH2Tld2OgKUCHd5g9Pr3RVPEGGillZSqDWCP6317BQ0EDftTwABjPXoitQX2ZaHXXqXLWCRoLk6MsEWM0jsoGzv+GP4coZWreCD1XRI5an1W4998=\",\"thread_parent_guid\":\"44b525e5-cb6ed8649810a557-1ccd7106\"}", "attachment": [], "tag": [ { "type": "Hashtag", "href": "https://nerdica.net/search?tag=Friendica", "name": "#Friendica" }, { "type": "Hashtag", "href": "https://nerdica.net/search?tag=Lemmy", "name": "#Lemmy" }, { "type": "Mention", "href": "https://lemmy.ml/u/Liwott", "name": "@Liwott@lemmy.ml" } ], "to": [ "https://lemmy.ml/u/humanetech", "https://www.w3.org/ns/activitystreams#Public", "https://lemmy.ml/c/fediversefutures" ], "cc": [ "https://lemmy.ml/u/Liwott", "https://nerdica.net/followers/liwott", "https://lemmy.ml/u/poVoq", "https://mastodon.social/users/humanetech", "https://lemmy.ml/u/KelsonV" ] } ================================================ FILE: crates/apub/apub/assets/friendica/objects/page_1.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-8062-3c1b-016c-910426317080", "type": "Page", "summary": "", "inReplyTo": null, "diaspora:guid": "ec054ce7-8062-3c1b-016c-910426317080", "published": "2022-03-24T07:17:21Z", "url": "https://www.nasaspaceflight.com/2022/03/us-eva-80/", "attributedTo": "https://pirati.ca/profile/heluecht", "sensitive": false, "context": "https://pirati.ca/objects/ec054ce7-8062-3c1b-016c-910426317080#context", "name": "ISS astronauts perform final spacewalk of Expedition 66", "content": "Expedition 66 astronauts Raja Chari and Matthias Maurer ventured outside the International Space Station on Wednesday, performing a spacewalk to carry out repairs and upgrades on the space station.", "contentMap": { "de": "!testcom Expedition 66 astronauts RajaChari and Matthias Maurer ventured outside the International Space Station on Wednesday, performing a spacewalk to carry out repairs and upgrades on the space station.
ISS astronauts perform final spacewalk of Expedition 66" }, "source": { "content": "![url=https://ds9.lemmy.ml/c/testcom]testcom[/url] Expedition 66 astronauts Raja Chari and Matthias Maurer ventured outside the International Space Station on Wednesday, performing a spacewalk to carry out repairs and upgrades on the space station.\n[attachment type='link' url='https://www.nasaspaceflight.com/2022/03/us-eva-80/' title='ISS astronauts perform final spacewalk of Expedition 66' publisher_name='NASASpaceFlight.com' publisher_url='https://www.nasaspaceflight.com/' publisher_img='https://www.nasaspaceflight.com/wp-content/uploads/2017/12/logo.svg' author_name='Justin Davenport' author_url='https://www.nasaspaceflight.com/author/justin/' author_img='https://secure.gravatar.com/avatar/5dc0dc04b38dbb016bf6f15552555883?s=96&d=mm&r=g' image='https://www.nasaspaceflight.com/wp-content/uploads/2022/03/51941297402_fa7a00c1ee_o-scaled.jpg']Expedition 66 astronauts Raja Chari and Matthias Maurer ventured outside the International Space Station on…[/attachment]", "mediaType": "text/bbcode" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://ds9.lemmy.ml/c/testcom", "name": "@testcom@ds9.lemmy.ml" } ], "to": ["https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ] } ================================================ FILE: crates/apub/apub/assets/friendica/objects/page_2.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850", "type": "Article", "summary": "", "inReplyTo": null, "diaspora:guid": "ec054ce7-5162-3bf2-504c-16d024994850", "published": "2022-03-24T04:23:44Z", "url": "https://pirati.ca/display/ec054ce7-5162-3bf2-504c-16d024994850", "attributedTo": "https://pirati.ca/profile/heluecht", "sensitive": false, "context": "https://pirati.ca/objects/ec054ce7-5162-3bf2-504c-16d024994850#context", "name": "From Friendica to Lemmy", "content": "Hello Lemmy!", "contentMap": { "de": "!testcom Hello Lemmy!" }, "source": { "content": "![url=https://ds9.lemmy.ml/c/testcom]testcom[/url] Hello Lemmy!", "mediaType": "text/bbcode" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://ds9.lemmy.ml/c/testcom", "name": "@testcom@ds9.lemmy.ml" } ], "to": ["https://ds9.lemmy.ml/c/testcom"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://ds9.lemmy.ml/c/testcom/followers" ] } ================================================ FILE: crates/apub/apub/assets/friendica/objects/person_1.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://soc.schuerz.at/profile/jakob", "diaspora:guid": "4edd2508-1661-30f6-ebcc-2da966353356", "type": "Person", "following": "https://soc.schuerz.at/following/jakob", "followers": "https://soc.schuerz.at/followers/jakob", "inbox": "https://soc.schuerz.at/inbox/jakob", "outbox": "https://soc.schuerz.at/outbox/jakob", "preferredUsername": "jakob", "name": "Jakob :friendica:", "vcard:hasAddress": { "@type": "vcard:Home", "vcard:country-name": "Austria", "vcard:region": "Niederoesterreich", "vcard:locality": "" }, "summary": "Linux, FOSS, Öffentlicher Verkehr, Eisenbahn, Radfahren, Fußgehen, Verkehrsplanung, Städtebau, Will das Schöne wieder in die Welt bringen,Nachhaltigkeit, Modellbahn, Java Entwickler (jun), Bash,

#FediverseOnlyAccount", "vcard:hasInstantMessage": [ "xmpp:jakob@schuerz.at", "matrix:@jakob:schuerz.at" ], "url": "https://soc.schuerz.at/profile/jakob", "manuallyApprovesFollowers": true, "discoverable": true, "publicKey": { "id": "https://soc.schuerz.at/profile/jakob#main-key", "owner": "https://soc.schuerz.at/profile/jakob", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1RRoj3DpUmTiRBshv+kz\njO5tgfHs99aBJjvaoW8nbPcOs+HZm9Nj4ncJh99kwd+yONwac6ObMMIisYpVU4C1\neKpnlRrRu/8vQFwhHQT4RxpkibB+l+LvG1HJoMNIuYxvVCIaQZugdJclAdMJjDTF\nbDQNwG6xlcazKd4IbMbmgfoxTxSnQSomJQew1NUbdD3vDiCdJEtjCmeWm6eTCfyZ\njT0mjrAm8ccJ7+opN5SWJ0je0Rav5dohyaVFEtv1Dlv1UlqU4hKefvv71eoROHCA\nWQ3+kYGFGY4ApnbWxwLZyke7khzxr2BjDrfwUAeEsLJT4YOxa5fKJJ59+q5Iddaq\nPNT3QqP0Qzum5w6qDOWm3cNNw7ByqoqxKckZS5U2vm0sx83UEmBqysAkAS/8M9Qr\nBKkb9DQ9jgUa7GPpL+Oknr8hV+Vpk49Jjx+A1WJ/MlNja7fi4w4rBM+v3B8nRayM\nzX8XaKbbOib21mCawJiJIOAm0EP2rNqNM1GpUWPstHKG00o3Czz3P5Hm/q6RcNJE\nKRlSIPQZnUVsoC0bFsqWzipsgb3uDHnz3Ni2OjLNLWBVYkWD7RNfB3WV/XKl2QL3\nnnhmUDahGN7UCOrcBuLfWsTa+GZDFeHot1HXa9tNcxq+QxAUg3qv7oiAH1H+hoJg\nn/Ydg1IR5sLovKi3g7DRS7MCAwEAAQ==\n-----END PUBLIC KEY-----\n" }, "endpoints": { "sharedInbox": "https://soc.schuerz.at/inbox" }, "icon": { "type": "Image", "url": "https://soc.schuerz.at/photo/profile/jakob.png?ts=1630598950", "mediaType": "image/png" }, "attachment": [ { "type": "PropertyValue", "name": "Mobilizon", "value": "@jakob@events.schuerz.at
@jakob@events.tulln.social" }, { "type": "PropertyValue", "name": "Lemmy", "value": "https://lemmy.schuerz.at/u/jakob" }, { "type": "PropertyValue", "name": "Funkwhale", "value": "https://radio.schuerz.at/@jakob/" }, { "type": "PropertyValue", "name": "Peertube", "value": "https://kino.schuerz.at/a/jakob" }, { "type": "PropertyValue", "name": "Pixelfed", "value": "https://japix.schuerz.at/jakob" }, { "type": "PropertyValue", "name": "about:", "value": "This is an OpenPGP proof that connects my OpenPGP key to this Peertube account. For details check out https://keyoxide.org/guides/openpgp-proofs

[Verifying my OpenPGP key: openpgp4fpr:FED82F1C73FF53FB1EE9926336615E0FD12833CF]" } ], "generator": { "type": "Service", "name": "Friendica 'Siberian Iris' 2021.12-rc-1448", "url": "https://soc.schuerz.at" } } ================================================ FILE: crates/apub/apub/assets/friendica/objects/person_2.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "vcard": "http://www.w3.org/2006/vcard/ns#", "dfrn": "http://purl.org/macgirvin/dfrn/1.0/", "diaspora": "https://diasporafoundation.org/ns/", "litepub": "http://litepub.social/ns#", "toot": "http://joinmastodon.org/ns#", "schema": "http://schema.org#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "sensitive": "as:sensitive", "Hashtag": "as:Hashtag", "directMessage": "litepub:directMessage", "discoverable": "toot:discoverable", "PropertyValue": "schema:PropertyValue", "value": "schema:value" } ], "id": "https://poliverso.org/profile/informapirata", "diaspora:guid": "0477a01e-8161-2935-9a73-393807834700", "type": "Organization", "following": "https://poliverso.org/following/informapirata", "followers": "https://poliverso.org/followers/informapirata", "inbox": "https://poliverso.org/inbox/informapirata", "outbox": "https://poliverso.org/outbox/informapirata", "preferredUsername": "informapirata", "name": "Informa Pirata", "vcard:hasAddress": { "@type": "vcard:Home", "vcard:country-name": "Italy", "vcard:region": "Lazio", "vcard:locality": "" }, "summary": "Politica Pirata: informazione su #whistleblowing #dirittidigitali #sovranitàdigitale #copyright #privacy #cyberwarfare #pirati #Europa #opensource #opendata
➡️➡️ http://T.ME/PPINFORMA", "url": "https://poliverso.org/profile/informapirata", "manuallyApprovesFollowers": false, "discoverable": true, "publicKey": { "id": "https://poliverso.org/profile/informapirata#main-key", "owner": "https://poliverso.org/profile/informapirata", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA09bPyftct7KNf4hm+rmG\n1aX4HmJbAdiXkmxo3g7iJG21Vvd3+OQeKnjpLc0n9s9rKMrpy8FQj+E7FaVYIGcP\n/f8McmSb4ezFeNkVNKGm+Y7swpeAAmh0MBWfDD+j3WHznD+OLABhWZlnfhIxW1aD\nD6mN9mkITvLAut8vJVTaciGzjfv6AndHVerVPV8lw5gXCmvX/+NZUOjjLQVND3fL\n8fZiJjJ3NSQ1tAx0m38PVMHZGw2492gkbKxzkW6c/QyMRAOrKP2+kJQ/6O2sn/ZK\n7MtHzMQ4eUjGc0ZLWQlCqQ4oVbVTcPgwHW1+no3928fzhU95zi5oAI08wfJ5wo86\nAnPv4fnUL/gyGff/ytZ/kGhNv+jVlSbMYxiDslRoD2Zp+L1P5Ypw6iemR1rMivL4\nJMxx2FoYGD1xzKBqNcJ2cDRQ5VQGwhBs/U6XyRMrRTzhDoe5dHr49MjHGuYkUzhq\naYPgku+zA7hfjvZA982kK2jAMXPoTLoUrY7T6beanYwfFIxd++fNHxTSexrhwx7P\nqn7v+pi0WTA8Cxor4N+ICCXxVvpO7s5VERVugiJofKZhFXiE2S02S2jVoGCRtEKw\n9/iignMld/IQSojz8N+77KMYGuVT9eG9Io/mF4MjCLluNNRXklt55dz55vOHPBxg\nll83LwyA3eELfylUNV75DcsCAwEAAQ==\n-----END PUBLIC KEY-----\n" }, "endpoints": { "sharedInbox": "https://poliverso.org/inbox" }, "icon": { "type": "Image", "url": "https://poliverso.org/photo/profile/informapirata.png?ts=1630486460", "mediaType": "image/png" }, "attachment": [ { "type": "PropertyValue", "name": "Telegram", "value": "http://T.ME/PPINFORMA" }, { "type": "PropertyValue", "name": "Mastodon", "value": "https://mastodon.uno/@informapirata" } ], "generator": { "type": "Service", "name": "Friendica 'Siberian Iris' 2022.03-1452", "url": "https://poliverso.org" } } ================================================ FILE: crates/apub/apub/assets/gnusocial/activities/create_note.json ================================================ { "type": "Create", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "gs": "https://www.gnu.org/software/social/ns#" }, { "litepub": "http://litepub.social/ns#" }, { "chatMessage": "litepub:chatMessage" }, { "inConversation": { "@id": "gs:inConversation", "@type": "@id" } } ], "id": "https://instance.gnusocial.test/activity/1339", "published": "2022-03-01T20:58:48+00:00", "actor": "https://instance.gnusocial.test/actor/42", "object": { "type": "Note", "id": "https://instance.gnusocial.test/object/note/1339", "published": "2022-03-01T21:00:16+00:00", "attributedTo": "https://instance.gnusocial.test/actor/42", "content": "

yay ^^

", "mediaType": "text/html", "source": { "content": "yay ^^", "mediaType": "text/plain" }, "attachment": [], "tag": [], "inReplyTo": "https://instance.gnusocial.test/object/note/1338", "inConversation": "https://instance.gnusocial.test/conversation/1338", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/42/subscribers"] }, "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/42/subscribers"] } ================================================ FILE: crates/apub/apub/assets/gnusocial/activities/create_page.json ================================================ { "type": "Create", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "gs": "https://www.gnu.org/software/social/ns#" }, { "litepub": "http://litepub.social/ns#" }, { "chatMessage": "litepub:chatMessage" }, { "inConversation": { "@id": "gs:inConversation", "@type": "@id" } } ], "id": "https://instance.gnusocial.test/activity/1338", "published": "2022-03-17T23:30:26+00:00", "actor": "https://instance.gnusocial.test/actor/42", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/21"], "object": { "type": "Page", "id": "https://instance.gnusocial.test/object/note/1338", "published": "2022-03-17T23:30:26+00:00", "attributedTo": "https://instance.gnusocial.test/actor/42", "name": "hello, world.", "content": "

This is an interesting page.

", "mediaType": "text/html", "source": { "content": "This is an interesting page.", "mediaType": "text/markdown" }, "attachment": [], "tag": [], "inConversation": "https://instance.gnusocial.test/conversation/1338", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/21"] } } ================================================ FILE: crates/apub/apub/assets/gnusocial/activities/like_note.json ================================================ { "type": "Like", "@context": ["https://www.w3.org/ns/activitystreams"], "id": "https://another_instance.gnusocial.test/activity/41362", "published": "2022-03-20T17:54:15+00:00", "actor": "https://another_instance.gnusocial.test/actor/43", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/42"], "object": "https://instance.gnusocial.test/object/note/1337" } ================================================ FILE: crates/apub/apub/assets/gnusocial/objects/group.json ================================================ { "type": "Group", "streams": [], "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "gs": "https://www.gnu.org/software/social/ns#" }, { "litepub": "http://litepub.social/ns#" }, { "chatMessage": "litepub:chatMessage" }, { "inConversation": { "@id": "gs:inConversation", "@type": "@id" } } ], "id": "https://instance.gnusocial.test/actor/21", "inbox": "https://instance.gnusocial.test/actor/21/inbox.json", "outbox": "https://instance.gnusocial.test/actor/21/outbox.json", "following": "https://instance.gnusocial.test/actor/21/subscriptions", "followers": "https://instance.gnusocial.test/actor/21/subscribers", "liked": "https://instance.gnusocial.test/actor/21/favourites", "preferredUsername": "hackers", "publicKey": { "id": "https://instance.gnusocial.test/actor/2#public-key", "owner": "https://instance.gnusocial.test/actor/2", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoZyKL+GyJbTV/ilVBlzz\n8OL/UwNi3KpfV5kQwXU0pPcBbw6y2JOfWnKUT1CfiHG3ntiOFnc+wQfHZk4hRSE8\n9Xe/G5Y215xW+gqx/kjt2GOENqzSzYXdEZ5Qsx6yumZD/yb6VZK9Og0HjX2mpRs9\nbactY76w4BQVntjZ17gSkMhYcyPFZTAIe7QDkeSPk5lkXfTwtaB3YcJSbQ3+s7La\npeEgukQDkrLUIP6cxayKrgUl4fhHdpx1Yk4Bzd/1XkZCjeBca94lP1p2M12amI+Z\nOLSTuLyEiCcku8aN+Ms9plwATmIDaGvKFVk0YVtBHdIJlYXV0yIscab3bqyhsLBK\njwIDAQAB\n-----END PUBLIC KEY-----\n" }, "name": "Hackers!", "published": "2022-02-23T21:54:52+00:00", "updated": "2022-02-23T21:55:16+00:00", "url": "https://instance.gnusocial.test/!hackers", "endpoints": { "sharedInbox": "https://instance.gnusocial.test/inbox.json" } } ================================================ FILE: crates/apub/apub/assets/gnusocial/objects/note.json ================================================ { "type": "Note", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "gs": "https://www.gnu.org/software/social/ns#" }, { "litepub": "http://litepub.social/ns#" }, { "chatMessage": "litepub:chatMessage" }, { "inConversation": { "@id": "gs:inConversation", "@type": "@id" } }, { "@language": "en" } ], "id": "https://instance.gnusocial.test/object/note/1339", "published": "2022-03-01T21:00:16+00:00", "attributedTo": "https://instance.gnusocial.test/actor/42", "content": "

yay ^^

", "mediaType": "text/html", "source": { "content": "yay ^^", "mediaType": "text/plain" }, "attachment": [], "tag": [], "inReplyTo": "https://instance.gnusocial.test/object/note/1338", "inConversation": "https://instance.gnusocial.test/conversation/1338", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/42/subscribers"] } ================================================ FILE: crates/apub/apub/assets/gnusocial/objects/page.json ================================================ { "type": "Page", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "gs": "https://www.gnu.org/software/social/ns#" }, { "litepub": "http://litepub.social/ns#" }, { "chatMessage": "litepub:chatMessage" }, { "inConversation": { "@id": "gs:inConversation", "@type": "@id" } } ], "id": "https://instance.gnusocial.test/object/note/1338", "published": "2022-03-17T23:30:26+00:00", "attributedTo": "https://instance.gnusocial.test/actor/42", "name": "hello, world.", "content": "

This is an interesting page.

", "mediaType": "text/html", "source": { "content": "This is an interesting page.", "mediaType": "text/markdown" }, "attachment": [], "tag": [], "inConversation": "https://instance.gnusocial.test/conversation/1338", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://instance.gnusocial.test/actor/21"] } ================================================ FILE: crates/apub/apub/assets/gnusocial/objects/person.json ================================================ { "type": "Person", "streams": [], "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "gs": "https://www.gnu.org/software/social/ns#" }, { "litepub": "http://litepub.social/ns#" }, { "chatMessage": "litepub:chatMessage" }, { "inConversation": { "@id": "gs:inConversation", "@type": "@id" } } ], "id": "https://instance.gnusocial.test/actor/42", "inbox": "https://instance.gnusocial.test/actor/42/inbox.json", "outbox": "https://instance.gnusocial.test/actor/42/outbox.json", "following": "https://instance.gnusocial.test/actor/42/subscriptions", "followers": "https://instance.gnusocial.test/actor/42/subscribers", "liked": "https://instance.gnusocial.test/actor/42/favourites", "preferredUsername": "diogo", "publicKey": { "id": "https://instance.gnusocial.test/actor/42#public-key", "owner": "https://instance.gnusocial.test/actor/42", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArBB+3ldwA2qC1hQTtIho\n9KYhvvMlPdydn8dA6OlyIQ3Jy57ADt2e144jDSY5RQ3esmzWm2QqsI8rAsZsAraO\nl2+855y7Fw35WH4GBc7PJ6MLAEvMk1YWeS/rttXaDzh2i4n/AXkMuxDjS1IBqw2w\nn0qTz2sdGcBJ+mop6AB9Qt2lseBc5IW040jSnfLEDDIaYgoc5m2yRsjGKItOh3BG\njGHDb6JB9FySToSMGIt0/tE5k06wfvAxtkxX5dfGeKtciBpC2MGT169iyMIOM8DN\nFhSl8mowtV1NJQ7nN692USrmNvSJjqe9ugPCDPPvwQ5A6A61Qrgpz5pav/o5Sz69\nzQIDAQAB\n-----END PUBLIC KEY-----\n" }, "name": "Diogo Peralta Cordeiro", "published": "2022-02-23T17:20:30+00:00", "updated": "2022-02-25T02:12:48+00:00", "url": "https://instance.gnusocial.test/@diogo", "endpoints": { "sharedInbox": "https://instance.gnusocial.test/inbox.json" } } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/block/block_user.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/u/lemmy_alpha", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "http://enterprise.lemmy.ml/u/main", "target": "http://enterprise.lemmy.ml/c/main", "type": "Block", "removeData": true, "summary": "spam post", "endTime": "2021-11-01T12:23:50.151874Z", "id": "http://enterprise.lemmy.ml/activities/block/5d42fffb-0903-4625-86d4-0b39bb344fc2" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/block/undo_block_user.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/u/lemmy_alpha", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "http://enterprise.lemmy.ml/u/main", "target": "http://enterprise.lemmy.ml/c/main", "type": "Block", "removeData": true, "summary": "spam post", "endTime": "2021-11-01T12:23:50.151874Z", "id": "http://enterprise.lemmy.ml/activities/block/726f43ab-bd0e-4ab3-89c8-627e976f553c" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "http://enterprise.lemmy.ml/u/main", "type": "Undo", "id": "http://enterprise.lemmy.ml/activities/undo/06a20ffb-3e32-42fb-8f4c-674b36d7c557" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/add_featured_post.json ================================================ { "cc": ["https://ds9.lemmy.ml/c/main"], "id": "https://ds9.lemmy.ml/activities/add/47d911f5-52c5-4659-b2fd-0e58c451a427", "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Add", "actor": "https://ds9.lemmy.ml/u/lemmy_alpha", "object": "https://ds9.lemmy.ml/post/2", "target": "https://ds9.lemmy.ml/c/main/featured", "audience": "http://enterprise.lemmy.ml/u/main" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/add_mod.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/u/lemmy_alpha", "target": "http://enterprise.lemmy.ml/c/main/moderators", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "http://enterprise.lemmy.ml/u/main", "type": "Add", "id": "http://enterprise.lemmy.ml/activities/add/ec069147-77c3-447f-88c8-0ef1df10403f" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/announce_create_page.json ================================================ { "actor": "http://enterprise.lemmy.ml/c/main", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "type": "Page", "id": "http://enterprise.lemmy.ml/post/7", "attributedTo": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": [ "http://enterprise.lemmy.ml/c/main", "https://www.w3.org/ns/activitystreams#Public" ], "name": "post 4", "mediaType": "text/html", "commentsEnabled": true, "sensitive": false, "stickied": false, "published": "2021-11-01T12:11:22.871846Z", "audience": "http://enterprise.lemmy.ml/u/main" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "type": "Create", "id": "http://enterprise.lemmy.ml/activities/create/2807c9ec-3ad8-4859-a9e0-28b59b6e499f" }, "cc": ["http://enterprise.lemmy.ml/c/main/followers"], "type": "Announce", "id": "http://enterprise.lemmy.ml/activities/announce/8030b171-803a-4108-94b1-342688f375cf" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/lock_note.json ================================================ { "actor": "https://lemmy-alpha/u/lemmy_aplha", "to": [ "https://lemmy-alpha/c/test", "https://www.w3.org/ns/activitystreams#Public" ], "object": "https://lemmy-alpha/comment/1", "cc": ["https://lemmy-alpha/c/test"], "type": "Lock", "id": "https://lemmy-alpha/activities/lock/ae02478a-c7fa-4cc9-9838-eae131d3e9fa", "summary": "A reason for a lock", "audience": "http://lemmy-alpha/c/main" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/lock_page.json ================================================ { "id": "http://lemmy-alpha:8541/activities/lock/cb48761d-9e8c-42ce-aacb-b4bbe6408db2", "actor": "http://lemmy-alpha:8541/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://lemmy-alpha:8541/post/2", "cc": ["http://lemmy-alpha:8541/c/main"], "type": "Lock", "summary": "A reason for the lock", "audience": "http://lemmy-alpha:8541/c/main" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/remove_featured_post.json ================================================ { "cc": ["https://ds9.lemmy.ml/c/main"], "id": "https://ds9.lemmy.ml/activities/add/47d911f5-52c5-4659-b2fd-0e58c451a427", "to": ["https://www.w3.org/ns/activitystreams#Public"], "type": "Remove", "actor": "https://ds9.lemmy.ml/u/lemmy_alpha", "object": "https://ds9.lemmy.ml/post/2", "target": "https://ds9.lemmy.ml/c/main/featured", "audience": "https://ds9.lemmy.ml/c/main" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/remove_mod.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/u/lemmy_alpha", "cc": ["http://enterprise.lemmy.ml/c/main"], "type": "Remove", "target": "http://enterprise.lemmy.ml/c/main/moderators", "audience": "http://enterprise.lemmy.ml/u/main", "id": "http://enterprise.lemmy.ml/activities/remove/aab114f8-cfbd-4935-a5b7-e1a64603650d" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/report_page.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["http://enterprise.lemmy.ml/c/main"], "audience": "http://enterprise.lemmy.ml/u/main", "object": "http://enterprise.lemmy.ml/post/7", "summary": "report this post", "type": "Flag", "id": "http://ds9.lemmy.ml/activities/flag/98b0933f-5e45-4a95-a15f-e0dc86361ba4" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/resolve_report_page.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_user", "to": ["http://enterprise.lemmy.ml/c/main"], "type": "Resolve", "id": "http://ds9.lemmy.ml/activities/flag/4323412-5e45-4a95-a15f-e0dc86361ba4", "audience": "http://ds9.lemmy.ml/u/main", "object": { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["http://enterprise.lemmy.ml/c/main"], "object": "http://enterprise.lemmy.ml/post/7", "summary": "report this post", "type": "Flag", "audience": "http://ds9.lemmy.ml/u/main", "id": "http://ds9.lemmy.ml/activities/flag/98b0933f-5e45-4a95-a15f-e0dc86361ba4" } } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/undo_lock_note.json ================================================ { "id": "https://lemmy-alpha/activities/undo/8c0a65ff-eea6-47cf-9025-6b94a86252ff", "actor": "https://lemmy-alpha/u/lemmy_aplha", "to": [ "https://lemmy-alpha/c/test", "https://www.w3.org/ns/activitystreams#Public" ], "object": { "actor": "https://lemmy-alpha/u/lemmy_aplha", "to": [ "https://lemmy-alpha/c/test", "https://www.w3.org/ns/activitystreams#Public" ], "object": "https://lemmy-alpha/comment/1", "cc": ["https://lemmy-alpha/c/test"], "audience": "http://lemmy-alpha/c/main", "type": "Lock", "id": "https://lemmy-alpha/activities/lock/574b9805-19f5-4349-8c6e-c38c82898df9" }, "cc": ["https://lemmy-alpha/c/test"], "audience": "http://lemmy-alpha/c/main", "type": "Undo", "summary": "A reason for an unlock." } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/undo_lock_page.json ================================================ { "id": "http://lemmy-alpha:8541/activities/undo/d6066719-d277-4964-9190-4d6faffac286", "actor": "http://lemmy-alpha:8541/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "http://lemmy-alpha:8541/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://lemmy-alpha:8541/post/2", "cc": ["http://lemmy-alpha:8541/c/main"], "type": "Lock", "id": "http://lemmy-alpha:8541/activities/lock/08b6fd3e-9ef3-4358-a987-8bb641f3e2c3", "audience": "http://lemmy-alpha:8541/c/main" }, "cc": ["http://lemmy-alpha:8541/c/main"], "type": "Undo", "audience": "http://lemmy-alpha:8541/c/main", "summary": "A reason for the unlock" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/community/update_community.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "type": "Group", "id": "http://enterprise.lemmy.ml/c/main", "preferredUsername": "main", "name": "The Updated Community", "summary": "

updated 2

\n", "source": { "content": "updated 2", "mediaType": "text/markdown" }, "sensitive": false, "postingRestrictedToMods": false, "inbox": "http://enterprise.lemmy.ml/c/main/inbox", "outbox": "http://enterprise.lemmy.ml/c/main/outbox", "followers": "http://enterprise.lemmy.ml/c/main/followers", "attributedTo": "https://enterprise.lemmy.ml/c/main/moderators", "endpoints": { "sharedInbox": "http://enterprise.lemmy.ml/inbox" }, "publicKey": { "id": "http://enterprise.lemmy.ml/c/main#main-key", "owner": "http://enterprise.lemmy.ml/c/main", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA16Xh06V1l2yy0WAIMUTV\nnvZIuAuKDxzDQUNT+n8gmcVuvBu7tkpbPTQ3DjGB3bQfGC2ekew/yldwOXyZ7ry1\npbJSYSrCBJrAlPLs/ao3OPTqmcl3vnSWti/hqopEV+Um2t7fwpkCjVrnzVKRSlys\nihnrth64ZiwAqq2llpaXzWc1SR2URZYSdnry/4d9UNrZVkumIeg1gk9KbCAo4j/O\njsv/aBjpZcTeLmtMZf6fcrvGre9duJdx6e2Tg/YNcnSnARosqev/UwVTzzGNVWXg\n9rItaa0a0aea4se4Bn6QXvOBbcq3+OYZMR6a34hh5BTeNG8WbpwmVahS0WFUsv9G\nswIDAQAB\n-----END PUBLIC KEY-----\n" }, "language": [ { "identifier": "fr", "name": "Français" }, { "identifier": "de", "name": "Deutsch" } ], "published": "2021-10-29T15:05:51.476984Z", "updated": "2021-11-01T12:23:50.151874Z" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Update", "id": "http://ds9.lemmy.ml/activities/update/d3717cf5-096d-473f-9530-5d52f9d51f5f" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/create_or_update/create_comment.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "type": "Note", "id": "http://ds9.lemmy.ml/comment/1", "attributedTo": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": [ "http://enterprise.lemmy.ml/c/main", "http://ds9.lemmy.ml/u/lemmy_alpha" ], "audience": "https://enterprise.lemmy.ml/c/main", "content": "hello", "mediaType": "text/html", "source": { "content": "hello", "mediaType": "text/markdown" }, "inReplyTo": "http://ds9.lemmy.ml/post/1", "published": "2021-11-01T11:45:49.794920Z" }, "cc": [ "http://enterprise.lemmy.ml/c/main", "http://ds9.lemmy.ml/u/lemmy_alpha" ], "audience": "https://enterprise.lemmy.ml/c/main", "tag": [ { "href": "http://ds9.lemmy.ml/u/lemmy_alpha", "type": "Mention", "name": "@lemmy_alpha@ds9.lemmy.ml" } ], "type": "Create", "id": "http://ds9.lemmy.ml/activities/create/1e77d67c-44ac-45ed-bf2a-460e21f60236" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/create_or_update/create_page.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "type": "Page", "id": "http://ds9.lemmy.ml/post/1", "attributedTo": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": [ "http://enterprise.lemmy.ml/c/main", "https://www.w3.org/ns/activitystreams#Public" ], "audience": "https://enterprise.lemmy.ml/c/main", "name": "test post", "content": "

test body

\n", "mediaType": "text/html", "source": { "content": "test body", "mediaType": "text/markdown" }, "attachment": [ { "type": "Link", "href": "https://lemmy.ml/pictrs/image/xl8W7FZfk9.jpg" } ], "sensitive": false, "language": { "identifier": "ko", "name": "한국어" }, "published": "2021-10-29T15:10:51.557399Z" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Create", "id": "http://ds9.lemmy.ml/activities/create/eee6a57a-622f-464d-b560-73ae1fcd3ddf" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/create_or_update/create_private_message.json ================================================ { "id": "http://enterprise.lemmy.ml/activities/create/987d05fa-f637-46d7-85be-13d112bc269f", "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://ds9.lemmy.ml/u/lemmy_alpha"], "object": { "type": "Note", "id": "http://enterprise.lemmy.ml/private_message/1", "attributedTo": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://ds9.lemmy.ml/u/lemmy_alpha"], "content": "hello", "mediaType": "text/html", "source": { "content": "hello", "mediaType": "text/markdown" }, "published": "2021-10-29T15:31:56.058289Z" }, "type": "Create" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/create_or_update/update_page.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "type": "Page", "id": "http://ds9.lemmy.ml/post/1", "attributedTo": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": [ "http://enterprise.lemmy.ml/c/main", "https://www.w3.org/ns/activitystreams#Public" ], "audience": "https://enterprise.lemmy.ml/c/main", "name": "test post 1", "content": "

test body

\n", "mediaType": "text/html", "source": { "content": "test body", "mediaType": "text/markdown" }, "attachment": [ { "type": "Link", "href": "https://lemmy.ml/pictrs/image/xl8W7FZfk9.jpg" } ], "sensitive": false, "published": "2021-10-29T15:10:51.557399Z", "updated": "2021-10-29T15:11:35.976374Z", "context": "http://ds9.lemmy.ml/post/1/context" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Update", "id": "http://ds9.lemmy.ml/activities/update/ab360117-e165-4de4-b7fc-906b62c98631" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/delete_page.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/post/1", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Delete", "id": "http://ds9.lemmy.ml/activities/delete/f2abee48-c7bb-41d5-9e27-8775ff32db12" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/delete_private_message.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://enterprise.lemmy.ml/u/lemmy_beta"], "object": "http://enterprise.lemmy.ml/private_message/1", "type": "Delete", "id": "http://enterprise.lemmy.ml/activities/delete/041d9858-5eef-4ad9-84ae-7455b4d87ed9" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/delete_user.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/u/lemmy_alpha", "type": "Delete", "id": "http://ds9.lemmy.ml/activities/delete/f2abee48-c7bb-41d5-9e27-8775ff32db12", "removeData": true } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/remove_note.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/comment/1", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Delete", "summary": "bad comment", "id": "http://enterprise.lemmy.ml/activities/delete/42ca1a79-f99e-4518-a2ca-ba2df221eb5e" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/undo_delete_page.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/post/1", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Delete", "id": "http://ds9.lemmy.ml/activities/delete/b13cca96-7737-41e1-9769-8fbf972b3509" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Undo", "id": "http://ds9.lemmy.ml/activities/undo/5e939cfb-b8a1-4de8-950f-9d684e9162b9" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/undo_delete_private_message.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://ds9.lemmy.ml/u/lemmy_alpha"], "object": { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["http://enterprise.lemmy.ml/u/lemmy_beta"], "object": "http://enterprise.lemmy.ml/private_message/1", "type": "Delete", "id": "http://enterprise.lemmy.ml/activities/delete/616c41be-04ed-4bd4-b865-30712186b122" }, "type": "Undo", "id": "http://enterprise.lemmy.ml/activities/undo/35e5b337-014c-4bbe-8d63-6fac96f51409" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/deletion/undo_remove_note.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": "http://ds9.lemmy.ml/comment/1", "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Delete", "summary": "bad comment", "id": "http://enterprise.lemmy.ml/activities/delete/2598435c-87a3-49cd-81f3-a44b03b7af9d" }, "cc": ["http://enterprise.lemmy.ml/c/main"], "audience": "https://enterprise.lemmy.ml/c/main", "type": "Undo", "id": "http://enterprise.lemmy.ml/activities/undo/a850cf21-3866-4b3a-b80b-56aa00997fee" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/following/accept.json ================================================ { "actor": "http://enterprise.lemmy.ml/c/main", "to": ["http://ds9.lemmy.ml/u/lemmy_alpha"], "object": { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["http://enterprise.lemmy.ml/c/main"], "object": "http://enterprise.lemmy.ml/c/main", "type": "Follow", "id": "http://ds9.lemmy.ml/activities/follow/6abcd50b-b8ca-4952-86b0-a6dd8cc12866" }, "type": "Accept", "id": "http://enterprise.lemmy.ml/activities/accept/75f080cc-3d45-4654-8186-8f3bb853fa27" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/following/follow.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["http://enterprise.lemmy.ml/c/main"], "object": "http://enterprise.lemmy.ml/c/main", "type": "Follow", "id": "http://ds9.lemmy.ml/activities/follow/6abcd50b-b8ca-4952-86b0-a6dd8cc12866" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/following/undo_follow.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["http://enterprise.lemmy.ml/c/main"], "object": { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "to": ["http://enterprise.lemmy.ml/c/main"], "object": "http://enterprise.lemmy.ml/c/main", "type": "Follow", "id": "http://ds9.lemmy.ml/activities/follow/dc2f1bc5-f3a0-4daa-a46b-428cbfbd023c" }, "type": "Undo", "id": "http://ds9.lemmy.ml/activities/undo/dd83c482-8ebd-4b6c-9008-c8373bd1a86a" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/voting/dislike_page.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "object": "http://ds9.lemmy.ml/post/1", "audience": "https://enterprise.lemmy.ml/c/tenforward", "type": "Dislike", "id": "http://enterprise.lemmy.ml/activities/dislike/64d40d40-a829-43a5-8247-1fb595b3ca1c" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/voting/like_note.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "object": "http://ds9.lemmy.ml/comment/1", "audience": "https://enterprise.lemmy.ml/c/tenforward", "type": "Like", "id": "http://ds9.lemmy.ml/activities/like/fd61d070-7382-46a9-b2b7-6bb253732877" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/voting/undo_dislike_page.json ================================================ { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "object": { "actor": "http://enterprise.lemmy.ml/u/lemmy_beta", "object": "http://ds9.lemmy.ml/post/1", "type": "Like", "audience": "https://enterprise.lemmy.ml/c/tenforward", "id": "http://enterprise.lemmy.ml/activities/like/2227ab2c-79e2-4fca-a1d2-1d67dacf2457" }, "audience": "https://enterprise.lemmy.ml/c/tenforward", "type": "Undo", "id": "http://enterprise.lemmy.ml/activities/undo/6cc6fb71-39fe-49ea-9506-f0423b101e98" } ================================================ FILE: crates/apub/apub/assets/lemmy/activities/voting/undo_like_note.json ================================================ { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "object": { "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "object": "http://ds9.lemmy.ml/comment/1", "audience": "https://enterprise.lemmy.ml/c/tenforward", "type": "Like", "id": "http://ds9.lemmy.ml/activities/like/efcf7ae2-dfcc-4ff4-9ce4-6adf251ff004" }, "audience": "https://enterprise.lemmy.ml/c/tenforward", "type": "Undo", "id": "http://ds9.lemmy.ml/activities/undo/3518565c-24a7-4d9e-8e0a-f7a2f45ac618" } ================================================ FILE: crates/apub/apub/assets/lemmy/collections/group_featured_posts.json ================================================ { "type": "OrderedCollection", "id": "https://ds9.lemmy.ml/c/main/featured", "totalItems": 2, "orderedItems": [ { "type": "Page", "id": "https://ds9.lemmy.ml/post/2", "attributedTo": "https://ds9.lemmy.ml/u/lemmy_alpha", "to": [ "https://ds9.lemmy.ml/c/main", "https://www.w3.org/ns/activitystreams#Public" ], "name": "test 2", "cc": [], "mediaType": "text/html", "attachment": [], "sensitive": false, "published": "2023-02-06T06:42:41.939437Z", "language": { "identifier": "de", "name": "Deutsch" }, "audience": "https://ds9.lemmy.ml/c/main" }, { "type": "Page", "id": "https://ds9.lemmy.ml/post/1", "attributedTo": "https://ds9.lemmy.ml/u/lemmy_alpha", "to": [ "https://ds9.lemmy.ml/c/main", "https://www.w3.org/ns/activitystreams#Public" ], "name": "test 1", "cc": [], "mediaType": "text/html", "attachment": [], "sensitive": false, "published": "2023-02-06T06:42:37.119567Z", "language": { "identifier": "de", "name": "Deutsch" }, "audience": "https://ds9.lemmy.ml/c/main" } ] } ================================================ FILE: crates/apub/apub/assets/lemmy/collections/group_followers.json ================================================ { "id": "http://enterprise.lemmy.ml/c/main/followers", "type": "Collection", "totalItems": 3, "items": [] } ================================================ FILE: crates/apub/apub/assets/lemmy/collections/group_moderators.json ================================================ { "type": "OrderedCollection", "id": "https://enterprise.lemmy.ml/c/tenforward/moderators", "orderedItems": ["https://enterprise.lemmy.ml/u/picard"] } ================================================ FILE: crates/apub/apub/assets/lemmy/collections/group_outbox.json ================================================ { "type": "OrderedCollection", "id": "https://ds9.lemmy.ml/c/testcom/outbox", "totalItems": 2, "orderedItems": [ { "actor": "https://ds9.lemmy.ml/c/testcom", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "https://ds9.lemmy.ml/u/nutomic", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://ds9.lemmy.ml/c/testcom"], "type": "Create", "id": "http://ds9.lemmy.ml/activities/create/eee6a57a-622f-464d-b560-73ae1fcd3ddf", "object": { "type": "Page", "id": "https://ds9.lemmy.ml/post/2328", "attributedTo": "https://ds9.lemmy.ml/u/nutomic", "to": [ "https://ds9.lemmy.ml/c/testcom", "https://www.w3.org/ns/activitystreams#Public" ], "name": "another outbox test", "mediaType": "text/html", "sensitive": false, "stickied": false, "published": "2021-11-18T17:19:45.895163Z" } }, "cc": ["https://ds9.lemmy.ml/c/testcom/followers"], "type": "Announce", "id": "https://ds9.lemmy.ml/activities/announce/b204fe9f-b13d-4af2-9d22-239ac2d892e6" }, { "actor": "https://ds9.lemmy.ml/c/testcom", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "actor": "https://ds9.lemmy.ml/u/nutomic", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://ds9.lemmy.ml/c/testcom"], "type": "Create", "id": "http://ds9.lemmy.ml/activities/create/eee6a57a-622f-464d-b560-73ae1fcd3ddf", "object": { "type": "Page", "id": "https://ds9.lemmy.ml/post/2327", "attributedTo": "https://ds9.lemmy.ml/u/nutomic", "to": [ "https://ds9.lemmy.ml/c/testcom", "https://www.w3.org/ns/activitystreams#Public" ], "name": "outbox test", "mediaType": "text/html", "sensitive": false, "stickied": false, "published": "2021-11-18T17:19:05.763109Z" } }, "cc": ["https://ds9.lemmy.ml/c/testcom/followers"], "type": "Announce", "id": "https://ds9.lemmy.ml/activities/announce/c6c960ce-c8d8-4231-925e-3ba367468f18" } ] } ================================================ FILE: crates/apub/apub/assets/lemmy/collections/person_outbox.json ================================================ { "type": "OrderedCollection", "id": "http://ds9.lemmy.ml/u/lemmy_alpha/outbox", "orderedItems": [], "totalItems": 0 } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/comment.json ================================================ { "id": "https://enterprise.lemmy.ml/comment/38741", "type": "Note", "attributedTo": "https://enterprise.lemmy.ml/u/picard", "to": [ "https://enterprise.lemmy.ml/c/tenforward", "https://www.w3.org/ns/activitystreams#Public" ], "audience": "https://enterprise.lemmy.ml/c/tenforward", "cc": ["https://enterprise.lemmy.ml/u/picard"], "inReplyTo": "https://enterprise.lemmy.ml/post/55143", "content": "

first comment!

\n", "mediaType": "text/html", "source": { "content": "first comment!", "mediaType": "text/markdown" }, "tag": [ { "href": "https://enterprise.lemmy.ml/u/picard", "type": "Mention", "name": "@picard@enterprise.lemmy.ml" } ], "distinguished": false, "language": { "identifier": "fr", "name": "Français" }, "published": "2021-03-01T13:42:43.966208Z", "updated": "2021-03-01T13:43:03.955787Z", "context": "https://enterprise.lemmy.ml/comment/38741/context" } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/group.json ================================================ { "id": "https://enterprise.lemmy.ml/c/tenforward", "type": "Group", "preferredUsername": "tenforward", "name": "Ten Forward", "description": "A description of ten forward.", "summary": "

Lounge and recreation facility

\n
\n

Welcome to the Enterprise!.

\n", "source": { "content": "Lounge and recreation facility\n\n---\n\nWelcome to the Enterprise!", "mediaType": "text/markdown" }, "mediaType": "text/html", "sensitive": false, "icon": { "type": "Image", "url": "https://enterprise.lemmy.ml/pictrs/image/waqyZwLAy4.webp" }, "image": { "type": "Image", "url": "https://enterprise.lemmy.ml/pictrs/image/Wt8zoMcCmE.jpg" }, "inbox": "https://enterprise.lemmy.ml/c/tenforward/inbox", "followers": "https://enterprise.lemmy.ml/c/tenforward/followers", "attributedTo": "https://enterprise.lemmy.ml/c/tenforward/moderators", "featured": "https://enterprise.lemmy.ml/c/tenforward//featured", "postingRestrictedToMods": false, "endpoints": { "sharedInbox": "https://enterprise.lemmy.ml/inbox" }, "outbox": "https://enterprise.lemmy.ml/c/tenforward/outbox", "publicKey": { "id": "https://enterprise.lemmy.ml/c/tenforward#main-key", "owner": "https://enterprise.lemmy.ml/c/tenforward", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzRjKTNtvDCmugplwEh+g\nx1bhKm6BHUZfXfpscgMMm7tXFswSDzUQirMgfkxa9ubfr1PDFKffA2vQ9x6CyuO/\n70xTafdOHyV1tSqzgKz0ZvFZ/VCOo6qy1mYWVkrtBm/fKzM+87MdkKYB/zI4VyEJ\nLfLQgjwxBAEYUH3CBG71U0gO0TwbimWNN0vqlfp0QfThNe1WYObF88ZVzMLgFbr7\nRHBItZjlZ/d8foPDidlIR3l2dJjy0EsD8F9JM340jtX7LXqFmU4j1AQKNHTDLnUF\nwYVhzuQGNJ504l5LZkFG54XfIFT7dx2QwuuM9bSnfPv/98RYrq1Si6tCkxEt1cVe\n4wIDAQAB\n-----END PUBLIC KEY-----\n" }, "language": [ { "identifier": "fr", "name": "Français" }, { "identifier": "de", "name": "Deutsch" } ], "tag": [ { "type": "CommunityPostTag", "id": "https://enterprise.lemmy.ml/c/tenforward/tag/news", "preferredUsername": "news" } ], "published": "2019-06-02T16:43:50.799554Z", "updated": "2021-03-10T17:18:10.498868Z" } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/instance.json ================================================ { "type": "Application", "id": "https://enterprise.lemmy.ml/", "name": "Enterprise", "preferredUsername": "enterprise.lemmy.ml", "description": "A test instance", "content": "

Enterprise sidebar

\\n", "mediaType": "text/html", "source": { "content": "Enterprise sidebar", "mediaType": "text/markdown" }, "inbox": "https://enterprise.lemmy.ml/inbox", "outbox": "https://enterprise.lemmy.ml/outbox", "publicKey": { "id": "https://enterprise.lemmy.ml/#main-key", "owner": "https://enterprise.lemmy.ml/", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAupcK0xTw5yQb/fnztAmb\n9LfPbhJJP1+1GwUaOXGYiDJD6uYJhl9CLmgztLl3RyV9ltOYoN8/NLNDfOMmgOjd\nrsNWEjDI9IcVPmiZnhU7hsi6KgQvJzzv8O5/xYjAGhDfrGmtdpL+lyG0B5fQod8J\n/V5VWvTQ0B0qFrLSBBuhOrp8/fTtDskdtElDPtnNfH2jn6FgtLOijidWwf9ekFo4\n0I1JeuEw6LuD/CzKVJTPoztzabUV1DQF/DnFJm+8y7SCJa9jEO56Uf9eVfa1jF6f\ndH6ZvNJMiafstVuLMAw7C/eNJy3ufXgtZ4403oOKA0aRSYf1cc9pHSZ9gDE/mevH\nLwIDAQAB\n-----END PUBLIC KEY-----\n" }, "language": [ { "identifier": "fr", "name": "Français" }, { "identifier": "es", "name": "Español" } ], "published": "2022-01-19T21:52:11.110741Z" } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/page.json ================================================ { "id": "https://enterprise.lemmy.ml/post/55143", "type": "Page", "attributedTo": "https://enterprise.lemmy.ml/u/picard", "to": [ "https://enterprise.lemmy.ml/c/tenforward", "https://www.w3.org/ns/activitystreams#Public" ], "audience": "https://enterprise.lemmy.ml/c/tenforward", "name": "Post title", "content": "

This is a post in the /c/tenforward community

\n", "mediaType": "text/html", "source": { "content": "This is a post in the /c/tenforward community", "mediaType": "text/markdown" }, "attachment": [ { "type": "Link", "href": "https://enterprise.lemmy.ml/pictrs/image/eOtYb9iEiB.png" } ], "image": { "type": "Image", "url": "https://enterprise.lemmy.ml/pictrs/image/eOtYb9iEiB.png" }, "sensitive": false, "language": { "identifier": "fr", "name": "Français" }, "tag": [ { "type": "CommunityPostTag", "id": "https://enterprise.lemmy.ml/c/tenforward/tag/news", "preferredUsername": "news" } ], "context": "https://enterprise.lemmy.ml/post/55143/context", "published": "2021-02-26T12:35:34.292626Z" } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/person.json ================================================ { "id": "https://enterprise.lemmy.ml/u/picard", "type": "Person", "preferredUsername": "picard", "name": "Jean-Luc Picard", "summary": "

Captain of the starship Enterprise.

\n", "source": { "content": "Captain of the starship **Enterprise**.", "mediaType": "text/markdown" }, "icon": { "type": "Image", "url": "https://enterprise.lemmy.ml/pictrs/image/ed9ej7.jpg" }, "image": { "type": "Image", "url": "https://enterprise.lemmy.ml/pictrs/image/XenaYI5hTn.png" }, "matrixUserId": "@picard:matrix.org", "inbox": "https://enterprise.lemmy.ml/u/picard/inbox", "outbox": "https://enterprise.lemmy.ml/u/picard/outbox", "endpoints": { "sharedInbox": "https://enterprise.lemmy.ml/inbox" }, "published": "2020-01-17T01:38:22.348392Z", "updated": "2021-08-13T00:11:15.941990Z", "publicKey": { "id": "https://enterprise.lemmy.ml/u/picard#main-key", "owner": "https://enterprise.lemmy.ml/u/picard", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0lP99/s5Vv+XbPdkeqIJ\nwoD4GFnHmBnBHdEKChEUWfWj1TtioC/rGNoXFQeXQA3Amhy4nxSceiDnUgwkkuQY\nv0MtIW58NzgknEavtllxL+LSds5pg3gANaDIk8UiWTkqXTg0GnlJMpCK1Chen0l/\nszL6DEvUyTSuS5ZYDXFgewF89Pe7U0S15V5U2Harv7AgJYDyxmUL0D1pGuUCRqcE\nl5MTHJjrXeNnH1w2g8aly8YlO/Cr0L51rFg/lBF23vni7ZLv8HbmWh6YpaAf1R8h\nE45zKR7OHqymdjzrg1ITBwovefpwMkVgnJ+Wdr4HPnFlBSkXPoZeM11+Z8L0anzA\nXwIDAQAB\n-----END PUBLIC KEY-----\n" } } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/private_message.json ================================================ { "id": "https://enterprise.lemmy.ml/private_message/1621", "type": "Note", "attributedTo": "https://enterprise.lemmy.ml/u/picard", "to": ["https://queer.hacktivis.me/users/lanodan"], "content": "

Hello hello, testing

\n", "mediaType": "text/html", "source": { "content": "Hello hello, testing", "mediaType": "text/markdown" }, "published": "2021-10-21T10:13:14.597721Z" } ================================================ FILE: crates/apub/apub/assets/lemmy/objects/tombstone.json ================================================ { "id": "https://lemmy.ml/comment/110273", "type": "Tombstone" } ================================================ FILE: crates/apub/apub/assets/lotide/activities/create_note_reply.json ================================================ { "actor": "https://c.tide.tk/users/1", "object": { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://c.tide.tk/comments/52", "type": "Note", "mediaType": "text/html", "source": { "content": "test comment", "mediaType": "text/markdown" }, "attributedTo": "https://c.tide.tk/users/1", "content": "

test comment

\n", "published": "2021-09-16T01:20:27.558063+00:00", "inReplyTo": "https://c.tide.tk/posts/51", "to": "https://c.tide.tk/users/1", "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://c.tide.tk/communities/1" ] }, "to": "https://c.tide.tk/users/1", "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://c.tide.tk/communities/1" ], "@context": "https://www.w3.org/ns/activitystreams", "id": "https://c.tide.tk/comments/52/create", "type": "Create" } ================================================ FILE: crates/apub/apub/assets/lotide/activities/create_page.json ================================================ { "actor": "https://b.tide.tk/apub/users/1", "object": { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://b.tide.tk/apub/posts/60", "type": "Page", "name": "test post from b", "summary": "test post from b", "to": "https://c.tide.tk/communities/1", "cc": "https://www.w3.org/ns/activitystreams#Public", "published": "2020-12-19T19:20:26.941381+00:00", "attributedTo": "https://b.tide.tk/apub/users/1", "url": "https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake.html" }, "to": "https://c.tide.tk/communities/1", "cc": "https://www.w3.org/ns/activitystreams#Public", "@context": "https://www.w3.org/ns/activitystreams", "id": "https://b.tide.tk/apub/posts/60/create", "type": "Create" } ================================================ FILE: crates/apub/apub/assets/lotide/activities/create_page_image.json ================================================ { "actor": "http://ltthostname.local:3334/apub/users/3", "object": { "@context": "https://www.w3.org/ns/activitystreams", "id": "http://ltthostname.local:3334/apub/posts/46", "type": "Note", "name": "image", "to": "http://localhost:8536/c/elsewhere", "cc": "https://www.w3.org/ns/activitystreams#Public", "attributedTo": "http://ltthostname.local:3334/apub/users/3", "attachment": [ { "type": "Image", "url": "http://ltthostname.local:3334/api/stable/posts/46/href" } ], "sensitive": false, "published": "2022-08-06T18:35:01.043072+00:00", "summary": "image" }, "to": "http://localhost:8536/c/elsewhere", "cc": "https://www.w3.org/ns/activitystreams#Public", "@context": "https://www.w3.org/ns/activitystreams", "id": "http://ltthostname.local:3334/apub/posts/46/create", "type": "Create" } ================================================ FILE: crates/apub/apub/assets/lotide/activities/delete_note.json ================================================ { "actor": "https://narwhal.city/users/3", "object": "https://narwhal.city/posts/12", "@context": "https://www.w3.org/ns/activitystreams", "id": "https://narwhal.city/posts/12/delete", "type": "Delete" } ================================================ FILE: crates/apub/apub/assets/lotide/activities/follow.json ================================================ { "actor": "https://dev.narwhal.city/users/1", "object": "https://beehaw.org/c/foss", "to": "https://beehaw.org/c/foss", "@context": "https://www.w3.org/ns/activitystreams", "id": "https://dev.narwhal.city/communities/90/followers/1", "type": "Follow" } ================================================ FILE: crates/apub/apub/assets/lotide/objects/group.json ================================================ { "publicKey": { "id": "https://narwhal.city//communities/12#main-key", "owner": "https://narwhal.city/communities/12", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtktBbjovDSQmjZo1SIGK\n1TP1FKuIj8JlFgY6iGrAA5IBUN8PPKRzvo0U0FDvF+7SsUx+yiY0JrU1KzWcJxRr\nCfTrjNzaKeMS4E6ZU9czf8D157JUJQtkgikObxwU84eY5K+jic1ZgGv2eX77E6f/\nBZFO8StdS73g8a1vxPEsJVBn/VEVdsD9fg3uvhwFN7UrUKoKGf+1h2PajeX1aPZb\ntD3ql3Xff2IZFZu6Euj80OezozQ6/AqZx+qW6HfjvSf30C8ZGYU1PSF6MczY+Sg6\n6nyPMfmbKykYgWqfRMZ/NKaldsIjN8nMRDCfHASt6+pNmZgWh9HvSaFiSFKIn3Xj\nXwIDAQAB\n-----END PUBLIC KEY-----\n", "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" }, "featured": "https://narwhal.city/communities/12/featured", "inbox": "https://narwhal.city/communities/12/inbox", "outbox": "https://narwhal.city/communities/12/outbox", "followers": "https://narwhal.city/communities/12/followers", "preferredUsername": "Iotide", "summary": "This is for talking about lotide\r\n\r\n\r\nI accidentally called it iotide because I misread the text when I made it lol", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "featured": { "@id": "toot:featured", "@type": "@id" }, "toot": "http://joinmastodon.org/ns#" } ], "id": "https://narwhal.city/communities/12", "type": "Group", "name": "Iotide" } ================================================ FILE: crates/apub/apub/assets/lotide/objects/note.json ================================================ { "source": { "mediaType": "text/markdown", "content": "ed: now featuring Bob Dylan and RNG" }, "attributedTo": "https://narwhal.city/users/3", "content": "

ed: now featuring Bob Dylan and RNG

\n", "@context": "https://www.w3.org/ns/activitystreams", "inReplyTo": "https://narwhal.city/posts/9", "to": "https://narwhal.city/users/1", "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://narwhal.city/communities/4" ], "id": "https://narwhal.city/comments/3", "type": "Note", "mediaType": "text/html", "published": "2020-12-31T06:47:24.470801+00:00" } ================================================ FILE: crates/apub/apub/assets/lotide/objects/page.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://narwhal.city/posts/9", "type": "Page", "name": "What's Dylan Grillin'? (reupload)", "to": "https://narwhal.city/communities/4", "attributedTo": "https://narwhal.city/users/1", "published": "2020-12-30T07:29:19.460932+00:00", "url": "https://www.youtube.com/watch?v=ZI4LGTXscR4", "summary": "What's Dylan Grillin'? (reupload)", "cc": "https://www.w3.org/ns/activitystreams#Public" } ================================================ FILE: crates/apub/apub/assets/lotide/objects/person.json ================================================ { "publicKey": { "id": "https://narwhal.city//users/3#main-key", "owner": "https://narwhal.city/users/3", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvC+ZURasjlyX1o4FqMWB\npAppKWU2zPV7cUokKsnKo9m2PKw+53mmVUMQ66LtN80l/WCK/hy7r2lDKvpyt3YO\nnEsNcSCYLaYnTLDNkE2u14kx8jKOFiyRKKVKCNA32b+XvM+rLDmfaNOeBsB92mVR\nVmIz+WO+0FVPtg1MQMKWIoe6SgKW8SHpz/qVeggYNMKp/b2ai7Of0KTSbYIcqFR2\nT8g/6L5Mmjz4zKIn+a5GFmBNTMTCsJTxa5yOjPwefh/9SrukWt01N5KLrIpmApms\nRoJSsBWh0xo7N+v23PaFHEkaJ2zCtT5zkzITa8bUfHoIc3rM6Ipa1uFlnmrnUIZE\nUQIDAQAB\n-----END PUBLIC KEY-----\n", "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" }, "inbox": "https://narwhal.city/users/3/inbox", "outbox": "https://narwhal.city/users/3/outbox", "preferredUsername": "57H", "endpoints": { "sharedInbox": "https://narwhal.city/inbox" }, "summary": "", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id": "https://narwhal.city/users/3", "type": "Person", "name": "57H" } ================================================ FILE: crates/apub/apub/assets/lotide/objects/tombstone.json ================================================ { "former_type": "Note", "@context": "https://www.w3.org/ns/activitystreams", "id": "https://narwhal.city/posts/12", "type": "Tombstone" } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/create_note.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri" } ], "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645/activity", "type": "Create", "actor": "https://mastodon.madrid/users/felix", "published": "2021-11-05T11:46:50Z", "to": ["https://mastodon.madrid/users/felix/followers"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://mamot.fr/users/retiolus" ], "object": { "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645", "type": "Note", "summary": null, "inReplyTo": "https://mamot.fr/users/retiolus/statuses/107224244380204526", "published": "2021-11-05T11:46:50Z", "url": "https://mastodon.madrid/@felix/107224289116410645", "attributedTo": "https://mastodon.madrid/users/felix", "to": ["https://mastodon.madrid/users/felix/followers"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://mamot.fr/users/retiolus" ], "sensitive": false, "atomUri": "https://mastodon.madrid/users/felix/statuses/107224289116410645", "inReplyToAtomUri": "https://mamot.fr/users/retiolus/statuses/107224244380204526", "conversation": "tag:mamot.fr,2021-11-05:objectId=64635960:objectType=Conversation", "content": "

@retiolus i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.

", "contentMap": { "en": "

@retiolus i have neverbeendisappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.

" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://mamot.fr/users/retiolus", "name": "@retiolus@mamot.fr" } ], "replies": { "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies?only_other_accounts=true&page=true", "partOf": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies", "items": [] } } } } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/delete.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri" } ], "id": "https://mastodon.madrid/users/felix/statuses/107773559874184870#delete", "type": "Delete", "actor": "https://mastodon.madrid/users/felix", "to": ["https://www.w3.org/ns/activitystreams#Public"], "object": { "id": "https://mastodon.madrid/users/felix/statuses/107773559874184870", "type": "Tombstone", "atomUri": "https://mastodon.madrid/users/felix/statuses/107773559874184870" }, "signature": { "type": "RsaSignature2017", "creator": "https://mastodon.madrid/users/felix#main-key", "created": "2022-02-10T11:54:18Z", "signatureValue": "NjGnbkvouSP/cSusR7+sz39iEYxWXCu6nFmBXU3t8ETPkmbpMF5ASeJixXvpTOqbOfkMoWfXncw+jDsbqZ3ELaHGG1gZ5wHWym7mk7YCjQokpF3oPhTWmlEJCVKgewXMrfI4Ok8GGsUMGzuki9EyBDGc/UNBMEAhcxV5Huu7QSQDowcbIwxS3ImxFmtKFceh6mv/kMiXUerCgkYSm6rYZeXZGMTUpvcn9gP6X6Ed6UsrLjCSb3Fj0Naz7LHtzZXRSZDZF/SX2Vw/xKJIgEGzSCv+LKZGvEEkK8PPfMJJhi8cBJebkqOnBGtE6gYK2z2cm/oGorZtXU2L05pXmLAlYQ==" } } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/flag.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://mastodon.example/ccb4f39a-506a-490e-9a8c-71831c7713a4", "type": "Flag", "actor": "https://mastodon.example/actor", "content": "Please take a look at this user and their posts", "object": [ "https://example.com/users/1", "https://example.com/posts/380590", "https://example.com/posts/380591" ], "to": "https://example.com/users/1" } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/follow.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf", "type": "Follow", "actor": "https://masto.asonix.dog/users/asonix", "object": "https://ds9.lemmy.ml/c/testcom" } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/like_page.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://mastodon.madrid/users/felix#likes/212340", "type": "Like", "actor": "https://mastodon.madrid/users/felix", "object": "https://ds9.lemmy.ml/post/147" } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/private_message.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount" } ], "id": "https://mastodon.world/users/nutomic/statuses/110854468010322301", "type": "Note", "summary": null, "inReplyTo": "https://mastodon.world/users/nutomic/statuses/110854464248188528", "published": "2023-08-08T14:29:04Z", "url": "https://mastodon.world/@nutomic/110854468010322301", "attributedTo": "https://mastodon.world/users/nutomic", "to": ["https://ds9.lemmy.ml/u/nutomic"], "cc": [], "sensitive": false, "atomUri": "https://mastodon.world/users/nutomic/statuses/110854468010322301", "inReplyToAtomUri": "https://mastodon.world/users/nutomic/statuses/110854464248188528", "conversation": "tag:mastodon.world,2023-08-08:objectId=121377096:objectType=Conversation", "content": "

@nutomic@ds9.lemmy.ml 444

", "contentMap": { "es": "

@nutomic@ds9.lemmy.ml 444

" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://ds9.lemmy.ml/u/nutomic", "name": "@nutomic@ds9.lemmy.ml" } ], "replies": { "id": "https://mastodon.world/users/nutomic/statuses/110854468010322301/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://mastodon.world/users/nutomic/statuses/110854468010322301/replies?only_other_accounts=true&page=true", "partOf": "https://mastodon.world/users/nutomic/statuses/110854468010322301/replies", "items": [] } } } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/undo_follow.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://masto.asonix.dog/users/asonix#follows/449/undo", "type": "Undo", "actor": "https://masto.asonix.dog/users/asonix", "object": { "id": "https://masto.asonix.dog/1ea87517-63c5-4118-8831-460ee641b2cf", "type": "Follow", "actor": "https://masto.asonix.dog/users/asonix", "object": "https://ds9.lemmy.ml/c/testcom" } } ================================================ FILE: crates/apub/apub/assets/mastodon/activities/undo_like_page.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://mastodon.madrid/users/felix#likes/212341/undo", "type": "Undo", "actor": "https://mastodon.madrid/users/felix", "object": { "id": "https://mastodon.madrid/users/felix#likes/212341", "type": "Like", "actor": "https://mastodon.madrid/users/felix", "object": "https://ds9.lemmy.ml/post/147" } } ================================================ FILE: crates/apub/apub/assets/mastodon/collections/featured.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount", "Hashtag": "as:Hashtag" } ], "id": "https://mastodon.social/users/LemmyDev/collections/featured", "type": "OrderedCollection", "totalItems": 1, "orderedItems": [ { "id": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728", "type": "Note", "summary": null, "inReplyTo": null, "published": "2020-05-28T14:52:14Z", "url": "https://mastodon.social/@LemmyDev/104246642906910728", "attributedTo": "https://mastodon.social/users/LemmyDev", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://mastodon.social/users/LemmyDev/followers"], "sensitive": false, "atomUri": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728", "inReplyToAtomUri": null, "conversation": "tag:mastodon.social,2020-05-28:objectId=175451535:objectType=Conversation", "content": "

Inaugural Post for Lemmy, a decentralized, easily self-hostable #reddit / link aggregator alternative,intended to work in the #fediverse:

https://github.com/LemmyNet/lemmy/

#activitypub

", "contentMap": { "en": "

Inaugural Post for Lemmy, a decentralized, easily self-hostable #reddit / link aggregator alternative, intended to work in the #fediverse:

https://github.com/LemmyNet/lemmy/

#activitypub

" }, "attachment": [], "tag": [ { "type": "Hashtag", "href": "https://mastodon.social/tags/reddit", "name": "#reddit" }, { "type": "Hashtag", "href": "https://mastodon.social/tags/fediverse", "name": "#fediverse" }, { "type": "Hashtag", "href": "https://mastodon.social/tags/activitypub", "name": "#activitypub" } ], "replies": { "id": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies?min_id=104246644059085152&page=true", "partOf": "https://mastodon.social/users/LemmyDev/statuses/104246642906910728/replies", "items": [ "https://mastodon.social/users/LemmyDev/statuses/104246644059085152" ] } } } ] } ================================================ FILE: crates/apub/apub/assets/mastodon/objects/note_1.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount" } ], "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645", "type": "Note", "summary": null, "inReplyTo": "https://mamot.fr/users/retiolus/statuses/107224244380204526", "published": "2021-11-05T11:46:50Z", "url": "https://mastodon.madrid/@felix/107224289116410645", "attributedTo": "https://mastodon.madrid/users/felix", "to": ["https://mastodon.madrid/users/felix/followers"], "cc": [ "https://www.w3.org/ns/activitystreams#Public", "https://mamot.fr/users/retiolus" ], "sensitive": false, "atomUri": "https://mastodon.madrid/users/felix/statuses/107224289116410645", "inReplyToAtomUri": "https://mamot.fr/users/retiolus/statuses/107224244380204526", "conversation": "tag:mamot.fr,2021-11-05:objectId=64635960:objectType=Conversation", "content": "

@retiolus i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.

", "contentMap": { "en": "

@retiolus i have never been disappointed by a thinkpad. if you want to save money, get a model from a few years ago, there isnt a huge difference anyway.

" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://mamot.fr/users/retiolus", "name": "@retiolus@mamot.fr" } ], "replies": { "id": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies?only_other_accounts=true&page=true", "partOf": "https://mastodon.madrid/users/felix/statuses/107224289116410645/replies", "items": [] } } } ================================================ FILE: crates/apub/apub/assets/mastodon/objects/note_2.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount", "blurhash": "toot:blurhash", "focalPoint": { "@container": "@list", "@id": "toot:focalPoint" } } ], "id": "https://floss.social/users/kde/statuses/113306831140126616", "type": "Note", "summary": null, "inReplyTo": "https://floss.social/users/kde/statuses/113306824627995724", "published": "2024-10-14T16:57:15Z", "url": "https://floss.social/@kde/113306831140126616", "attributedTo": "https://floss.social/users/kde", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": [ "https://floss.social/users/kde/followers", "https://lemmy.kde.social/c/kde", "https://lemmy.kde.social/c/kde/followers" ], "sensitive": false, "atomUri": "https://floss.social/users/kde/statuses/113306831140126616", "inReplyToAtomUri": "https://floss.social/users/kde/statuses/113306824627995724", "conversation": "tag:floss.social,2024-10-14:objectId=71424279:objectType=Conversation", "content": "

@kde@lemmy.kde.social

We also need funding 💶 to keep the gears turning! Please support us with a donation:

https://kde.org/donate/

[3/3]

", "contentMap": { "en": "

@kde@lemmy.kde.social

We also need funding 💶 to keep the gears turning! Please support us with a donation:

https://kde.org/donate/

[3/3]

" }, "attachment": [ { "type": "Document", "mediaType": "image/jpeg", "url": "https://cdn.masto.host/floss/media_attachments/files/113/306/826/682/985/891/original/c8d906a2f2ab2334.jpg", "name": "The KDE dragons Katie and Konqi stand on either side of a pot filling up with gold coins. Donate!", "blurhash": "USQv:h-W-qI-^,W;RPs=^-R%NZxbo#sDobSc", "focalPoint": [0.0, 0.0], "width": 1500, "height": 1095 } ], "tag": [ { "type": "Mention", "href": "https://lemmy.kde.social/c/kde", "name": "@kde@lemmy.kde.social" } ], "replies": { "id": "https://floss.social/users/kde/statuses/113306831140126616/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://floss.social/users/kde/statuses/113306831140126616/replies?only_other_accounts=true&page=true", "partOf": "https://floss.social/users/kde/statuses/113306831140126616/replies", "items": [] } }, "likes": { "id": "https://floss.social/users/kde/statuses/113306831140126616/likes", "type": "Collection", "totalItems": 39 }, "shares": { "id": "https://floss.social/users/kde/statuses/113306831140126616/shares", "type": "Collection", "totalItems": 24 } } ================================================ FILE: crates/apub/apub/assets/mastodon/objects/page.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount" } ], "id": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519", "type": "Note", "summary": null, "inReplyTo": null, "published": "2023-08-04T09:55:39Z", "url": "https://masto.qa.urbanwildlife.biz/110830743680706519", "attributedTo": "https://masto.qa.urbanwildlife.biz/users/mastodon", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": [ "https://masto.qa.urbanwildlife.biz/users/mastodon/followers", "https://enterprise.lemmy.ml/c/tenforward", "https://enterprise.lemmy.ml/c/tenforward/followers" ], "sensitive": false, "atomUri": "https://masto.qa.urbanwildlife.biz/statuses/110830743680706519", "inReplyToAtomUri": null, "conversation": "tag:dice.camp,2023-08-04:objectId=29969291:objectType=Conversation", "content": "

@tenforward Variable never resetting at refresh

Hi! I'm using a variable to count elements in my generator but every time I generate a new character, the counter's value carries on from the previous one. Is there a function to reset it (I set it to 0 at the beginning of the file)

", "contentMap": { "it": "

@tenforwardVariable never resetting at refresh

Hi! I'm using a variable to count elements in my generator but every time I generate a new character, the counter's value carries on from the previous one. Is there a function to reset it (I set it to 0 at the beginning of the file)

" }, "attachment": [], "tag": [ { "type": "Mention", "href": "https://enterprise.lemmy.ml/c/tenforward", "name": "@tenforward@enterprise.lemmy.ml" } ], "replies": { "id": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519/replies?only_other_accounts=true&page=true", "partOf": "https://masto.qa.urbanwildlife.biz/users/mastodon/statuses/110830743680706519/replies", "items": [] } } } ================================================ FILE: crates/apub/apub/assets/mastodon/objects/person.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "toot": "http://joinmastodon.org/ns#", "featured": { "@id": "toot:featured", "@type": "@id" }, "featuredTags": { "@id": "toot:featuredTags", "@type": "@id" }, "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" }, "movedTo": { "@id": "as:movedTo", "@type": "@id" }, "schema": "http://schema.org#", "PropertyValue": "schema:PropertyValue", "value": "schema:value", "discoverable": "toot:discoverable", "Device": "toot:Device", "Ed25519Signature": "toot:Ed25519Signature", "Ed25519Key": "toot:Ed25519Key", "Curve25519Key": "toot:Curve25519Key", "EncryptedMessage": "toot:EncryptedMessage", "publicKeyBase64": "toot:publicKeyBase64", "deviceId": "toot:deviceId", "claim": { "@type": "@id", "@id": "toot:claim" }, "fingerprintKey": { "@type": "@id", "@id": "toot:fingerprintKey" }, "identityKey": { "@type": "@id", "@id": "toot:identityKey" }, "devices": { "@type": "@id", "@id": "toot:devices" }, "messageFranking": "toot:messageFranking", "messageType": "toot:messageType", "cipherText": "toot:cipherText", "suspended": "toot:suspended", "focalPoint": { "@container": "@list", "@id": "toot:focalPoint" } } ], "id": "https://masto.qa.urbanwildlife.biz/users/mastodon", "type": "Person", "following": "https://masto.qa.urbanwildlife.biz/users/mastodon/following", "followers": "https://masto.qa.urbanwildlife.biz/users/mastodon/followers", "inbox": "https://masto.qa.urbanwildlife.biz/users/mastodon/inbox", "outbox": "https://masto.qa.urbanwildlife.biz/users/mastodon/outbox", "featured": "https://masto.qa.urbanwildlife.biz/users/mastodon/collections/featured", "featuredTags": "https://masto.qa.urbanwildlife.biz/users/mastodon/collections/tags", "preferredUsername": "mastodon", "name": "Mastodon", "summary": "", "url": "https://masto.qa.urbanwildlife.biz/@mastodon", "manuallyApprovesFollowers": false, "discoverable": false, "published": "2022-10-03T00:00:00Z", "devices": "https://masto.qa.urbanwildlife.biz/users/mastodon/collections/devices", "publicKey": { "id": "https://masto.qa.urbanwildlife.biz/users/mastodon#main-key", "owner": "https://masto.qa.urbanwildlife.biz/users/mastodon", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtBdE55VmV9gTrhJmRF1K\neX7xTRo17JGQ7d1/KJWsQ1zH62GGeG/E+BG3h/BRtfgI7Z9jwfNEyx8g/Ue8rSeZ\n3M7yc09/Z90uwGVY24hxwAJyzWIN2cv5ayhdtk268byT6NX98a9PQcHlx5i6Bhef\nMlpY73I5gxYlofvwJTHq/VupXVw9K76KId2AgR2z8tLiXPc8TED56HulDWdMlWn3\n9B4mWNYmzMBF7lOl58Ws6bFsiv8GnI3uEywzUGhXqz4242FGveHdAGBaCpUYrm8W\nmT8PArqv3B4fCD1ghakSmxRr3y9clwhkC+kB/aoT6z313uZYbQuvZF1bfbh6EZWm\nIQIDAQAB\n-----END PUBLIC KEY-----\n" }, "tag": [], "attachment": [], "endpoints": { "sharedInbox": "https://masto.qa.urbanwildlife.biz/inbox" }, "icon": { "type": "Image", "mediaType": "image/png", "url": "https://masto.qa.urbanwildlife.biz/system/accounts/avatars/109/105/103/301/739/269/original/2cf61ff96e94cb1d.png" } } ================================================ FILE: crates/apub/apub/assets/mbin/activities/accept.json ================================================ { "@context": "https://www.w3.org/ns/activitystreams", "id": "https://some-mbin.instance/f/object/2721ffc3-f8a9-417e-a124-af057434a3af#accept", "type": "Accept", "actor": "https://some-mbin.instance/m/someMag", "object": { "id": "https://some-other.instance/f/object/c51ea652-e594-4920-a989-f5350f0cec05", "type": "Follow", "actor": "https://some-other.instance/u/someUser", "object": "https://some-mbin.instance/m/someMag" } } ================================================ FILE: crates/apub/apub/assets/mbin/activities/flag.json ================================================ { "@context": ["https://www.w3.org/ns/activitystreams"], "id": "https://mbin-test1/reports/45f8a01d-a73e-4575-bffa-c9f24c61f458", "type": "Flag", "actor": "https://mbin-test1/u/BentiGorlich", "object": ["https://lemmy-test/post/4", "https://lemmy-test/u/BentiGorlich"], "audience": "https://lemmy-test/c/test_mag", "summary": "dikjhgasdpas dsaü", "content": "dikjhgasdpas dsaü", "to": ["https://lemmy-test/c/test_mag"] } ================================================ FILE: crates/apub/apub/assets/mbin/objects/instance.json ================================================ { "@context": ["https:\/\/fedia.io\/contexts"], "id": "https:\/\/fedia.io\/i\/actor", "type": "Application", "name": "Mbin", "inbox": "https:\/\/fedia.io\/i\/inbox", "outbox": "https:\/\/fedia.io\/i\/outbox", "preferredUsername": "fedia.io", "manuallyApprovesFollowers": true, "publicKey": { "id": "https:\/\/fedia.io\/i\/actor#main-key", "owner": "https:\/\/fedia.io\/i\/actor", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\r\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAr4P8hDVpL+DpvNvl5s+S\r\nAuRoIl8C00sSfgFfXwMKKxzSEfD2FJBvQuFhu3DSmD26owdItQqKTfMKop7YBvTj\r\nvwngDyBCz9nDSBQtaVG4lPuo\/45Fcdu+jr9SPC7Bd5JFDIejKf86ONDMfFwuz1Ns\r\nyf1sj\/UEVV8fO9CX+aey9E67\/SZ\/SxpbU6b02jY7hWN5wGHvIxJifpRabhVIcTja\r\nV4CZiv6IObKQQ7h\/nK4ly2K7CpA9meljldu2CgV0q6QTnJNqNw12yPhUPVC\/ywOd\r\nUhPUaTEeU0DBuJMzxr9bX9fMLlegYuVV6btOp8JAds0C3rUWv\/bTpymh+CUDL8QL\r\nA9KNqJ\/aFmKZ5z58lRuKW2xJos5ScJnpzkWSHxzC6ZSAvD5zUfCRy42s43qyIcxR\r\nyvzjly9vZIzN5YyDG5QE5YOPMVDSR9cRfq2hi+vVxwTSoYn73EKiRZfVOA\/i6l35\r\nXY6EVPUSykk\/YmOeoKyc4FJQ3ARLBFcI0iOAr1CnseX8KKjGvS3pu3JJdlZEUYv8\r\ngO6kPArPa53VDmlFxRL9uPLR2TGKmVjjLO6SY1sGc1jQAfIvbsg5NY2Q503aLVPB\r\nnZHo\/gtR9ugFfJ8SxnZgGXiuzx6L7+6IZgZYnngGK5KV0h0o7YF2umi5fJJeEtHd\r\nCKOHcjXz2DAn6MD8BCqbfmMCAwEAAQ==\r\n-----END PUBLIC KEY-----" } } ================================================ FILE: crates/apub/apub/assets/mobilizon/objects/event.json ================================================ { "timezone": "Europe/London", "isOnline": false, "contacts": ["https://rendezvous.nomagic.uk/@emorrp1"], "cc": ["https://rendezvous.nomagic.uk/@emorrp1/followers"], "id": "https://rendezvous.nomagic.uk/events/b81c0531-a57c-497d-93ba-af0f8b255498", "inLanguage": "en", "endTime": "2022-12-11T21:00:00+00:00", "repliesModerationOption": "allow_all", "content": "

£6 each.

The dance style is like a posh ceilidh, with some exciting new ways to turn your partner, set patterns and learn some reels by heart. Looking forward to sharing the Hamilton House and the Inverness with those of you who can make it along to this one.

For anyone unfamiliar with ceilidhs, they're very social and energetic dances that are very accessible because everyone gets told exactly what to do by a caller and when to do it. Here's a sample video from when I learned CaledonianDancing at uni. The cover photo is by Dave Conner CC-BY-2.0.

", "category": "SPORTS", "actor": "https://rendezvous.nomagic.uk/@emorrp1", "type": "Event", "url": "https://rendezvous.nomagic.uk/events/b81c0531-a57c-497d-93ba-af0f8b255498", "remainingAttendeeCapacity": null, "anonymousParticipationEnabled": true, "ical:status": "CONFIRMED", "to": ["https://www.w3.org/ns/activitystreams#Public"], "joinMode": "free", "location": { "address": { "addressCountry": "United Kingdom", "addressLocality": "Teignbridge", "addressRegion": "England", "postalCode": "EX6 7TW", "streetAddress": "Devon Expressway", "type": "PostalAddress" }, "id": "https://rendezvous.nomagic.uk/address/e4c95383-15ac-4cc7-adf6-723d74ee2ccc", "latitude": 50.66881615, "longitude": -3.537739788359949, "name": "The Kenn Centre", "type": "Place" }, "startTime": "2022-12-11T19:00:00+00:00", "published": "2022-09-27T14:33:14Z", "draft": false, "participantCount": 0, "uuid": "b81c0531-a57c-497d-93ba-af0f8b255498", "maximumAttendeeCapacity": 0, "tag": [ { "href": "https://rendezvous.nomagic.uk/tags/dance", "name": "#Dance", "type": "Hashtag" }, { "href": "https://rendezvous.nomagic.uk/tags/caledonian", "name": "#Caledonian", "type": "Hashtag" }, { "href": "https://rendezvous.nomagic.uk/tags/lesson", "name": "#Lesson", "type": "Hashtag" } ], "updated": "2022-09-27T14:39:18Z", "attributedTo": "https://rendezvous.nomagic.uk/@devon_caledonian_society", "commentsEnabled": true, "attachment": [ { "mediaType": "image/jpeg", "name": "Banner", "type": "Document", "url": "https://rendezvous.nomagic.uk/media/cd75bf2f61b66004fe20af4797f5aa847ae1f9ea1c118f53093d6fc4e51a6045.jpg?name=devon_caledonian_society%27s%20banner.jpg" } ], "name": "Caledonian scottish dance class", "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "addressRegion": "sc:addressRegion", "timezone": { "@id": "mz:timezone", "@type": "sc:Text" }, "isOnline": { "@id": "mz:isOnline", "@type": "sc:Boolean" }, "pt": "https://joinpeertube.org/ns#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "inLanguage": "sc:inLanguage", "address": { "@id": "sc:address", "@type": "sc:PostalAddress" }, "discoverable": "toot:discoverable", "repliesModerationOption": { "@id": "mz:repliesModerationOption", "@type": "mz:repliesModerationOptionType" }, "sc": "http://schema.org#", "mz": "https://joinmobilizon.org/ns#", "category": "sc:category", "joinModeType": { "@id": "mz:joinModeType", "@type": "rdfs:Class" }, "Hashtag": "as:Hashtag", "propertyID": "sc:propertyID", "PostalAddress": "sc:PostalAddress", "discussions": { "@id": "mz:discussions", "@type": "@id" }, "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity", "streetAddress": "sc:streetAddress", "anonymousParticipationEnabled": { "@id": "mz:anonymousParticipationEnabled", "@type": "sc:Boolean" }, "addressLocality": "sc:addressLocality", "joinMode": { "@id": "mz:joinMode", "@type": "mz:joinModeType" }, "location": { "@id": "sc:location", "@type": "sc:Place" }, "toot": "http://joinmastodon.org/ns#", "participantCount": { "@id": "mz:participantCount", "@type": "sc:Integer" }, "uuid": "sc:identifier", "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity", "participationMessage": { "@id": "mz:participationMessage", "@type": "sc:Text" }, "openness": { "@id": "mz:openness", "@type": "@id" }, "members": { "@id": "mz:members", "@type": "@id" }, "events": { "@id": "mz:events", "@type": "@id" }, "resources": { "@id": "mz:resources", "@type": "@id" }, "addressCountry": "sc:addressCountry", "posts": { "@id": "mz:posts", "@type": "@id" }, "commentsEnabled": { "@id": "pt:commentsEnabled", "@type": "sc:Boolean" }, "value": "sc:value", "PropertyValue": "sc:PropertyValue", "repliesModerationOptionType": { "@id": "mz:repliesModerationOptionType", "@type": "rdfs:Class" }, "todos": { "@id": "mz:todos", "@type": "@id" }, "ical": "http://www.w3.org/2002/12/cal/ical#", "postalCode": "sc:postalCode", "memberCount": { "@id": "mz:memberCount", "@type": "sc:Integer" }, "@language": "und" } ], "mediaType": "text/html" } ================================================ FILE: crates/apub/apub/assets/mobilizon/objects/group.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "addressRegion": "sc:addressRegion", "timezone": { "@id": "mz:timezone", "@type": "sc:Text" }, "isOnline": { "@id": "mz:isOnline", "@type": "sc:Boolean" }, "pt": "https://joinpeertube.org/ns#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "inLanguage": "sc:inLanguage", "address": { "@id": "sc:address", "@type": "sc:PostalAddress" }, "discoverable": "toot:discoverable", "repliesModerationOption": { "@id": "mz:repliesModerationOption", "@type": "mz:repliesModerationOptionType" }, "sc": "http://schema.org#", "mz": "https://joinmobilizon.org/ns#", "category": "sc:category", "joinModeType": { "@id": "mz:joinModeType", "@type": "rdfs:Class" }, "Hashtag": "as:Hashtag", "propertyID": "sc:propertyID", "PostalAddress": "sc:PostalAddress", "discussions": { "@id": "mz:discussions", "@type": "@id" }, "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity", "streetAddress": "sc:streetAddress", "anonymousParticipationEnabled": { "@id": "mz:anonymousParticipationEnabled", "@type": "sc:Boolean" }, "addressLocality": "sc:addressLocality", "joinMode": { "@id": "mz:joinMode", "@type": "mz:joinModeType" }, "location": { "@id": "sc:location", "@type": "sc:Place" }, "toot": "http://joinmastodon.org/ns#", "participantCount": { "@id": "mz:participantCount", "@type": "sc:Integer" }, "uuid": "sc:identifier", "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity", "participationMessage": { "@id": "mz:participationMessage", "@type": "sc:Text" }, "openness": { "@id": "mz:openness", "@type": "@id" }, "members": { "@id": "mz:members", "@type": "@id" }, "events": { "@id": "mz:events", "@type": "@id" }, "resources": { "@id": "mz:resources", "@type": "@id" }, "addressCountry": "sc:addressCountry", "posts": { "@id": "mz:posts", "@type": "@id" }, "commentsEnabled": { "@id": "pt:commentsEnabled", "@type": "sc:Boolean" }, "value": "sc:value", "PropertyValue": "sc:PropertyValue", "repliesModerationOptionType": { "@id": "mz:repliesModerationOptionType", "@type": "rdfs:Class" }, "todos": { "@id": "mz:todos", "@type": "@id" }, "ical": "http://www.w3.org/2002/12/cal/ical#", "postalCode": "sc:postalCode", "memberCount": { "@id": "mz:memberCount", "@type": "sc:Integer" }, "@language": "und" } ], "discoverable": true, "discussions": "https://mobilizon.fr/@contribateliers/discussions", "endpoints": { "discussions": "https://mobilizon.fr/@contribateliers/discussions", "events": "https://mobilizon.fr/@contribateliers/events", "members": "https://mobilizon.fr/@contribateliers/members", "posts": "https://mobilizon.fr/@contribateliers/posts", "resources": "https://mobilizon.fr/@contribateliers/resources", "sharedInbox": "https://mobilizon.fr/inbox", "todos": "https://mobilizon.fr/@contribateliers/todos" }, "events": "https://mobilizon.fr/@contribateliers/events", "followers": "https://mobilizon.fr/@contribateliers/followers", "following": "https://mobilizon.fr/@contribateliers/following", "icon": { "mediaType": null, "type": "Image", "url": "https://mobilizon.fr/media/a94f7f8da4b39f6b375f55bd8664abff4ae61d33496df7ee23ad6bf473c3632f.png?name=contribateliers%27s%20avatar.png" }, "id": "https://mobilizon.fr/@contribateliers", "image": { "mediaType": null, "type": "Image", "url": "https://mobilizon.fr/media/7fe251dd5f8b5abcea10c31b655c09afee457efdd49f3087ba78b054b3f0dbeb.jpg?name=contribateliers%27s%20banner.jpg" }, "inbox": "https://mobilizon.fr/@contribateliers/inbox", "location": { "address": { "addressCountry": null, "addressLocality": null, "addressRegion": null, "postalCode": null, "streetAddress": null, "type": "PostalAddress" }, "id": "https://mobilizon.fr/address/935f207e-4c0f-4818-8762-51d6ab2ed27e", "name": null, "type": "Place" }, "manuallyApprovesFollowers": false, "memberCount": 13, "members": "https://mobilizon.fr/@contribateliers/members", "name": "Contribateliers", "openness": "open", "outbox": "https://mobilizon.fr/@contribateliers/outbox", "posts": "https://mobilizon.fr/@contribateliers/posts", "preferredUsername": "contribateliers", "publicKey": { "id": "https://mobilizon.fr/@contribateliers#main-key", "owner": "https://mobilizon.fr/@contribateliers", "publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEA1laog+0zKOkGdUHfWQ+lIJq5LOwWzGKLeqXzSdvaUzfk2X5Q5gTf\nbjh7pWJaWo2uxrIeNKRJSpmxeBn/lNR3+OrG05/MiYW6Y42q+ZL18coUDht46u23\nHH9+fFblmvY905cNslJ4/NouxpN0ai5JytZOzlNnJCan241rS4gkeLAy+LDW6UOd\nTvDPMJQlrAl8gr+OamRUxd4RL/8ws7/FbqNiAetXmN/5knjkQe5rFi0D/3fQtWEv\n/kSTG6CmnBhpeKE8eqp1sD0+CMROfOb7ceVIpJvUKAPHsENRE6DQFF9j3wl8AXjd\ndtGxTyOYYaMXCPyAUBjH/Rt6uV5Bc5x2CQIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" }, "resources": "https://mobilizon.fr/@contribateliers/resources", "summary": "

Des ateliers pour contribuer au libre sans rien y connaître.

", "todos": "https://mobilizon.fr/@contribateliers/todos", "type": "Group", "url": "https://mobilizon.fr/@contribateliers" } ================================================ FILE: crates/apub/apub/assets/mobilizon/objects/person.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "addressRegion": "sc:addressRegion", "timezone": { "@id": "mz:timezone", "@type": "sc:Text" }, "isOnline": { "@id": "mz:isOnline", "@type": "sc:Boolean" }, "pt": "https://joinpeertube.org/ns#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "inLanguage": "sc:inLanguage", "address": { "@id": "sc:address", "@type": "sc:PostalAddress" }, "discoverable": "toot:discoverable", "repliesModerationOption": { "@id": "mz:repliesModerationOption", "@type": "mz:repliesModerationOptionType" }, "sc": "http://schema.org#", "mz": "https://joinmobilizon.org/ns#", "category": "sc:category", "joinModeType": { "@id": "mz:joinModeType", "@type": "rdfs:Class" }, "Hashtag": "as:Hashtag", "propertyID": "sc:propertyID", "PostalAddress": "sc:PostalAddress", "discussions": { "@id": "mz:discussions", "@type": "@id" }, "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity", "streetAddress": "sc:streetAddress", "anonymousParticipationEnabled": { "@id": "mz:anonymousParticipationEnabled", "@type": "sc:Boolean" }, "addressLocality": "sc:addressLocality", "joinMode": { "@id": "mz:joinMode", "@type": "mz:joinModeType" }, "location": { "@id": "sc:location", "@type": "sc:Place" }, "toot": "http://joinmastodon.org/ns#", "participantCount": { "@id": "mz:participantCount", "@type": "sc:Integer" }, "uuid": "sc:identifier", "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity", "participationMessage": { "@id": "mz:participationMessage", "@type": "sc:Text" }, "openness": { "@id": "mz:openness", "@type": "@id" }, "members": { "@id": "mz:members", "@type": "@id" }, "events": { "@id": "mz:events", "@type": "@id" }, "resources": { "@id": "mz:resources", "@type": "@id" }, "addressCountry": "sc:addressCountry", "posts": { "@id": "mz:posts", "@type": "@id" }, "commentsEnabled": { "@id": "pt:commentsEnabled", "@type": "sc:Boolean" }, "value": "sc:value", "PropertyValue": "sc:PropertyValue", "repliesModerationOptionType": { "@id": "mz:repliesModerationOptionType", "@type": "rdfs:Class" }, "todos": { "@id": "mz:todos", "@type": "@id" }, "ical": "http://www.w3.org/2002/12/cal/ical#", "postalCode": "sc:postalCode", "memberCount": { "@id": "mz:memberCount", "@type": "sc:Integer" }, "@language": "und" } ], "discoverable": false, "discussions": null, "endpoints": { "discussions": null, "events": null, "members": null, "posts": null, "resources": null, "sharedInbox": "https://mobilizon.fr/inbox", "todos": null }, "events": null, "followers": "https://mobilizon.fr/@sanof44/followers", "following": "https://mobilizon.fr/@sanof44/following", "id": "https://mobilizon.fr/@sanof44", "inbox": "https://mobilizon.fr/@sanof44/inbox", "manuallyApprovesFollowers": false, "members": null, "name": "Sanof44", "openness": "moderated", "outbox": "https://mobilizon.fr/@sanof44/outbox", "posts": null, "preferredUsername": "sanof44", "publicKey": { "id": "https://mobilizon.fr/@sanof44#main-key", "owner": "https://mobilizon.fr/@sanof44", "publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAneK5zzdQQ/6ElSpPv1mj34IMoIIHcTK+iEjZYd85yPfG4krK5bqI\nkUw0TUXFekpntLfDSsGohayrvD2WhN2b499y/A9wdl77RVLIAcBfE3UXr/TfDnjh\nsQEEzV4ghcYaKmXZa/ct2sSt6poT/WhahVweEugfyA75UHgW5VA7nS5URhd7uZUw\nS2CI8fXigDbJlB9AqcxvR7Uncgsn0JCCt5boP8X1jDrh5PEsqsqePm9ZpxvvX4WD\n1yib/ZPBsTo50hJgHoA9bLXO14KvAOeIrzgOlJkyjWTQ+rk+5ewXIZuM0ECPEzAC\nRcpopBjqk07lMxPu1OMG4D+oI0n0K+PgNwIDAQAB\n-----END RSA PUBLIC KEY-----\n\n" }, "resources": null, "summary": "", "todos": null, "type": "Person", "url": "https://mobilizon.fr/@sanof44" } ================================================ FILE: crates/apub/apub/assets/nodebb/objects/group.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id": "https://bb.devnull.land/category/2", "url": "https://bb.devnull.land/category/2/general-discussion", "inbox": "https://bb.devnull.land/category/2/inbox", "outbox": "https://bb.devnull.land/category/2/outbox", "type": "Group", "name": "General Discussion", "preferredUsername": "general", "summary": "

A place to talk about whatever you want

\n

This is a forum category containing topical discussion. You can start new discussions by mentioning this category.

\n", "icon": { "type": "Image", "mediaType": "image/png", "url": "https://bb.devnull.land/assets/uploads/category/category-2-icon.png" }, "publicKey": { "id": "https://bb.devnull.land/category/2#key", "owner": "https://bb.devnull.land/category/2", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAunqTJfBQ1PpsKW6/EDGe\nMFQubvI8vL9VomZBfcgJWbEtCmAwpaR7LP+Du/OcoRDbkm04BQJR0xT2QEOO4YDs\nZjm520C0O34iw/bUdmnFMJEaFiyJUn7zEaqPIUf3+etPslAdq3rivHXYlpuP2i1U\nHGvLO4N08k6LDpAbgO5sdXGPP2k2HAo8Sch8PEqdiMj68i5v1cIz4Q0vvPnAf5UY\nmeMbnQg7yx0o+WPu6QTmd7DFYfGG36LOfRJKpqjzlVXjq2iQhU4XiqHWG0KJvzQ7\nsIqlAtb3ESVchDamNzHQi6rsMJR9zCZpcUt8VnxiL7B8tVKk4mBoHxkISY9Sj9iO\nnQIDAQAB\n-----END PUBLIC KEY-----\n" }, "endpoints": { "sharedInbox": "https://bb.devnull.land/inbox" } } ================================================ FILE: crates/apub/apub/assets/nodebb/objects/page.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "toot": "http://joinmastodon.org/ns#", "Emoji": "toot:Emoji" } ], "id": "https://bb.devnull.land/post/1", "type": "Article", "to": [ "https://www.w3.org/ns/activitystreams#Public", "https://bb.devnull.land/category/2" ], "cc": ["https://bb.devnull.land/uid/1/followers"], "inReplyTo": null, "published": "2025-10-07T15:22:42.176Z", "updated": null, "url": "https://bb.devnull.land/post/1", "attributedTo": "https://bb.devnull.land/uid/1", "context": "https://bb.devnull.land/topic/1", "audience": "https://bb.devnull.land/category/2", "summary": "

Welcome to your brand new NodeBB forum!

This is what a topic and post looks like. As an administrator, you can edit the post's title and content.
To customise your forum, go to the Administrator Control Panel. You can modify all aspects of your forum there, including installation of third-party plugins.

Additional Resources

", "name": "Welcome to your NodeBB!", "preview": { "type": "Note", "attributedTo": "https://bb.devnull.land/uid/1", "content": "

Welcome to your brand new NodeBB forum!

\n

This is what a topic and post looks like. As an administrator, you can edit the post's title and content.
\nTo customise your forum, go to the Administrator Control Panel. You can modify all aspects of your forum there, including installation of third-party plugins.

\n

Additional Resources

\n\n", "published": "2025-10-07T15:22:42.176Z", "attachment": [] }, "content": "

Welcome to your brand new NodeBB forum!

\n

This is what a topic and post looks like. As an administrator, you can edit the post's title and content.
\nTo customise your forum, go to the Administrator Control Panel. You can modify all aspects of your forum there, including installation of third-party plugins.

\n

Additional Resources

\n\n", "source": { "content": "### Welcome to your brand new NodeBB forum!\n\nThis is what a topic and post looks like. As an administrator, you can edit the post\\'s title and content.\nTo customise your forum, go to the [Administrator Control Panel](https://bb.devnull.land/admin). You can modify all aspects of your forum there, including installation of third-party plugins.\n\n#### Additional Resources\n\n* [NodeBB Documentation](https://docs.nodebb.org/)\n* [Community SupportForum](https://community.nodebb.org/)\n* [Project repository](https://github.com/nodebb/nodebb)", "mediaType": "text/markdown" }, "tag": [], "attachment": [], "replies": "https://bb.devnull.land/post/1/replies" } ================================================ FILE: crates/apub/apub/assets/nodebb/objects/person.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id": "https://bb.devnull.land/uid/1", "url": "https://bb.devnull.land/user/julian", "followers": "https://bb.devnull.land/uid/1/followers", "following": "https://bb.devnull.land/uid/1/following", "inbox": "https://bb.devnull.land/uid/1/inbox", "outbox": "https://bb.devnull.land/uid/1/outbox", "type": "Person", "name": "julian", "preferredUsername": "julian", "summary": "

This is a test account for NodeBB ActivityPub Development

\n", "icon": null, "image": null, "published": "2025-10-07T15:22:41.457Z", "attachment": [], "publicKey": { "id": "https://bb.devnull.land/uid/1#key", "owner": "https://bb.devnull.land/uid/1", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArSOkxd0fjmW69jN5CnJ2\nLLw+A4TGjMKqDVlPUTuY3trkCk4O1jVa3d+kyC+y8CF56VNHOQTjkq4po0b+2k6V\nDtaGTexrjPVvRqJhk7+3trP6t584jT9IxEXy6hs72CatpJGN3/tEgAfLXpDnw6pa\nRBbGDX31lri7ssdHcIlR9TioK3+U1RisGKmuuGjuv1WDEiR5arbBbIfzNsfMyjgr\nMWIR10VkE7axy/ybO8y6kATVtSoq8qQcw2/KJwzqnDPmZPtmbX/93JP6+sIBXS4T\nf409xGZp3fHVEDRqLFiEAn9rHg4cxRp71DFql2EpRdt/Z1oZGZpN9y4VJRoLXGdC\nAwIDAQAB\n-----END PUBLIC KEY-----\n" }, "endpoints": { "sharedInbox": "https://bb.devnull.land/inbox" } } ================================================ FILE: crates/apub/apub/assets/peertube/activities/announce_video.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" } ], "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://tilvids.com/accounts/thelinuxexperiment/followers"], "type": "Announce", "id": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/announces/299", "actor": "https://tilvids.com/video-channels/thelinuxexperiment_channel", "object": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1" } ================================================ FILE: crates/apub/apub/assets/peertube/objects/group.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" }, { "pt": "https://joinpeertube.org/ns#", "sc": "http://schema.org/", "playlists": { "@id": "pt:playlists", "@type": "@id" }, "support": { "@type": "sc:Text", "@id": "pt:support" }, "lemmy": "https://join-lemmy.org/ns#", "postingRestrictedToMods": "lemmy:postingRestrictedToMods" } ], "type": "Group", "id": "https://tilvids.com/video-channels/thelinuxexperiment_channel", "following": "https://tilvids.com/video-channels/thelinuxexperiment_channel/following", "followers": "https://tilvids.com/video-channels/thelinuxexperiment_channel/followers", "playlists": "https://tilvids.com/video-channels/thelinuxexperiment_channel/playlists", "inbox": "https://tilvids.com/video-channels/thelinuxexperiment_channel/inbox", "outbox": "https://tilvids.com/video-channels/thelinuxexperiment_channel/outbox", "preferredUsername": "thelinuxexperiment_channel", "url": "https://tilvids.com/video-channels/thelinuxexperiment_channel", "name": "The Linux Experiment", "endpoints": { "sharedInbox": "https://tilvids.com/inbox" }, "publicKey": { "id": "https://tilvids.com/video-channels/thelinuxexperiment_channel#main-key", "owner": "https://tilvids.com/video-channels/thelinuxexperiment_channel", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7mWF3Il0lE+nWiArDK4B\n8Z9rUCYR/C9651CcqPFIpHFLkJgoAkYxeMqfCo7lbXil1abaQERjgAYAJtdfObvY\neqUrHejEHAClFIO5BilyTP8b02RVZX6xxtTNF7jUEePFI0xOtPtt3Yz+YP0c6rz6\noyCCpqTy8LRfDkD9RATQrYfFxZCQ2yo2SlCoymNrDjoVwPI0XMZWHyMthKcaVwAq\ni+dYd0pmNUxdY9V042tIg+YwR3mOYvkXCNqy1SDygcIY6N5kdqioFoKxMK3MFApK\nY7tkfZkZXLlBdzHjjtYGHictaZzNYl4HV6onx//A21w0A7dGimlYd5bYLwz/BteD\nTwIDAQAB\n-----END PUBLIC KEY-----" }, "published": "2020-06-30T13:45:17.984Z", "icon": [ { "type": "Image", "mediaType": "image/jpeg", "height": 48, "width": 48, "url": "https://tilvids.com/lazy-static/avatars/1bbe97f1-d283-4db4-8bdd-e5320564aff9.jpg" }, { "type": "Image", "mediaType": "image/jpeg", "height": 120, "width": 120, "url": "https://tilvids.com/lazy-static/avatars/13b0214b-edc0-4c5b-a04d-be648a3a370a.jpg" } ], "image": [ { "type": "Image", "mediaType": "image/jpeg", "height": 317, "width": 1920, "url": "https://tilvids.com/lazy-static/banners/1a8d6881-30c8-47cb-8576-7af62d869c45.jpg" } ], "summary": "I'm Nick, and I like to tinker with Linux stuff. I'll bumble through distro reviews, tutorials, and general helpful tidbits and impressions on Linux desktop environments, applications, and news. \n\nYou might see a bit of Linux gaming here and there, and some more personal opinion pieces, but in the end, it's more or less all about Linux and FOSS !\n\nIf you want to stay up to snuff, follow me on Mastodon: https://mastodon.social/@thelinuxEXP \n\nIf you can, consider supporting the channel here: \nhttps://www.patreon.com/thelinuxexperiment", "support": "Support the channel on Patreon: \nhttps://www.patreon.com/thelinuxexperiment\n\nSupport on Liberapay:\nhttps://liberapay.com/TheLinuxExperiment/", "postingRestrictedToMods": true, "attributedTo": [ { "type": "Person", "id": "https://tilvids.com/accounts/thelinuxexperiment" } ] } ================================================ FILE: crates/apub/apub/assets/peertube/objects/note.json ================================================ { "type": "Note", "id": "https://video.antopie.org/videos/watch/4294a720-f263-4ea4-9392-cf9cea4d5277/comments/200873", "content": "@af2@bae.st idk", "mediaType": "text/markdown", "inReplyTo": "https://bae.st/objects/87c1cbf5-542a-491d-af57-0414c8648381", "updated": "2022-04-29T07:52:32.555Z", "published": "2022-04-29T07:52:32.548Z", "url": "https://video.antopie.org/videos/watch/4294a720-f263-4ea4-9392-cf9cea4d5277/comments/200873", "attributedTo": "https://video.antopie.org/accounts/yoge6785555", "tag": [ { "type": "Mention", "href": "https://bae.st/users/af2", "name": "@af2@bae.st" } ], "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://video.antopie.org/accounts/yoge6785555/followers"], "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" }, { "pt": "https://joinpeertube.org/ns#", "sc": "http://schema.org#", "Hashtag": "as:Hashtag", "uuid": "sc:identifier", "category": "sc:category", "licence": "sc:license", "subtitleLanguage": "sc:subtitleLanguage", "sensitive": "as:sensitive", "language": "sc:inLanguage", "isLiveBroadcast": "sc:isLiveBroadcast", "liveSaveReplay": { "@type": "sc:Boolean", "@id": "pt:liveSaveReplay" }, "permanentLive": { "@type": "sc:Boolean", "@id": "pt:permanentLive" }, "Infohash": "pt:Infohash", "Playlist": "pt:Playlist", "PlaylistElement": "pt:PlaylistElement", "originallyPublishedAt": "sc:datePublished", "views": { "@type": "sc:Number", "@id": "pt:views" }, "state": { "@type": "sc:Number", "@id": "pt:state" }, "size": { "@type": "sc:Number", "@id": "pt:size" }, "fps": { "@type": "sc:Number", "@id": "pt:fps" }, "startTimestamp": { "@type": "sc:Number", "@id": "pt:startTimestamp" }, "stopTimestamp": { "@type": "sc:Number", "@id": "pt:stopTimestamp" }, "position": { "@type": "sc:Number", "@id": "pt:position" }, "commentsEnabled": { "@type": "sc:Boolean", "@id": "pt:commentsEnabled" }, "downloadEnabled": { "@type": "sc:Boolean", "@id": "pt:downloadEnabled" }, "waitTranscoding": { "@type": "sc:Boolean", "@id": "pt:waitTranscoding" }, "support": { "@type": "sc:Text", "@id": "pt:support" }, "likes": { "@id": "as:likes", "@type": "@id" }, "dislikes": { "@id": "as:dislikes", "@type": "@id" }, "playlists": { "@id": "pt:playlists", "@type": "@id" }, "shares": { "@id": "as:shares", "@type": "@id" }, "comments": { "@id": "as:comments", "@type": "@id" } } ] } ================================================ FILE: crates/apub/apub/assets/peertube/objects/person.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" }, { "pt": "https://joinpeertube.org/ns#", "sc": "http://schema.org/", "playlists": { "@id": "pt:playlists", "@type": "@id" }, "support": { "@type": "sc:Text", "@id": "pt:support" }, "lemmy": "https://join-lemmy.org/ns#", "postingRestrictedToMods": "lemmy:postingRestrictedToMods" } ], "type": "Person", "id": "https://tilvids.com/accounts/thelinuxexperiment", "following": "https://tilvids.com/accounts/thelinuxexperiment/following", "followers": "https://tilvids.com/accounts/thelinuxexperiment/followers", "playlists": "https://tilvids.com/accounts/thelinuxexperiment/playlists", "inbox": "https://tilvids.com/accounts/thelinuxexperiment/inbox", "outbox": "https://tilvids.com/accounts/thelinuxexperiment/outbox", "preferredUsername": "thelinuxexperiment", "url": "https://tilvids.com/accounts/thelinuxexperiment", "name": "The Linux Experiment", "endpoints": { "sharedInbox": "https://tilvids.com/inbox" }, "publicKey": { "id": "https://tilvids.com/accounts/thelinuxexperiment#main-key", "owner": "https://tilvids.com/accounts/thelinuxexperiment", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqbMvBSLhwEA3VXQ3TPgd\nDCeVpicrjGlk5tRg9OMBMY/xRhT4M3T8H2uYMUmIQJubUcooqAImWL7bYyXig0Ms\nby18vLyAgIR7V7ymvJbJxF2WZV33CC7Ad1yjqLlnhydcG+pWKWqkjP7SXzAy/EHo\n46OhDQK1+Q6FXfDrLAGEDRq5z+qTi5dh1hi/c9ZvI0+3PBg1IfAf5zLeo1AoydV7\nvISCm7kyClABwOW3OjPP86SbAlQL6STFOO3s6EdvvVifTkacC/gl8ad8TI8610Wa\n5wLsjdE8LIky9lLUsFYvVPrJ6v5havxCSmc6W1tkDicitpFylN2X914L36bn609M\n8QIDAQAB\n-----END PUBLIC KEY-----" }, "published": "2020-06-30T13:45:17.950Z", "icon": [ { "type": "Image", "mediaType": "image/jpeg", "height": 48, "width": 48, "url": "https://tilvids.com/lazy-static/avatars/e74c2c6b-1f6b-4506-9d03-2cbba1635b20.jpg" }, { "type": "Image", "mediaType": "image/jpeg", "height": 120, "width": 120, "url": "https://tilvids.com/lazy-static/avatars/bdaa7218-ba3c-43ba-abd3-cfd081394c18.jpg" } ], "summary": "I'm Nick, and I like to tinker with Linux stuff. I'll bumble through distro reviews, tutorials, and general helpful tidbits and impressions on Linux desktop environments, applications, and news. \n\nYou might see a bit of Linux gaming here and there, and some more personal opinion pieces, but in the end, it's more or less all about Linux and FOSS !\n\nIf you want to stay up to snuff, follow me on Mastodon @TheLinuxEXP@mastodon.social" } ================================================ FILE: crates/apub/apub/assets/peertube/objects/video.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" }, { "pt": "https://joinpeertube.org/ns#", "sc": "http://schema.org/", "Hashtag": "as:Hashtag", "category": "sc:category", "licence": "sc:license", "subtitleLanguage": "sc:subtitleLanguage", "automaticallyGenerated": "pt:automaticallyGenerated", "sensitive": "as:sensitive", "language": "sc:inLanguage", "identifier": "sc:identifier", "isLiveBroadcast": "sc:isLiveBroadcast", "liveSaveReplay": { "@type": "sc:Boolean", "@id": "pt:liveSaveReplay" }, "permanentLive": { "@type": "sc:Boolean", "@id": "pt:permanentLive" }, "latencyMode": { "@type": "sc:Number", "@id": "pt:latencyMode" }, "Infohash": "pt:Infohash", "tileWidth": { "@type": "sc:Number", "@id": "pt:tileWidth" }, "tileHeight": { "@type": "sc:Number", "@id": "pt:tileHeight" }, "tileDuration": { "@type": "sc:Number", "@id": "pt:tileDuration" }, "aspectRatio": { "@type": "sc:Float", "@id": "pt:aspectRatio" }, "uuid": { "@type": "sc:identifier", "@id": "pt:uuid" }, "originallyPublishedAt": "sc:datePublished", "uploadDate": "sc:uploadDate", "hasParts": "sc:hasParts", "views": { "@type": "sc:Number", "@id": "pt:views" }, "state": { "@type": "sc:Number", "@id": "pt:state" }, "size": { "@type": "sc:Number", "@id": "pt:size" }, "fps": { "@type": "sc:Number", "@id": "pt:fps" }, "commentsEnabled": { "@type": "sc:Boolean", "@id": "pt:commentsEnabled" }, "canReply": "pt:canReply", "commentsPolicy": { "@type": "sc:Number", "@id": "pt:commentsPolicy" }, "downloadEnabled": { "@type": "sc:Boolean", "@id": "pt:downloadEnabled" }, "waitTranscoding": { "@type": "sc:Boolean", "@id": "pt:waitTranscoding" }, "support": { "@type": "sc:Text", "@id": "pt:support" }, "likes": { "@id": "as:likes", "@type": "@id" }, "dislikes": { "@id": "as:dislikes", "@type": "@id" }, "shares": { "@id": "as:shares", "@type": "@id" }, "comments": { "@id": "as:comments", "@type": "@id" }, "PropertyValue": "sc:PropertyValue", "value": "sc:value" } ], "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://tilvids.com/accounts/thelinuxexperiment/followers"], "type": "Video", "id": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1", "name": "Mesa, Wayland & X.org in trouble, Debian leaves X, Facebook blocks Linux: Linux & Open Source News", "duration": "PT1145S", "uuid": "e7946124-7b72-4ad7-9d22-844a84bb2de1", "category": { "identifier": "15", "name": "Science & Technology" }, "licence": { "identifier": "2", "name": "Attribution - Share Alike" }, "language": { "identifier": "en", "name": "English" }, "views": 360, "sensitive": false, "waitTranscoding": true, "state": 1, "commentsEnabled": true, "canReply": null, "commentsPolicy": 1, "downloadEnabled": true, "published": "2025-02-01T11:59:45.094Z", "originallyPublishedAt": "2025-02-01T11:39:50.000Z", "updated": "2025-02-04T09:00:50.396Z", "tag": [], "mediaType": "text/markdown", "content": "Head to https://squarespace.com/thelinuxexperiment to save 10% off your first purchase of a website or domain using code thelinuxexperiment\r\n\r\nGrab a brand new laptop or desktop running Linux: https://www.tuxedocomputers.com/en# \r\n\r\n\r\n👏 SUPPORT THE CHANNEL:\r\nGet access to:\r\n- a Daily Linux News show\r\n- a weekly patroncast for more personal thoughts\r\n- polls on the next topics I cover,\r\n- your name in the credits\r\n\r\nYouTube: https://www.youtube.com/@thelinuxexp/join\r\nPatreon: https://www.patreon.com/thelinuxexperiment\r\n\r\nOr, you can donate whatever you want:\r\nhttps://paypal.me/thelinuxexp\r\nLiberapay: https://liberapay.com/TheLinuxExperiment/\r\n\r\n👕 GET TLE MERCH\r\nSupport the channel AND get cool new gear: https://the-linux-experiment.creator-spring.com/\r\n\r\n🏆 FOLLOW ME ON THE FEDIVERSE:\r\nMastodon: https://mastodon.social/web/@thelinuxEXP\r\nPixelfed: https://pixelfed.social/TLENick\r\nPeerTube: https://tilvids.com/c/thelinuxexperiment_channel/videos\r\n\r\n🎙 LINUX AND OPEN SOURCE NEWS PODCAST:\r\nListen to the latestLinux and open source news, with more in depth coverage, and ad-free! https://podcast.thelinuxexp.com\r\n\r\nTimestamps:\r\n00:00 Intro\r\n00:34 Sponsor: Squarespace\r\n01:42 Mesa, Wayland and X.org lose their hosting\r\n03:57 Debian quits Twitter\r\n06:07 GNOME 48 alpha is out\r\n08:14 Kernel wifi maintainersteps down without replacement\r\n10:15 Facebook blocked posts linked to Linux\r\n12:12 OpenAI accuses another model of stealing their stolen work\r\n14:13Steam Deck is getting outclassed\r\n17:15 Sponsor: Tuxedo Computers\r\n18:10 Support the channel\r\n\r\nLinks:\r\n\r\nMesa, Wayland and X.org lose their hosting\r\nhttps://www.phoronix.com/news/2025-XOrg-FreeDesktop-Cloud\r\n\r\nDebian quits Twitter\r\nhttps://news.itsfoss.com/debian-logs-off-twitter/\r\n\r\nGNOME 48 alpha is out\r\nhttps://discourse.gnome.org/t/gnome-48-alpha-released/26414\r\nhttps://download.gnome.org/teams/releng/48.alpha.8/NEWS\r\n\r\nKernelwifi maintainer steps down without replacement\r\nhttps://linuxiac.com/linux-kernel-surpasses-40-million-lines/\r\nhttps://www.phoronix.com/news/Linux-Wireless-Maintainer-2025\r\n\r\nFacebook blocking posts linked to Linux\r\nhttps://distrowatch.com/weekly.php?issue=20250127#sitenews\r\nhttps://www.theregister.com/2025/01/28/facebook_blocks_distrowatch/\r\n\r\nOpenAI accuses another model of stealing their stolen work\r\nhttps://www.techradar.com/pro/us-navy-bans-use-of-deepseek-in-any-capacity-due-to-potential-security-and-ethical-concerns\r\nhttps://www.techradar.com/computing/artificial-intelligence/openai-says-deepseek-used-its-models-illegally-and-it-has-evidence-to-prove-it-new-report-claims\r\n\r\nSteam Deck is getting outclassed\r\nhttps://www.forbes.com/sites/jasonevangelho/2025/01/28/the-steam-deck-suddenly-has-a-serious-switch-2-problem/", "support": "Support the channel on Patreon: \r\nhttps://www.patreon.com/thelinuxexperiment\r\n\r\nSupport on Liberapay:\r\nhttps://liberapay.com/TheLinuxExperiment/", "subtitleLanguage": [], "icon": [ { "type": "Image", "url": "https://tilvids.com/lazy-static/thumbnails/904efceb-0715-476f-b0dc-b5fba6769851.jpg", "mediaType": "image/jpeg", "width": 280, "height": 157 }, { "type": "Image", "url": "https://tilvids.com/lazy-static/previews/ef6088ee-c83a-4fcf-8be2-58db95ca5135.jpg", "mediaType": "image/jpeg", "width": 850, "height": 480 } ], "preview": [ { "type": "Image", "rel": ["storyboard"], "url": [ { "mediaType": "image/jpeg", "href": "https://tilvids.com/lazy-static/storyboards/b94d7ec4-97d4-4860-a4aa-220c5cf5beae.jpg", "width": 2112, "height": 1188, "tileWidth": 192, "tileHeight": 108, "tileDuration": "PT10S" } ] } ], "aspectRatio": 1.7778, "url": [ { "type": "Link", "mediaType": "text/html", "href": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1" }, { "type": "Link", "mediaType": "application/x-mpegURL", "href": "https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/2020efb9-9f43-4e37-b268-3470a4bb89cd-master.m3u8", "tag": [ { "type": "Infohash", "name": "bade027756842ecef7a1fb7b437dcaa52eb72350" }, { "type": "Infohash", "name": "dc1091029454a93ae893b207cfb1e7faf8d4d8b8" }, { "type": "Infohash", "name": "c83b5123b8dcb1b81b53fbdb4c95903cf61a2022" }, { "type": "Link", "name": "sha256", "mediaType": "application/json", "href": "https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/0c0d34b1-ab46-4fc8-ae02-c97c23bfb2db-segments-sha256.json" }, { "type": "Link", "mediaType": "video/mp4", "href": "https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/0b870685-4461-47a3-8fac-e5531cd8acf5-1080-fragmented.mp4", "height": 1080, "width": 1920, "size": 245864545, "fps": 60, "attachment": [ { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "peertube_format_flag", "value": "fragmented" } ] }, { "type": "Link", "rel": ["metadata", "video/mp4"], "mediaType": "application/json", "href": "https://tilvids.com/api/v1/videos/e7946124-7b72-4ad7-9d22-844a84bb2de1/metadata/729362", "height": 1080, "width": 1920, "fps": 60 }, { "type": "Link", "mediaType": "application/x-bittorrent", "href": "https://tilvids.com/lazy-static/torrents/cf3222e4-b9fe-4cb3-8b43-2da8afd83895-1080-hls.torrent", "height": 1080, "width": 1920, "fps": 60 }, { "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xs=https%3A%2F%2Ftilvids.com%2Flazy-static%2Ftorrents%2Fcf3222e4-b9fe-4cb3-8b43-2da8afd83895-1080-hls.torrent&xt=urn:btih:f9b4ddffa454ad6a7d5d7000d307c33f84aba1d1&dn=Mesa%2C+Wayland+%26+X.org+in+trouble%2C+Debian+leaves+X%2C+Facebook+blocks+Linux%3A+Linux+%26+Open+Source+News&tr=https%3A%2F%2Ftilvids.com%2Ftracker%2Fannounce&tr=wss%3A%2F%2Ftilvids.com%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Ftilvids.com%2Fstatic%2Fstreaming-playlists%2Fhls%2Fe7946124-7b72-4ad7-9d22-844a84bb2de1%2F0b870685-4461-47a3-8fac-e5531cd8acf5-1080-fragmented.mp4", "height": 1080, "width": 1920, "fps": 60 }, { "type": "Link", "mediaType": "video/mp4", "href": "https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/339ea14b-0fb9-495b-870e-218a9a6c22f9-360-fragmented.mp4", "height": 360, "width": 640, "size": 62546436, "fps": 30, "attachment": [ { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "peertube_format_flag", "value": "fragmented" } ] }, { "type": "Link", "rel": ["metadata", "video/mp4"], "mediaType": "application/json", "href": "https://tilvids.com/api/v1/videos/e7946124-7b72-4ad7-9d22-844a84bb2de1/metadata/729352", "height": 360, "width": 640, "fps": 30 }, { "type": "Link", "mediaType": "application/x-bittorrent", "href": "https://tilvids.com/lazy-static/torrents/dbdbd47d-42e8-4544-bb78-ae7835312cab-360-hls.torrent", "height": 360, "width": 640, "fps": 30 }, { "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xs=https%3A%2F%2Ftilvids.com%2Flazy-static%2Ftorrents%2Fdbdbd47d-42e8-4544-bb78-ae7835312cab-360-hls.torrent&xt=urn:btih:913416ac02f6bbfe7bb46e0b19bfe2a4a48d40b8&dn=Mesa%2C+Wayland+%26+X.org+in+trouble%2C+Debian+leaves+X%2C+Facebook+blocks+Linux%3A+Linux+%26+Open+Source+News&tr=https%3A%2F%2Ftilvids.com%2Ftracker%2Fannounce&tr=wss%3A%2F%2Ftilvids.com%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Ftilvids.com%2Fstatic%2Fstreaming-playlists%2Fhls%2Fe7946124-7b72-4ad7-9d22-844a84bb2de1%2F339ea14b-0fb9-495b-870e-218a9a6c22f9-360-fragmented.mp4", "height": 360, "width": 640, "fps": 30 }, { "type": "Link", "mediaType": "video/mp4", "href": "https://tilvids.com/static/streaming-playlists/hls/e7946124-7b72-4ad7-9d22-844a84bb2de1/15585bf4-ff07-4687-8c01-537922958877-144-fragmented.mp4", "height": 144, "width": 256, "size": 31021375, "fps": 30, "attachment": [ { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "audio" }, { "type": "PropertyValue", "name": "ffprobe_codec_type", "value": "video" }, { "type": "PropertyValue", "name": "peertube_format_flag", "value": "fragmented" } ] }, { "type": "Link", "rel": ["metadata", "video/mp4"], "mediaType": "application/json", "href": "https://tilvids.com/api/v1/videos/e7946124-7b72-4ad7-9d22-844a84bb2de1/metadata/729356", "height": 144, "width": 256, "fps": 30 }, { "type": "Link", "mediaType": "application/x-bittorrent", "href": "https://tilvids.com/lazy-static/torrents/f8a4e994-7be7-46b5-b823-29e041baf687-144-hls.torrent", "height": 144, "width": 256, "fps": 30 }, { "type": "Link", "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", "href": "magnet:?xs=https%3A%2F%2Ftilvids.com%2Flazy-static%2Ftorrents%2Ff8a4e994-7be7-46b5-b823-29e041baf687-144-hls.torrent&xt=urn:btih:6594dbb8a43e77ae7565fcd5744019f630c97706&dn=Mesa%2C+Wayland+%26+X.org+in+trouble%2C+Debian+leaves+X%2C+Facebook+blocks+Linux%3A+Linux+%26+Open+Source+News&tr=https%3A%2F%2Ftilvids.com%2Ftracker%2Fannounce&tr=wss%3A%2F%2Ftilvids.com%3A443%2Ftracker%2Fsocket&ws=https%3A%2F%2Ftilvids.com%2Fstatic%2Fstreaming-playlists%2Fhls%2Fe7946124-7b72-4ad7-9d22-844a84bb2de1%2F15585bf4-ff07-4687-8c01-537922958877-144-fragmented.mp4", "height": 144, "width": 256, "fps": 30 } ] }, { "type": "Link", "name": "tracker-http", "rel": ["tracker", "http"], "href": "https://tilvids.com/tracker/announce" }, { "type": "Link", "name": "tracker-websocket", "rel": ["tracker", "websocket"], "href": "wss://tilvids.com:443/tracker/socket" } ], "likes": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/likes", "dislikes": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/dislikes", "shares": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/announces", "comments": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/comments", "hasParts": "https://tilvids.com/videos/watch/e7946124-7b72-4ad7-9d22-844a84bb2de1/chapters", "attributedTo": [ { "type": "Person", "id": "https://tilvids.com/accounts/thelinuxexperiment" }, { "type": "Group", "id": "https://tilvids.com/video-channels/thelinuxexperiment_channel" } ], "isLiveBroadcast": false, "liveSaveReplay": null, "permanentLive": null, "latencyMode": null } ================================================ FILE: crates/apub/apub/assets/pleroma/activities/create_note.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://greenish.red/schemas/litepub-0.1.jsonld", { "@language": "und" } ], "actor": "https://greenish.red/users/nutomic", "cc": ["https://greenish.red/users/nutomic/followers"], "context": "https://greenish.red/contexts/f6244742-0526-4b84-ac4f-ceadf1fb4e56", "context_id": 6336544, "directMessage": false, "id": "https://greenish.red/activities/db61d52b-9c35-486a-bf27-bbd4edc6c6a1", "object": { "actor": "https://greenish.red/users/nutomic", "attachment": [], "attributedTo": "https://greenish.red/users/nutomic", "cc": ["https://greenish.red/users/nutomic/followers"], "content": "@lanodan test", "context": "https://greenish.red/contexts/f6244742-0526-4b84-ac4f-ceadf1fb4e56", "conversation": "https://greenish.red/contexts/f6244742-0526-4b84-ac4f-ceadf1fb4e56", "id": "https://greenish.red/objects/1a522f2e-d5ab-454b-93d7-e58bc0650c2a", "inReplyTo": "https://enterprise.lemmy.ml/post/55143", "published": "2021-10-26T10:28:35.602455Z", "sensitive": false, "source": "@lanodan@ds9.lemmy.ml test", "summary": "", "tag": [ { "href": "https://enterprise.lemmy.ml/u/picard", "name": "@lanodan@ds9.lemmy.ml", "type": "Mention" } ], "to": [ "https://enterprise.lemmy.ml/u/picard", "https://www.w3.org/ns/activitystreams#Public" ], "type": "Note" }, "published": "2021-10-26T10:28:35.595650Z", "to": [ "https://enterprise.lemmy.ml/u/picard", "https://www.w3.org/ns/activitystreams#Public" ], "type": "Create" } ================================================ FILE: crates/apub/apub/assets/pleroma/activities/delete.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://greenish.red/schemas/litepub-0.1.jsonld", { "@language": "und" } ], "actor": "https://greenish.red/users/vpzom", "attachment": [], "attributedTo": "https://greenish.red/users/vpzom", "cc": [], "conversation": null, "id": "https://greenish.red/activities/52f0b259-596e-429f-8a1b-c0b455f8932b", "object": "https://greenish.red/objects/38e2b983-ebf5-4387-9bc2-3b80305469c9", "tag": [ { "href": "https://voyager.lemmy.ml/c/main", "name": "@main@voyager.lemmy.ml", "type": "Mention" }, { "href": "https://voyager.lemmy.ml/u/dess_voy_41u2", "name": "@dess_voy_41u2@voyager.lemmy.ml", "type": "Mention" } ], "to": [ "https://greenish.red/users/vpzom/followers", "https://voyager.lemmy.ml/c/main", "https://voyager.lemmy.ml/u/dess_voy_41u2", "https://www.w3.org/ns/activitystreams#Public" ], "type": "Delete" } ================================================ FILE: crates/apub/apub/assets/pleroma/activities/follow.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://mycrowd.ca/schemas/litepub-0.1.jsonld", { "@language": "und" } ], "actor": "https://mycrowd.ca/users/kinetix", "cc": [], "id": "https://mycrowd.ca/activities/dab6a4d3-0db0-41ee-8aab-7bfa4929b4fd", "object": "https://lemmy.ca/u/kinetix", "state": "pending", "to": ["https://lemmy.ca/u/kinetix"], "type": "Follow" } ================================================ FILE: crates/apub/apub/assets/pleroma/objects/chat_message.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://queer.hacktivis.me/schemas/litepub-0.1.jsonld", { "@language": "und" } ], "attributedTo": "https://queer.hacktivis.me/users/lanodan", "content": "Hi!", "id": "https://queer.hacktivis.me/objects/2", "published": "2020-02-12T14:08:20Z", "to": ["https://enterprise.lemmy.ml/u/picard"], "type": "ChatMessage" } ================================================ FILE: crates/apub/apub/assets/pleroma/objects/note.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://queer.hacktivis.me/schemas/litepub-0.1.jsonld", { "@language": "und" } ], "actor": "https://queer.hacktivis.me/users/lanodan", "attachment": [], "attributedTo": "https://queer.hacktivis.me/users/lanodan", "cc": ["https://www.w3.org/ns/activitystreams#Public"], "content": "Have what?", "context": "https://queer.hacktivis.me/contexts/34cba3d2-2f35-4169-aeff-56af9bfeb753", "conversation": "https://queer.hacktivis.me/contexts/34cba3d2-2f35-4169-aeff-56af9bfeb753", "id": "https://queer.hacktivis.me/objects/8d4973f4-53de-49cd-8c27-df160e16a9c2", "inReplyTo": "https://enterprise.lemmy.ml/post/55143", "published": "2021-10-07T18:06:52.555500Z", "sensitive": null, "source": "@popolon@pleroma.popolon.org Have what?", "summary": "", "tag": [ { "href": "https://pleroma.popolon.org/users/popolon", "name": "@popolon@pleroma.popolon.org", "type": "Mention" } ], "to": [ "https://pleroma.popolon.org/users/popolon", "https://queer.hacktivis.me/users/lanodan/followers" ], "type": "Note" } ================================================ FILE: crates/apub/apub/assets/pleroma/objects/person.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://queer.hacktivis.me/schemas/litepub-0.1.jsonld", { "@language": "und" } ], "alsoKnownAs": [], "attachment": [], "capabilities": { "acceptsChatMessages": true }, "discoverable": false, "endpoints": { "oauthAuthorizationEndpoint": "https://queer.hacktivis.me/oauth/authorize", "oauthRegistrationEndpoint": "https://queer.hacktivis.me/api/v1/apps", "oauthTokenEndpoint": "https://queer.hacktivis.me/oauth/token", "sharedInbox": "https://queer.hacktivis.me/inbox", "uploadMedia": "https://queer.hacktivis.me/api/ap/upload_media" }, "featured": "https://queer.hacktivis.me/users/lanodan/collections/featured", "followers": "https://queer.hacktivis.me/users/lanodan/followers", "following": "https://queer.hacktivis.me/users/lanodan/following", "icon": { "type": "Image", "url": "https://queer.hacktivis.me/media/d23cf9b0-5586-4592-aca5-9a52777a6042/avatar_HD.png" }, "id": "https://queer.hacktivis.me/users/lanodan", "image": { "type": "Image", "url": "https://queer.hacktivis.me/media/37b6ce56-8c24-4e64-bd70-a76e84ab0c69/53a48a3a49ed5e5637a84e4f3663df17f8d764244bbc1027ba03cfc446e8b7bd.jpg" }, "inbox": "https://queer.hacktivis.me/users/lanodan/inbox", "manuallyApprovesFollowers": false, "name": "Haelwenn /элвэн/ :bzh: ", "outbox": "https://queer.hacktivis.me/users/lanodan/outbox", "preferredUsername": "lanodan", "publicKey": { "id": "https://queer.hacktivis.me/users/lanodan#main-key", "owner": "https://queer.hacktivis.me/users/lanodan", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsWOgdjSMc010qvxC3njI\nXJlFWMJ5gJ8QXCW/PajYdsHPM6d+jxBNJ6zp9/tIRa2m7bWHTSkuHQ7QthOpt6vu\n+dAWpKRLS607SPLItn/qUcyXvgN+H8shfyhMxvkVs9jXdtlBsLUVE7UNpN0dxzqe\nI79QWbf7o4amgaIWGRYB+OYMnIxKt+GzIkivZdSVSYjfxNnBYkMCeUxm5EpPIxKS\nP5bBHAVRRambD5NUmyKILuC60/rYuc/C+vmgpY2HCWFS2q6o34dPr9enwL6t4b3m\nS1t/EJHk9rGaaDqSGkDEfyQI83/7SDebWKuETMKKFLZi1vMgQIFuOYCIhN6bIiZm\npQIDAQAB\n-----END PUBLIC KEY-----\n\n" }, "summary": "---Lang: Français(natif), English(fluent), LSF(🤏~👌), русский (еле-еле),
Politics: Anarchist as in DIY/DIWO, freedom of association, anti-authoritarian, anti-identitarianism

Pronouns: meh, pick any, have fun
Timezone: Let's say Mars, I have a non-24h cycle
```
🦊🦄⚧🂡ⓥ :anarchy: 👿🐧 :gentoo:
Pleroma maintainer (mostly backend)
BadWolf developer
Gentoo contributor

Dayjob: yogoko.fr

That person which uses HJKL in games

Just because computer bad: X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

banner from: https://soc.flyingcube.tech/objects/56f79be2-9013-4559-9826-f7dc392417db
Federation-bots: #nobot", "tag": [ { "icon": { "type": "Image", "url": "https://queer.hacktivis.me/emoji/custom/symbols/anarchy.png" }, "id": "https://queer.hacktivis.me/emoji/custom/symbols/anarchy.png", "name": ":anarchy:", "type": "Emoji", "updated": "1970-01-01T00:00:00Z" }, { "icon": { "type": "Image", "url": "https://queer.hacktivis.me/emoji/custom/mastodon.xyz/bzh.png" }, "id": "https://queer.hacktivis.me/emoji/custom/mastodon.xyz/bzh.png", "name": ":bzh:", "type": "Emoji", "updated": "1970-01-01T00:00:00Z" }, { "icon": { "type": "Image", "url": "https://queer.hacktivis.me/emoji/custom/gentoo.png" }, "id": "https://queer.hacktivis.me/emoji/custom/gentoo.png", "name": ":gentoo:", "type": "Emoji", "updated": "1970-01-01T00:00:00Z" } ], "type": "Person", "url": "https://queer.hacktivis.me/users/lanodan" } ================================================ FILE: crates/apub/apub/assets/smithereen/activities/create_note.json ================================================ { "type": "Create", "id": "https://friends.grishka.me/posts/66561/activityCreate", "published": "2021-11-09T11:42:35Z", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://ds9.lemmy.ml/u/nutomic"], "actor": "https://friends.grishka.me/users/1", "object": { "type": "Note", "id": "https://friends.grishka.me/posts/66561", "attributedTo": "https://friends.grishka.me/users/1", "content": "

So does this federate now?

", "inReplyTo": "https://ds9.lemmy.ml/post/1723", "published": "2021-11-09T11:42:35Z", "tag": [ { "type": "Mention", "href": "https://ds9.lemmy.ml/u/nutomic" } ], "url": "https://friends.grishka.me/posts/66561", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://ds9.lemmy.ml/u/nutomic"], "replies": { "type": "Collection", "id": "https://friends.grishka.me/posts/66561/replies", "first": { "type": "CollectionPage", "partOf": "https://friends.grishka.me/posts/66561/replies", "next": "https://friends.grishka.me/posts/66561/replies?page=1" } }, "sensitive": false, "likes": "https://friends.grishka.me/posts/66561/likes" }, "@context": [ "https://www.w3.org/ns/activitystreams", { "sensitive": "as:sensitive" } ], "signature": { "creator": "https://friends.grishka.me/users/1#main-key", "created": "2021-11-09T11:42:35Z", "type": "RsaSignature2017", "signatureValue": "MmEf4hjfwfQbm/W8qfONwf0uEXO4dhKApX8PlodSNi9x6E4kEgBvx7BrKg3gtqnXfU/cbGdVIN/yCz8+v7Tp2T2kj1yRpD7WjbgwzkrOlhxLi3zPXd4En/cVVdZYSfc7R6DGflXOSeOZPnKbrmY6i+1kYkM80Yc+LFtoj0Ftdgc/YbwMynt1OwPvDbB5bJo1NVyRnpNqlqia2VNmdAh1+2vREXZmINsCOFMC5c0RVzEENYMw+ZPsbVdXfoz4wfqK2u2i7SlcDKVErVNPrKn71wfGWRRiLUNupokY1x3jsWeZlPqGvAP3WGS9ChU+FxhnVHbtxIf0QmeOas3okLDSjw==" } } ================================================ FILE: crates/apub/apub/assets/smithereen/objects/note.json ================================================ { "type": "Note", "id": "https://friends.grishka.me/posts/66561", "attributedTo": "https://friends.grishka.me/users/1", "content": "

So does this federate now?

", "inReplyTo": "https://ds9.lemmy.ml/post/1723", "published": "2021-11-09T11:42:35Z", "tag": [ { "type": "Mention", "href": "https://ds9.lemmy.ml/u/nutomic" } ], "url": "https://friends.grishka.me/posts/66561", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://ds9.lemmy.ml/u/nutomic"], "replies": { "type": "Collection", "id": "https://friends.grishka.me/posts/66561/replies", "first": { "type": "CollectionPage", "partOf": "https://friends.grishka.me/posts/66561/replies", "next": "https://friends.grishka.me/posts/66561/replies?page=1" } }, "sensitive": false, "likes": "https://friends.grishka.me/posts/66561/likes", "@context": [ "https://www.w3.org/ns/activitystreams", { "sensitive": "as:sensitive" } ] } ================================================ FILE: crates/apub/apub/assets/smithereen/objects/person.json ================================================ { "type": "Person", "id": "https://friends.grishka.me/users/1", "name": "Григорий Клюшников", "icon": { "type": "Image", "image": { "type": "Image", "url": "https://friends.grishka.me/i/6QLsOws97AWp5N_osd74C1IC1ijnFopyCBD9MSEeXNQ/q:93/bG9jYWw6Ly8vcy91cGxvYWRzL2F2YXRhcnMvNTYzODRhODEwODk5ZTRjMzI4YmY4YmQwM2Q2MWM3NmMud2VicA.jpg", "mediaType": "image/jpeg", "width": 1280, "height": 960 }, "width": 573, "height": 572, "cropRegion": [ 0.26422762870788574, 0.3766937553882599, 0.7113820910453796, 0.9728997349739075 ], "url": "https://friends.grishka.me/i/ql_49PQcETAWgY_nC-Qj63H_Oa6FyOAEoWFkUSSkUvQ/c:573:572:nowe:338:362/q:93/bG9jYWw6Ly8vcy91cGxvYWRzL2F2YXRhcnMvNTYzODRhODEwODk5ZTRjMzI4YmY4YmQwM2Q2MWM3NmMud2VicA.jpg", "mediaType": "image/jpeg" }, "summary": "

Делаю эту хрень, пытаюсь вырвать социальные сети из жадных лап корпораций

\n

\n

\n

\n

This server does NOT support direct messages. Please write me on Telegram or Matrix (@grishk:matrix.org).

", "url": "https://friends.grishka.me/grishka", "preferredUsername": "grishka", "inbox": "https://friends.grishka.me/users/1/inbox", "outbox": "https://friends.grishka.me/users/1/outbox", "followers": "https://friends.grishka.me/users/1/followers", "following": "https://friends.grishka.me/users/1/following", "endpoints": { "sharedInbox": "https://friends.grishka.me/activitypub/sharedInbox" }, "publicKey": { "id": "https://friends.grishka.me/users/1#main-key", "owner": "https://friends.grishka.me/users/1", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjlakm+i/d9ER/hIeR7KfiFW+SdLZj2SkKIeM8cmR+YFJuh9ghFqXrkFEjcaqUnAFqe5gYDNSQACnDLA8y4DnzjfGNIohKAnRoa9x6GORmfKQvcnjaTZ53S1NvUiPPyc0Pv/vfCtY/Ab0CEXe5BLqL38oZn817Jf7pBrPRTYH7m012kvwAUTT6k0Y8lPITBEG7nzYbbuGcrN9Y/RDdwE08jmBXlZ45bahRH3VNXVpQE17dCzJB+7k+iJ1R7YCoI+DuMlBYGXGE2KVk46NZTuLnOjFV9SyXfWX4/SrJM4oxev+SX2N75tQgmNZmVVHeqg2ZcbC0WCfNjJOi2HHS9MujwIDAQAB\n-----END PUBLIC KEY-----\n" }, "wall": "https://friends.grishka.me/users/1/wall", "firstName": "Григорий", "lastName": "Клюшников", "middleName": "Александрович", "vcard:bday": "1993-01-22", "gender": "http://schema.org#Male", "supportsFriendRequests": true, "friends": "https://friends.grishka.me/users/1/friends", "groups": "https://friends.grishka.me/users/1/groups", "@context": [ "https://www.w3.org/ns/activitystreams", { "sm": "http://smithereen.software/ns#", "cropRegion": { "@id": "sm:cropRegion", "@container": "@list" }, "wall": { "@id": "sm:wall", "@type": "@id" }, "sc": "http://schema.org#", "firstName": "sc:givenName", "lastName": "sc:familyName", "middleName": "sc:additionalName", "gender": { "@id": "sc:gender", "@type": "sc:GenderType" }, "supportsFriendRequests": "sm:supportsFriendRequests", "maidenName": "sm:maidenName", "friends": { "@id": "sm:friends", "@type": "@id" }, "groups": { "@id": "sm:groups", "@type": "@id" }, "vcard": "http://www.w3.org/2006/vcard/ns#" }, "https://w3id.org/security/v1" ] } ================================================ FILE: crates/apub/apub/assets/wordpress/activities/announce.json ================================================ { "@context": ["https://www.w3.org/ns/activitystreams"], "id": "https://pfefferle.org/lemmy-part-4/#activity#activity", "type": "Announce", "audience": "https://pfefferle.org/@pfefferle.org", "published": "2024-05-03T12:32:29Z", "updated": "2024-05-06T08:20:33Z", "to": [ "https://www.w3.org/ns/activitystreams#Public", "https://pfefferle.org/wp-json/activitypub/1.0/actors/1/followers" ], "cc": [], "object": { "id": "https://pfefferle.org/lemmy-part-4/#activity", "type": "Update", "audience": "https://pfefferle.org/@pfefferle.org", "published": "2024-05-03T12:32:29Z", "updated": "2024-05-06T08:20:33Z", "to": [ "https://www.w3.org/ns/activitystreams#Public", "https://pfefferle.org/wp-json/activitypub/1.0/actors/1/followers" ], "cc": [], "object": { "id": "https://pfefferle.org/lemmy-part-4/", "type": "Article", "attachment": [], "attributedTo": "https://pfefferle.org/author/pfefferle/", "audience": "https://pfefferle.org/@pfefferle.org", "content": "\u003Cp\u003EIdentifies one or more entities that represent the total population of entities for which the object can considered to be relevant. Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant. \u003C/p\u003E", "contentMap": { "en": "\u003Cp\u003EIdentifies one or more entities that represent the total population of entities for which the object can considered to be relevant. Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant. \u003C/p\u003E" }, "name": "Lemmy (Part 4)", "published": "2024-05-03T12:32:29Z", "summary": "Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant. Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object can considered to be relevant.Identifies one or more entities that represent the total population of entities for which the object [...]", "tag": [], "updated": "2024-05-06T08:20:33Z", "url": "https://pfefferle.org/lemmy-part-4/", "to": [ "https://www.w3.org/ns/activitystreams#Public", "https://pfefferle.org/wp-json/activitypub/1.0/actors/1/followers" ], "cc": [] }, "actor": "https://pfefferle.org/author/pfefferle/" }, "actor": "https://pfefferle.org/@pfefferle.org" } ================================================ FILE: crates/apub/apub/assets/wordpress/objects/group.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", "https://purl.archive.org/socialweb/webfinger", { "schema": "http://schema.org#", "toot": "http://joinmastodon.org/ns#", "lemmy": "https://join-lemmy.org/ns#", "litepub": "http://litepub.social/ns#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "PropertyValue": "schema:PropertyValue", "value": "schema:value", "Hashtag": "as:Hashtag", "featured": { "@id": "toot:featured", "@type": "@id" }, "featuredTags": { "@id": "toot:featuredTags", "@type": "@id" }, "moderators": { "@id": "lemmy:moderators", "@type": "@id" }, "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" }, "movedTo": { "@id": "as:movedTo", "@type": "@id" }, "attributionDomains": { "@id": "toot:attributionDomains", "@type": "@id" }, "implements": { "@id": "https://w3id.org/fep/844e/implements", "@type": "@id", "@container": "@list" }, "postingRestrictedToMods": "lemmy:postingRestrictedToMods", "discoverable": "toot:discoverable", "indexable": "toot:indexable", "invisible": "litepub:invisible" } ], "generator": { "type": "Application", "implements": [ { "href": "https://datatracker.ietf.org/doc/html/rfc9421", "name": "RFC-9421: HTTP Message Signatures" } ] }, "type": "Group", "inbox": "https://dbzer0.com/wp-json/activitypub/1.0/actors/0/inbox", "outbox": "https://dbzer0.com/wp-json/activitypub/1.0/actors/0/outbox", "following": "https://dbzer0.com/wp-json/activitypub/1.0/actors/0/following", "followers": "https://dbzer0.com/wp-json/activitypub/1.0/actors/0/followers", "streams": [], "preferredUsername": "dbzer0.com", "endpoints": { "sharedInbox": "https://dbzer0.com/wp-json/activitypub/1.0/inbox" }, "publicKey": { "id": "https://dbzer0.com/?author=0#main-key", "owner": "https://dbzer0.com/?author=0", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEI3WTumR109MNB4O/YJ\nyUQO5i1+dlftm9TmRFkgH+3cTQ1yR4Xhp/V6XFq4P+y/s8HUhFWlckr2BWY15qHJ\nnlzSWY2wtivhl/vQ6ZqlngGwS9uhsai+a090eGNrrwSB/jIKp4N7JPe8n06SVDfQ\nmu9BNyWrhqvHIkQsC4fgSHQrwEfH1hQx2KdE4J0hMJSPkLm1m2Pd5FRZVdxLZqMU\nWGCWn/wB5fTRb/2PpnxMrSETxEHL7hoI5HqPGaEGPvJUFLLKOPfYLlTJ/d5E0W3T\n5EThDUH811JWBvFhYNHltPu7J7FhrqAClxDXJsFyrxwDIN1YYEqI9P33z6KedgJi\nVQIDAQAB\n-----END PUBLIC KEY-----\n" }, "manuallyApprovesFollowers": false, "attributionDomains": ["dbzer0.com"], "alsoKnownAs": [ "https://dbzer0.com/?author=0", "https://dbzer0.com", "https://dbzer0.com/" ], "featured": "https://dbzer0.com/wp-json/activitypub/1.0/actors/0/collections/featured", "featuredTags": "https://dbzer0.com/wp-json/activitypub/1.0/actors/0/collections/tags", "discoverable": true, "indexable": true, "webfinger": "dbzer0.com@dbzer0.com", "moderators": "https://dbzer0.com/wp-json/activitypub/1.0/collections/moderators", "postingRestrictedToMods": true, "attachment": [ { "type": "PropertyValue", "name": "Blog", "value": "

https://dbzer0.com/

" }, { "type": "Link", "name": "Blog", "href": "https://dbzer0.com/", "rel": ["nofollow", "noopener", "noreferrer", "me"] } ], "attributedTo": "https://dbzer0.com/wp-json/activitypub/1.0/collections/moderators", "name": "A Division by Zer0", "icon": { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2024/12/cropped-8b790ffc-2b23-461a-ae98-1bd743cf66bc.webp?fit=512%2C512&ssl=1" }, "published": "2005-02-11T23:20:15Z", "summary": "

A bug in the code of the universe

\n", "tag": [ { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/quote-of-the-day/", "name": "#QuoteOfTheDay" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/anarchism/", "name": "#anarchism" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/reddit/", "name": "#reddit" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/site-updates/", "name": "#SiteUpdates" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/aihorde/", "name": "#aiHorde" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/capitalism/", "name": "#Capitalism" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/wordpress/", "name": "#Wordpress" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/libertarianism/", "name": "#Libertarianism" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/rant/", "name": "#Rant" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/linux/", "name": "#Linux" } ], "url": "https://dbzer0.com", "id": "https://dbzer0.com/?author=0" } ================================================ FILE: crates/apub/apub/assets/wordpress/objects/note.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "Hashtag": "as:Hashtag" } ], "id": "https://pfefferle.org?c=148", "type": "Note", "attributedTo": "https://pfefferle.org/author/pfefferle/", "content": "

Nice! Hello from WordPress!

", "contentMap": { "en": "

Nice! Hello from WordPress!

" }, "inReplyTo": "https://socialhub.activitypub.rocks/ap/object/ce040f1ead95964f6dbbf1084b81432d", "published": "2024-04-30T15:21:13Z", "tag": [], "url": "https://pfefferle.org?c=148", "to": [ "https://www.w3.org/ns/activitystreams#Public", "https://pfefferle.org/wp-json/activitypub/1.0/users/0/followers" ], "cc": [] } ================================================ FILE: crates/apub/apub/assets/wordpress/objects/page.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", { "Hashtag": "as:Hashtag", "sensitive": "as:sensitive", "dcterms": "http://purl.org/dc/terms/", "gts": "https://gotosocial.org/ns#", "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" }, "canQuote": { "@id": "gts:canQuote", "@type": "@id" }, "canReply": { "@id": "gts:canReply", "@type": "@id" }, "canLike": { "@id": "gts:canLike", "@type": "@id" }, "canAnnounce": { "@id": "gts:canAnnounce", "@type": "@id" }, "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" }, "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }, "always": { "@id": "gts:always", "@type": "@id" } } ], "type": "Article", "attachment": [ { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-3.png?fit=980%2C980&ssl=1", "mediaType": "image/png", "name": "A fleet of varied spacships" }, { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art.jpg?fit=696%2C1024&ssl=1", "mediaType": "image/jpeg" }, { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small3-1.png?fit=980%2C980&ssl=1", "mediaType": "image/png" }, { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/small-1-scaled.jpg?fit=768%2C1024&ssl=1", "mediaType": "image/jpeg" } ], "attributedTo": "https://dbzer0.com/blog/author/db0/", "audience": "https://dbzer0.com/?author=0", "content": "

I recently got my hands on a 3D printer and have been slowly enhancing and customizing my board game collection through printing new insertsand replacing components (typically wooden cubes, or cardboard tokens) with 3D printed version of them1.

When I reached the time to do my Last Light board game I initially just printed some planet/extractors supports and called it a day. However after I finished with my improvements for Arcs, where I replaced the basic spaceships with 3D printed custom versions,I got the idea to do the same for Last Light. Unfortunately, unlike Arcs I couldn’t find any spaceships someone else prepared for it, so I continued with other projects until as part of these projects I discovered a new workflow.

You see, there are GenAI models which can generate full 3D models from an existing image. They’re not as great with any random image, but if you feed them something specifically looking like a 3D model, they can be very good. I also have access to Generative AI through the AI Horde and can generate any number of images for it through local compute (mostly mine), and recently a new very powerful model was added to it2. Finally an AI Horde LLM can be used to take the core idea for a conceptand expand/brainstorm on it to improve its look.

My first test for this was printing a large Gargoyle as a gift. As I couldn’t find one in the shape or design I wanted, I just generated one myself. This was also my first proof-of-concept for how this workload could work and I was frankly blown away when I finished printing the model. The results were significantly better quality than I expected! As soon as I saw that, I knew my next project would be to see if I can generate 3 ship types for each color in Last Light. And, friends, let’s just say I might have gotten a bit carried away…

My Epic Last Light Custom Ship Expansion

Initially I generated only one fleet per color. Each fleet includes one large/dreadnought, one medium/frigate and one small/fighter spaceship. The small ships are are designed to look maneuverable, while the dreadnoughts to look imposing. Once I did the primary colors, Ithought to myself, “What if someone picks a color, but doesn’t like the design of the ships for that color. And also, why even limit myself? Youknow what, I’m gonna make two designs per color”. Honestly I was having way too much time making these designs and on the second wave of, I decided to be even more creative with the options.

So without further ado, let’s see the results. I’ll separate the headings below for each of the primary colors of Last Light, and the two options I designed for it. Each color has one design for the primary color, and one for an off-color of some way. For example White and Silver, Blue and Cyan and so on, so as to have each off-color clearly belong to one of the primary colors in the game. For each color, you can find a download link to the models and more pictures on Makeworld in the image caption.

Black

For this design, I wanted to make something that is all sharp pointy edges and malicious force. Interesting tidbit, the Frigate was the first ship I printed and it came up slightly too large compared to the original medium spaceship cardboard token. Second interesting tidbit, the fighter design originally belonged to the Grey designs (see below), but I decided that it’s better to make it part of the shadows as a cohesive whole, and modified its design prompt for the dreadnought.

\"\"
Black Fighter Design
\"\"
Black Fighter Print
\"\"
Black Frigate Design
\"\"
Black Frigate Print
\"\"
Black Dreadnought Design
\"\"
Black Dreadnought Print
\"\"
Grey: Geometric

Of course I wished for a Borg-like brutalist design as a choice as well. The models you see below are actually hand-painted as I run out of grey filament. I had to do a few re-designs for the pyramid in order to appear that it has less of a base (since there’s no ground in space). I would have liked a spherical spaceship to complete this trio, but that doesn’t make for an easy miniature. So I went instead for a hexagonal fighter.

\"\"
Grey Fighter Design
\"\"
Grey Fighter Print
\"\"
Grey Frigate Design
\"\"
Grey Frigate Print
\"\"
Grey Dreadnought Design
\"\"
Grey Dreadnought Print

Orange

\"\"
Orange: Forgeworld

For orange, I went with something that might look like it comes out of Dwarven blacksmiths

\"\"
Orange Fighter Design
\"\"
Orange Fighter Print
\"\"
Orange Frigate Design
\"\"
Orange Frigate Print
\"\"
Orange Dreadnought Design
\"\"
Orange Dreadnought Print
\"\"
Brown: Subterranean

I used brown as a darkened version of orange. I felt a fitting theme for this color was a subterranean design.

\"\"
Brown Fighter Design
\"\"
Brown Fighter Print
\"\"
Brown Frigate Design
\"\"
Brown Frigate Print
\"\"
Brown Dreadnought Design
\"\"
Brown Dreadnought Print

Red

\"\"
Red: Volcanic

For red, the best concept I could come up was a volcanic design

\"\"
Red Fighter Design
\"\"
Red Fighter Print
\"\"
Red Frigate Design
\"\"
Red Frigate Print
\"\"
Red Dreadnought Design
\"\"
Red Dreadnought Print
\"\"
Pink: Organic

Pink is effectively desaturated red so it felt adequate for a secondary color. A fitting design for this color was a organic bioships, taking inspiration from things like Zerg and Tyranids. Initially I wanted the winged mosquito-like ship to be the small version, but through trial and error I couldn’t make a printable version. The wings and base kept breaking off. As a medium version however it’s surprisingly stable.

\"\"
Pink Fighter Design
\"\"
Pink Fighter Print
\"\"
Pink Frigate Design
\"\"
Pink Frigate Print
\"\"
Pink Freadnought Design
\"\"
Pink Dreadnought Print

Yellow

\"\"
Yellow: Smooth

Yellow gets a more straightforward spaceship design. I tried to make this somewhat long, so hopefully they won’ttake too much space.

\"\"
Yellow Fighter Design
\"\"
Yellow Fighter Print
Yellow Frigate Design
\"\"
Yellow Frigate Print
\"\"
Yellow Dreadnought Design
\"\"
Yellow Dreadnought Print
\"\"
Gold: Gilded

Gold fits very well as the secondary yellow color as its metallic cousin. This design is meant to give vibes of Croesus or some other gaudy personality. Also unlike their yellow cousins, they tend to be more short and stocky.

\"\"
Gold Fighter Design
\"\"
Gold Fighter Print
\"\"
Gold Frigate Design
\"\"
Gold Frigate Print
\"\"
Gold Dreadnought Design
\"\"
Gold Dreadnought Print

Purple

\"\"
Purple: Centrifugal

For purple I went for a round/centrifugal theme, to keep things interesting. Initially this was supposed to be my white color ships, but I then had a better idea for something to match the whitetheme (see below), so I decided to turn these purple instead. Unfortunately the fighter and dreadnought prints are not very well balanced, so they tend to tilt on the board, but that doesn’t affect gameplay much.

\"\"
Purple Fighter Design
\"\"
Purple Fighter Print
\"\"
Purple Frigate Design
\"\"
Purple Frigate Print
\"\"
Purple Dreadnought Design
\"\"
Purple Dreadnought Print
\"\"
Fuchsia: Coral

The secondary purple color was difficult to decide, since purple is a mix or red and blue, so any off-color I chose, would have a chance to look like it belongs as an off-colorto either of them. In the end I went for a Fuchsia color, but in practicality I didn’t have a fuchsia filament or acrylic, so when I painted them by hand, I made them more Bordeaux.

Initially my early design were planning to be more celestial-themed, but I quickly found troubles when the GenAI modelcouldn’t draw spaceships that looked, well, like spaceships. I then came up with the idea for a coral-themed design, and it came out so well, I kept the celestial design only for the fighter ship, given how few print details appear at that size. The medium and large ships have enough area for the coral fractality to actually appear. And they appear quite unique on the board as well!

\"\"
Fuchsia Fighter Design
\"\"
Fuchsia Fighter Print
\"\"
Fuchsia Frigate Design
\"\"
Fuchsia Frigate Print
\"\"
Fuchsia Dreadnought Design
\"\"
Fuchsia Dreadnought Print

Green

\"\"
Green: Botanical

Green obviously had to be plant based and this was the first color I went outside of the usual metallic spaceship design and generated something more organic. For this theme I went for something plant-like and botanical.

\"\"
Green Fighter Design
\"\"
Green Fighter Print
\"\"
Green Frigate Design
\"\"
Green Frigate Print
\"\"
Green Dreadnought Design
\"\"
Green Dreadnought Print
\"\"
Neon-Green: Mycelial

I couldn’t decide on an secondary green color. Initially I was planning to make some sort of jingoistic design in khaki, but then I run into a neon-green filament color on sale and inspiration took me towards a more bioluminescent green color. And what better theme for this, than mushrooms!

Unfortunately the filament itself ended up being too similar in hue to the existing green I used for the Botanical design, but on the other hand, the mycelial designs came out great. The frigate design in fact was the first winged design I printed and it came out so well, that it then gave me confidence to attempt it a second time with the Organic frigates.

\"\"
Neon-Green Fighter Design
\"\"
Neon-Green Fighter Print
\"\"
Neon-Green Frigate Design
\"\"
Neon-Green Frigate Print
\"\"
Neon-Green Dreadnought Design
\"\"
Neon-Green Drednought Print

White

\"\"
White: Crystalline

After I had my initial spaceships for white, it occurred to me that white would also go well for an energy-based race, and what better way SciFi trope for energy than crystals. Don’t ask me, I didn’t make the rules!

\"\"
White Fighter Design
\"\"
White Fighter Print
\"\"
White Frigate Design
\"\"
White Frigate Print
\"\"
White Dreadnought Design
\"\"
White Dreadnought Print
\"\"
Silver: Plasma

Interestingly enough, it was my 8yo kid that came up with the ship design for this color scheme as he was observing my process and wanted to make some spaceships of his own. The Dreadnought is supposed to be some sort of healing/repair support ship. Given the secondary color for the silver theme I got out of the model, I decided to call this design “Plasma”.

Unfortunately the details of these designs areapparently too fine to be adequately printed and I also tried on a different filament as well, so the actual print barely have any details. I might have to redo these.

\"\"
Silver Fighter Design
\"\"
Silver Fighter Print
\"\"
Silver Frigate Design
\"\"
Silver Frigate Print
\"\"
Silver Dreadnought Design
\"\"
Silver Frigate Print

Blue

\"\"
Blue: Chunky

For the blue designs, I wantedto go for something chunky and utilitarian, kinda like the firefly-class ships. These generations were I think my very first ones, and they were before I started color coding the prompts themselves, which is why these ships all have the same aluminum look.

\"\"
Blue Fighter Design
\"\"
Blue Fighter Print
\"\"
Blue Frigate Design
\"\"
Blue Frigate Print
\"\"
Blue Dreadnought Design
\"\"
Blue Dreadnought Print
\"\"
Cyan: Marine

Last, but not least, I created an aquatic-creature inspired design and this is the one that convinced me I should go for more weird and organically-inspired designs for my off-color designs. The Jellyfish-themed dreadnought in fact came out so great, even standing stable on the table on its tentacles, that I knew I needed to do more of that. It’s no wonder next ships I designed after this were the mycelial ones. Can you tell what aquatic life-form inspired the frigate and fighter classes?

\"\"
Cyan Fighter Design
\"\"
Cyan Fighter Print
\"\"
Cyan Frigate Design
\"\"
Cyan Frigate Print
\"\"
Cyan Dreadnought Design
\"\"
Cyan Dreadnought Print
  1. Seriously, a 3D printer is the best addition to a boardgame hobby. ↩︎
  2. Z-Image ↩︎

So there you have it. This project has been ongoing for a few weeks now and I’m really happy how it turned out. I hope my boardgaming partners will find the enhancements to Last Light just as cool as I do 🙂

Now that I also opened this door of combining Generative AI with 3D printing and boardgames, the sky’s my limit! I have so many ideas to upgrade each and every boring component I have! Subscribe to this blog via RSS or fediverse or follow my makerworld account to see what new stuff I create.

", "context": "https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/context", "contentMap": { "en": "

I recently got my hands on a 3D printer and have been slowly enhancing and customizing my board game collection through printing new inserts and replacing components (typically wooden cubes, or cardboard tokens) with 3D printed version of them1.

When I reached the time to do my Last Light board game I initially just printed some planet/extractors supports and called it a day. However after I finished with my improvements for Arcs, where I replaced the basic spaceships with 3D printed custom versions,I got the idea to do the same for Last Light. Unfortunately, unlike Arcs I couldn’t find any spaceships someone else prepared for it, so I continued with other projects until as part of these projects I discovered a new workflow.

You see, there are GenAI models which can generate full 3D models from an existing image. They’re not as great with any random image, but if you feed them something specifically looking like a 3D model, they can be very good. I also have access to Generative AI through the AI Horde and can generate any number of images for it through local compute (mostly mine), and recently a new very powerful model was added to it2. Finally an AI Horde LLM can be used to take the core idea for a concept and expand/brainstorm on it to improve its look.

My first test for this was printing a large Gargoyle as a gift. As I couldn’t find one in the shape or design I wanted, I just generated one myself. This was also my first proof-of-concept for how this workload could work and I was frankly blown away whenI finished printing the model. The results were significantly better quality than I expected! As soon as I saw that, I knew my next project would be to see if I can generate 3 ship types for each color in Last Light. And, friends, let’s just say I might have gotten a bit carried away…

My Epic Last Light Custom Ship Expansion

Initially I generated only one fleet per color. Each fleet includes one large/dreadnought, one medium/frigate and onesmall/fighter spaceship. The small ships are are designed to look maneuverable, while the dreadnoughts to look imposing. Once I did the primary colors, I thought to myself, “What if someone picks a color, but doesn’t like the design of the ships for that color. And also, why even limit myself? You know what, I’m gonna make two designs per color”. Honestly I was having way too much time making these designs and on the second wave of, I decided to be even more creative with the options.

So without further ado, let’s see the results. I’ll separate the headings below for each of the primary colors of Last Light, and the two options I designed for it. Each color has one design for the primary color, and one for an off-color of some way. For example White and Silver, Blue and Cyan and so on, so as to have each off-color clearly belong to one of the primary colors in the game.For each color, you can find a download link to the models and more pictures on Makeworld in the image caption.

Black

For this design, I wanted to make something that is all sharp pointy edges and malicious force. Interesting tidbit, the Frigate was the first shipI printed and it came up slightly too large compared to the original medium spaceship cardboard token. Second interesting tidbit, the fighter design originally belonged to the Grey designs (see below), but I decided that it’s better to make it part of the shadows as a cohesive whole, and modified its design prompt for the dreadnought.

\"\"
Black Fighter Design
\"\"
Black Fighter Print
\"\"
Black Frigate Design
\"\"
Black Frigate Print
\"\"
Black Dreadnought Design
\"\"
Black Dreadnought Print
\"\"
Grey: Geometric

Of course I wished for a Borg-like brutalist design as a choice as well. The models you see below are actually hand-painted as I run out of grey filament. I had to do a few re-designs for the pyramid in order to appear that it has less of a base (since there’s no ground in space). I would have liked a spherical spaceship to complete this trio, but that doesn’t make for an easy miniature. So I went instead for a hexagonal fighter.

\"\"
Grey Fighter Design
\"\"
Grey Fighter Print
\"\"
Grey Frigate Design
\"\"
Grey Frigate Print
\"\"
Grey Dreadnought Design
\"\"
Grey Dreadnought Print

Orange

\"\"
Orange: Forgeworld

For orange, I went with something that might look like it comes out of Dwarven blacksmiths

\"\"
Orange Fighter Design
\"\"
Orange Fighter Print
\"\"
Orange Frigate Design
\"\"
Orange Frigate Print
\"\"
Orange Dreadnought Design
\"\"/
Orange Dreadnought Print
\"\"
Brown: Subterranean

I used brown as a darkened version of orange. I felt a fitting theme for this color was a subterranean design.

\"\"
Brown Fighter Design
\"\"
Brown Fighter Print
\"\"
Brown Frigate Design
\"\"
Brown Frigate Print
\"\"
Brown Dreadnought Design
\"\"
Brown Dreadnought Print

Red

\"\"
Red: Volcanic

For red, the best concept I could come up was a volcanic design

\"\"
Red Fighter Design
\"\"
Red Fighter Print
\"\"
Red Frigate Design
\"\"
Red Frigate Print
\"\"
Red Dreadnought Design
\"\"
Red Dreadnought Print
\"\"
Pink: Organic

Pink is effectively desaturatedred so it felt adequate for a secondary color. A fitting design for this color was a organic bioships, taking inspiration from things like Zerg and Tyranids. Initially I wanted the winged mosquito-like ship to be the small version, but through trial and error I couldn’t make a printable version. The wings and base kept breaking off. As a medium version however it’s surprisingly stable.

\"\"
Pink Fighter Design
\"\"
Pink Fighter Print
\"\"
Pink Frigate Design
\"\"
Pink Frigate Print
\"\"
Pink Freadnought Design
\"\"
Pink Dreadnought Print

Yellow

\"\"
Yellow: Smooth

Yellow gets a more straightforward spaceship design. I tried to make this somewhat long, so hopefully they won’t take too much space.

\"\"
Yellow Fighter Design
\"\"
Yellow Fighter Print
\"\"
Yellow Frigate Design
\"\"
Yellow Frigate Print
\"\"
Yellow Dreadnought Design
\"\"
Yellow Dreadnought Print
\"\"
Gold: Gilded

Gold fits very well as the secondary yellow color as its metallic cousin. This design is meant to give vibes of Croesus or some other gaudy personality. Also unlike their yellow cousins, they tend to be more short and stocky.

\"\"
Gold Fighter Design
\"\"
Gold Fighter Print
\"\"
Gold Frigate Design
\"\"
Gold Frigate Print
\"\"
Gold Dreadnought Design
\"\"
Gold Dreadnought Print

Purple

\"\"
Purple: Centrifugal

For purple I went for a round/centrifugal theme, to keep things interesting. Initially this was supposed to be my white color ships, but I then had a better idea for something to match the white theme (see below), so I decided to turn these purple instead. Unfortunately the fighter and dreadnought prints are not very well balanced, so they tend to tilton the board, but that doesn’t affect gameplay much.

\"\"
Purple Fighter Design
\"\"
Purple Fighter Print
\"\"
Purple Frigate Design
\"\"
Purple Frigate Print
\"\"
Purple Dreadnought Design
\"\"
Purple Dreadnought Print
\"\"
Fuchsia: Coral

The secondary purple color was difficult to decide, since purple is a mix or red and blue, so any off-color I chose, would have a chance to look like it belongs as an off-color toeither of them. In the end I went for a Fuchsia color, but in practicality I didn’t have a fuchsia filament or acrylic, so when I painted them by hand, I made them more Bordeaux.

Initially my early design were planning to be more celestial-themed, but I quickly found troubles when the GenAI model couldn’t draw spaceships that looked, well, like spaceships. I then came up with the idea for a coral-themed design, and it came out so well, I kept the celestial design only for the fighter ship, given how few print details appear at that size. The medium and large ships have enough area for the coral fractality to actually appear. And they appear quite unique on the board as well!

\"\"
Fuchsia Fighter Design
\"\"
Fuchsia Fighter Print
\"\"
Fuchsia Frigate Design
\"\"
Fuchsia Frigate Print
\"\"/
Fuchsia Dreadnought Design
\"\"
Fuchsia Dreadnought Print

Green

\"\"
Green: Botanical

Green obviously had to be plant based and this was the first color I went outside of the usual metallic spaceship design and generated something more organic. For this theme I went for something plant-like and botanical.

\"\"
Green Fighter Design
\"\"
Green Fighter Print
\"\"
Green Frigate Design
\"\"
Green Frigate Print
\"\"
Green Dreadnought Design
\"\"
Green Dreadnought Print
\"\"
Neon-Green: Mycelial

I couldn’t decide on an secondary green color. Initially I was planning to make some sort of jingoistic design in khaki, but then I run into a neon-green filament color on sale and inspiration took me towards a more bioluminescent green color. And what better theme for this,than mushrooms!

Unfortunately the filament itself ended up being too similar in hue to the existing green I used for the Botanical design, but on theother hand, the mycelial designs came out great. The frigate design in fact was the first winged design I printed and it came out so well, that it then gaveme confidence to attempt it a second time with the Organic frigates.

\"\"
Neon-Green Fighter Design
\"\"
Neon-Green Fighter Print
\"\"
Neon-Green Frigate Design
\"\"
Neon-Green Frigate Print
\"\"
Neon-Green Dreadnought Design
\"\"
Neon-Green Drednought Print

White

\"\"
White: Crystalline

After I had my initial spaceships for white, it occurred to me that white would also go well for an energy-based race, and what better way SciFi trope for energy than crystals. Don’t ask me, I didn’t make the rules!

\"\"
White Fighter Design
\"\"
White Fighter Print
\"\"
White Frigate Design
\"\"
White Frigate Print
\"\"
White Dreadnought Design
\"\"
White Dreadnought Print
\"\"
Silver: Plasma

Interestingly enough, it was my 8yo kid that came up with the ship design for this color scheme as he was observing my process and wanted to make some spaceships of his own. The Dreadnought is supposed to be some sort of healing/repair support ship. Given the secondary color for the silver theme I got out of the model, I decided to call this design “Plasma”.

Unfortunately the details of these designs are apparently too fine to be adequately printed and I also tried on a different filament as well, so the actual print barely have any details. I might have to redo these.

\"\"
Silver Fighter Design
\"\"
Silver Fighter Print
\"\"
Silver Frigate Design
\"\"
Silver Frigate Print
\"\"
Silver Dreadnought Design
\"\"
Silver Frigate Print

Blue

\"\"
Blue: Chunky

For the blue designs, I wanted togo for something chunky and utilitarian, kinda like the firefly-class ships. These generations were I think my very first ones, and they were before I started color coding the prompts themselves, which is why these ships all have the same aluminum look.

\"\"
Blue Fighter Design
\"\"
Blue Fighter Print
\"\"
Blue Frigate Design
\"\"
Blue Frigate Print
\"\"
Blue Dreadnought Design
\"\"
Blue Dreadnought Print
\"\"
Cyan: Marine

Last, but not least, I created an aquatic-creature inspired design and this is the one that convinced me I should go for more weird and organically-inspired designs for my off-color designs. The Jellyfish-themed dreadnought in fact came out so great, even standing stable on the table on its tentacles, that I knew I needed to do more of that. It’s no wonder next ships I designed after this were the mycelial ones. Can you tell what aquatic life-form inspired the frigate and fighter classes?

\"\"
Cyan Fighter Design
\"\"
Cyan Fighter Print
\"\"
Cyan Frigate Design
\"\"
Cyan Frigate Print
\"\"
Cyan Dreadnought Design
\"\"
Cyan Dreadnought Print
  1. Seriously, a 3D printer is the best addition to a boardgame hobby. ↩︎
  2. Z-Image ↩︎

So there you have it. This project has been ongoing for a few weeks now and I’m really happy how it turned out. I hope my boardgaming partners will find the enhancements to Last Light just as cool as I do 🙂

Now that I also opened this door of combining Generative AI with 3D printing and boardgames, the sky’s my limit! I have so many ideas to upgrade each and every boring component I have! Subscribe to this blog via RSS or fediverse or follow my makerworld accountto see what new stuff I create.

" }, "name": "An Epic 3D-Printed Fan Expansion for Last Light", "nameMap": { "en": "An Epic 3D-Printed Fan Expansion for Last Light" }, "icon": { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-3.png?resize=150%2C150&ssl=1", "mediaType": "image/png", "name": "A fleet of varied spacships" }, "image": { "type": "Image", "url": "https://i0.wp.com/dbzer0.com/wp-content/uploads/2025/12/Splash-Art2-3.png?fit=980%2C980&ssl=1", "mediaType": "image/png", "name": "A fleet of varied spacships" }, "preview": { "type": "Note", "content": "I like the Last Light boardgame, so I went wild creating a collection of custom ships per color. All in all, 45 different spaceship designs, all available for everyone." }, "published": "2025-12-10T15:53:45Z", "summary": "I like the Last Light boardgame, so I went wild creating a collection of custom ships per color. All in all, 45 different spaceship designs, all available for everyone.", "summaryMap": { "en": "I like the Last Light boardgame, so I went wild creating a collection of custom ships per color. All in all, 45 different spaceship designs, all available for everyone." }, "tag": [ { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/3d-printing/", "name": "#3DPrinting" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/ai/", "name": "#ai" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/boardgames/", "name": "#Boardgames" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/genai/", "name": "#GenAI" }, { "type": "Hashtag", "href": "https://dbzer0.com/blog/tag/last-light/", "name": "#LastLight" } ], "updated": "2025-12-10T18:23:32Z", "url": "https://dbzer0.com/blog/an-epic-3d-printed-fan-expansion-for-last-light/", "to": ["https://www.w3.org/ns/activitystreams#Public"], "cc": ["https://dbzer0.com/wp-json/activitypub/1.0/actors/1/followers"], "mediaType": "text/html", "replies": { "id": "https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/replies", "type": "Collection", "first": { "id": "https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/replies?page=1", "type": "CollectionPage", "partOf": "https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/replies", "items": [] } }, "likes": { "id": "https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/likes", "type": "Collection", "totalItems": 2 }, "shares": { "id": "https://dbzer0.com/wp-json/activitypub/1.0/posts/27948/shares", "type": "Collection", "totalItems": 0 }, "interactionPolicy": { "canAnnounce": { "automaticApproval": "https://www.w3.org/ns/activitystreams#Public", "always": "https://www.w3.org/ns/activitystreams#Public" }, "canLike": { "automaticApproval": "https://www.w3.org/ns/activitystreams#Public", "always": "https://www.w3.org/ns/activitystreams#Public" }, "canQuote": { "automaticApproval": "https://www.w3.org/ns/activitystreams#Public", "always": "https://www.w3.org/ns/activitystreams#Public" }, "canReply": { "automaticApproval": "https://www.w3.org/ns/activitystreams#Public", "always": "https://www.w3.org/ns/activitystreams#Public" } }, "id": "https://dbzer0.com/?p=27948" } ================================================ FILE: crates/apub/apub/assets/wordpress/objects/person.json ================================================ { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", "https://purl.archive.org/socialweb/webfinger", { "schema": "http://schema.org#", "toot": "http://joinmastodon.org/ns#", "webfinger": "https://webfinger.net/#", "lemmy": "https://join-lemmy.org/ns#", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "PropertyValue": "schema:PropertyValue", "value": "schema:value", "Hashtag": "as:Hashtag", "featured": { "@id": "toot:featured", "@type": "@id" }, "featuredTags": { "@id": "toot:featuredTags", "@type": "@id" }, "moderators": { "@id": "lemmy:moderators", "@type": "@id" }, "postingRestrictedToMods": "lemmy:postingRestrictedToMods", "discoverable": "toot:discoverable", "indexable": "toot:indexable", "resource": "webfinger:resource" } ], "id": "https://pfefferle.org/author/pfefferle/", "type": "Person", "attachment": [ { "type": "PropertyValue", "name": "Blog", "value": "pfefferle.org" }, { "type": "PropertyValue", "name": "Profile", "value": "pfefferle.org" } ], "name": "Matthias Pfefferle", "icon": { "type": "Image", "url": "https://secure.gravatar.com/avatar/a2bdca7870e859658cece96c044b3be5?s=120&d=mm&r=g" }, "published": "2014-02-10T15:23:08Z", "summary": "

Ich arbeite als Open Web Lead für Automattic.

\n", "tag": [], "url": "https://pfefferle.org/author/pfefferle/", "inbox": "https://pfefferle.org/wp-json/activitypub/1.0/users/1/inbox", "outbox": "https://pfefferle.org/wp-json/activitypub/1.0/users/1/outbox", "following": "https://pfefferle.org/wp-json/activitypub/1.0/users/1/following", "followers": "https://pfefferle.org/wp-json/activitypub/1.0/users/1/followers", "preferredUsername": "matthias", "endpoints": { "sharedInbox": "https://pfefferle.org/wp-json/activitypub/1.0/inbox" }, "publicKey": { "id": "https://pfefferle.org/author/pfefferle/#main-key", "owner": "https://pfefferle.org/author/pfefferle/", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvTA5RA40nOsso04RSwyX\nHXTojRPUMlIlArDcSy3M5GUJp9/xbxSUOdBjqd31KKB1GIi3vrLmD1Qi/ZqS95Qy\nw2Zd3xOsCg+o9bsyOG+O6Y8Lu+HEB5JKLUbNHdiSviakJ8wGadH9Wm4WIiN20y+q\n/u6lgxgiWfZ2CFCN6SOc28fUKi9NmKvXK+M12BhFfy1tC5KWXKDm0UbfI1+dmqhR\n3Ffe6vEsCI/YIVVdWxQ9kouOd0XSHOGdslktkepRO7IP9i9TdwyeCa0WWRoeO5Wa\ntVpc1Y0WuNbTM2ksIXTg0G+rO1/6KO/hrHnGu3RCfb/ZIHK5L/aWYb9B3PG3LyKV\n+wIDAQAB\n-----END PUBLIC KEY-----\n" }, "manuallyApprovesFollowers": false, "featured": "https://pfefferle.org/wp-json/activitypub/1.0/users/1/collections/featured", "discoverable": true, "indexable": true, "webfinger": "matthias@pfefferle.org" } ================================================ FILE: crates/apub/apub/src/collections/community_featured.rs ================================================ use crate::protocol::collections::group_featured::GroupFeatured; use activitypub_federation::{ config::Data, kinds::collection::OrderedCollectionType, protocol::verification::verify_domains_match, traits::{Collection, Object}, }; use futures::future::{join_all, try_join_all}; use lemmy_api_utils::{context::LemmyContext, utils::generate_featured_url}; use lemmy_apub_objects::objects::{community::ApubCommunity, post::ApubPost}; use lemmy_db_schema::{ source::{community::Community, post::Post}, utils::FETCH_LIMIT_MAX, }; use lemmy_utils::error::LemmyError; use url::Url; #[derive(Clone, Debug, PartialEq)] pub(crate) struct ApubCommunityFeatured(()); #[async_trait::async_trait] impl Collection for ApubCommunityFeatured { type Owner = ApubCommunity; type DataType = LemmyContext; type Kind = GroupFeatured; type Error = LemmyError; async fn read_local( owner: &Self::Owner, data: &Data, ) -> Result { let ordered_items = try_join_all( Post::list_featured_for_community(&mut data.pool(), owner.id) .await? .into_iter() .map(ApubPost::from) .map(|p| p.into_json(data)), ) .await?; Ok(GroupFeatured { r#type: OrderedCollectionType::OrderedCollection, id: generate_featured_url(&owner.ap_id)?.into(), total_items: ordered_items.len().try_into()?, ordered_items, }) } async fn verify( apub: &Self::Kind, expected_domain: &Url, _data: &Data, ) -> Result<(), Self::Error> { verify_domains_match(expected_domain, &apub.id)?; Ok(()) } async fn from_json( apub: Self::Kind, owner: &Self::Owner, context: &Data, ) -> Result where Self: Sized, { let mut pages = apub.ordered_items; if pages.len() > FETCH_LIMIT_MAX { pages = pages.get(0..FETCH_LIMIT_MAX).unwrap_or_default().to_vec(); } // process items in parallel, to avoid long delay from fetch_site_metadata() and other // processing let stickied_posts: Vec = join_all(pages.into_iter().map(|page| async move { // Dont verify/receive the `page` directly because it throws error for local post page.id.dereference(context).await })) .await // ignore any failed or unparseable items .into_iter() .filter_map(|p| p.ok().map(|p| p.0)) .collect(); Community::set_featured_posts(owner.id, stickied_posts, &mut context.pool()).await?; // This return value is unused, so just set an empty vec Ok(ApubCommunityFeatured(())) } } ================================================ FILE: crates/apub/apub/src/collections/community_follower.rs ================================================ use crate::protocol::collections::group_followers::GroupFollowers; use activitypub_federation::{ config::Data, kinds::collection::CollectionType, protocol::verification::verify_domains_match, traits::Collection, }; use lemmy_api_utils::{context::LemmyContext, utils::generate_followers_url}; use lemmy_apub_objects::objects::community::ApubCommunity; use lemmy_db_schema::source::community::Community; use lemmy_db_views_community_follower::CommunityFollowerView; use lemmy_utils::error::LemmyError; use url::Url; #[derive(Clone, Debug)] pub(crate) struct ApubCommunityFollower(()); #[async_trait::async_trait] impl Collection for ApubCommunityFollower { type Owner = ApubCommunity; type DataType = LemmyContext; type Kind = GroupFollowers; type Error = LemmyError; async fn read_local( community: &Self::Owner, context: &Data, ) -> Result { let community_id = community.id; let community_followers = CommunityFollowerView::count_community_followers(&mut context.pool(), community_id).await?; Ok(GroupFollowers { id: generate_followers_url(&community.ap_id)?.into(), r#type: CollectionType::Collection, total_items: community_followers, items: vec![], }) } async fn verify( json: &Self::Kind, expected_domain: &Url, _data: &Data, ) -> Result<(), Self::Error> { verify_domains_match(expected_domain, &json.id)?; Ok(()) } async fn from_json( json: Self::Kind, community: &Self::Owner, context: &Data, ) -> Result { Community::update_federated_followers(&mut context.pool(), community.id, json.total_items) .await?; Ok(ApubCommunityFollower(())) } } ================================================ FILE: crates/apub/apub/src/collections/community_moderators.rs ================================================ use crate::{is_new_instance, protocol::collections::group_moderators::GroupModerators}; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::collection::OrderedCollectionType, protocol::verification::verify_domains_match, traits::Collection, }; use lemmy_api_utils::{context::LemmyContext, utils::generate_moderators_url}; use lemmy_apub_objects::objects::{community::ApubCommunity, person::ApubPerson}; use lemmy_db_schema::source::community::{CommunityActions, CommunityModeratorForm}; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; #[derive(Clone, Debug)] pub(crate) struct ApubCommunityModerators(()); #[async_trait::async_trait] impl Collection for ApubCommunityModerators { type Owner = ApubCommunity; type DataType = LemmyContext; type Kind = GroupModerators; type Error = LemmyError; async fn read_local(owner: &Self::Owner, data: &Data) -> LemmyResult { let moderators = CommunityModeratorView::for_community(&mut data.pool(), owner.id).await?; let ordered_items = moderators .into_iter() .map(|m| ObjectId::::from(m.moderator.ap_id)) .collect(); Ok(GroupModerators { r#type: OrderedCollectionType::OrderedCollection, id: generate_moderators_url(&owner.ap_id)?.into(), ordered_items, }) } async fn verify( group_moderators: &GroupModerators, expected_domain: &Url, _data: &Data, ) -> LemmyResult<()> { verify_domains_match(&group_moderators.id, expected_domain)?; Ok(()) } async fn from_json( apub: Self::Kind, owner: &Self::Owner, data: &Data, ) -> LemmyResult { handle_community_moderators(&apub.ordered_items, owner, data).await?; // This return value is unused, so just set an empty vec Ok(ApubCommunityModerators(())) } } pub(super) async fn handle_community_moderators( new_mods: &Vec>, community: &ApubCommunity, context: &Data, ) -> LemmyResult<()> { let community_id = community.id; let current_moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?; // Remove old mods from database which arent in the moderators collection anymore for mod_user in ¤t_moderators { let mod_id = ObjectId::from(mod_user.moderator.ap_id.clone()); if !new_mods.contains(&mod_id) { let community_moderator_form = CommunityModeratorForm::new(mod_user.community.id, mod_user.moderator.id); CommunityActions::leave(&mut context.pool(), &community_moderator_form).await?; } } // Add new mods to database which have been added to moderators collection for mod_id in new_mods { // Ignore errors as mod accounts might be deleted or instances unavailable. let mod_user: Option = mod_id.dereference(context).await.ok(); if let Some(mod_user) = mod_user && !current_moderators .iter() .any(|x| x.moderator.ap_id == mod_user.ap_id) { let community_moderator_form = CommunityModeratorForm::new(community.id, mod_user.id); CommunityActions::join(&mut context.pool(), &community_moderator_form).await?; } // Only add the top mod in case of new instance if is_new_instance(context).await? { return Ok(()); } } Ok(()) } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use lemmy_apub_objects::utils::test::{ file_to_json_object, parse_lemmy_community, parse_lemmy_person, }; use lemmy_db_schema::{ source::community::{CommunityActions, CommunityModeratorForm}, test_data::TestData, }; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_parse_lemmy_community_moderators() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let data = TestData::create(&mut context.pool()).await?; let (new_mod, site) = parse_lemmy_person(&context).await?; let community = parse_lemmy_community(&context).await?; let community_id = community.id; let community_moderator_form = CommunityModeratorForm::new(community.id, data.person.id); CommunityActions::join(&mut context.pool(), &community_moderator_form).await?; assert_eq!(site.ap_id.to_string(), "https://enterprise.lemmy.ml/"); let json: GroupModerators = file_to_json_object("assets/lemmy/collections/group_moderators.json")?; let url = Url::parse("https://enterprise.lemmy.ml/c/tenforward")?; ApubCommunityModerators::verify(&json, &url, &context).await?; ApubCommunityModerators::from_json(json, &community, &context).await?; assert_eq!(context.request_count(), 0); let current_moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?; assert_eq!(current_moderators.len(), 1); assert_eq!(current_moderators[0].moderator.id, new_mod.id); data.delete(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/apub/src/collections/community_outbox.rs ================================================ use crate::{is_new_instance, protocol::collections::group_outbox::GroupOutbox}; use activitypub_federation::{ config::Data, kinds::collection::OrderedCollectionType, protocol::verification::verify_domains_match, traits::{Activity, Collection}, }; use futures::future::join_all; use lemmy_api_utils::{context::LemmyContext, utils::generate_outbox_url}; use lemmy_apub_activities::{ activity_lists::AnnouncableActivities, protocol::{ CreateOrUpdateType, community::announce::AnnounceActivity, create_or_update::page::CreateOrUpdatePage, }, }; use lemmy_apub_objects::objects::community::ApubCommunity; use lemmy_db_schema::utils::FETCH_LIMIT_MAX; use lemmy_db_schema_file::enums::PostSortType; use lemmy_db_views_post::impls::PostQuery; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; #[derive(Clone, Debug)] pub(crate) struct ApubCommunityOutbox(()); #[async_trait::async_trait] impl Collection for ApubCommunityOutbox { type Owner = ApubCommunity; type DataType = LemmyContext; type Kind = GroupOutbox; type Error = LemmyError; async fn read_local(owner: &Self::Owner, data: &Data) -> LemmyResult { let site = SiteView::read_local(&mut data.pool()).await?.site; let mut post_views = Box::pin( PostQuery { community_id: Some(owner.id), sort: Some(PostSortType::New), limit: Some(FETCH_LIMIT_MAX.try_into()?), ..Default::default() } .list(&site, &mut data.pool()), ) .await? .items; // Outbox must be sorted reverse chronological (newest items first). This is already done // via SQL, but featured posts are always at the top so we need to manually sort it here. post_views.sort_unstable_by(|p1, p2| p2.post.published_at.cmp(&p1.post.published_at)); let mut ordered_items = vec![]; for post_view in post_views { // ignore errors, in particular if post creator was deleted if let Ok(create) = CreateOrUpdatePage::new( post_view.post.into(), &post_view.creator.into(), owner, CreateOrUpdateType::Create, data, ) .await { let announcable = AnnouncableActivities::CreateOrUpdatePost(create); if let Ok(announce) = AnnounceActivity::new(announcable.try_into()?, owner, data) { ordered_items.push(announce); } } } Ok(GroupOutbox { r#type: OrderedCollectionType::OrderedCollection, id: generate_outbox_url(&owner.ap_id)?.into(), total_items: owner.posts, ordered_items, }) } async fn verify( group_outbox: &GroupOutbox, expected_domain: &Url, _data: &Data, ) -> LemmyResult<()> { verify_domains_match(expected_domain, &group_outbox.id)?; Ok(()) } async fn from_json( apub: Self::Kind, _owner: &Self::Owner, data: &Data, ) -> LemmyResult { // Fetch less posts on new instance to save requests let fetch_limit = if is_new_instance(data).await? { 10 } else { FETCH_LIMIT_MAX }; let mut outbox_activities = apub.ordered_items; if outbox_activities.len() > fetch_limit { outbox_activities = outbox_activities .get(0..(fetch_limit)) .unwrap_or_default() .to_vec(); } // We intentionally ignore errors here. This is because the outbox might contain posts from old // Lemmy versions, or from other software which we cant parse. In that case, we simply skip the // item and only parse the ones that work. // process items in parallel, to avoid long delay from fetch_site_metadata() and other // processing join_all(outbox_activities.into_iter().map(|activity| { async { // Receiving announce requires at least one local community follower for anti spam purposes. // This won't be the case for newly fetched communities, so we extract the inner activity // and handle it directly to bypass this check. let inner = activity.object.object(data).await.map(TryInto::try_into); if let Ok(Ok(AnnouncableActivities::CreateOrUpdatePost(inner))) = inner { let verify = inner.verify(data).await; if verify.is_ok() { inner.receive(data).await.ok(); } } } })) .await; // This return value is unused, so just set an empty vec Ok(ApubCommunityOutbox(())) } } ================================================ FILE: crates/apub/apub/src/collections/mod.rs ================================================ use crate::{ collections::community_moderators::handle_community_moderators, is_new_instance, protocol::collections::url_collection::UrlCollection, }; use activitypub_federation::{ actix_web::response::create_http_response, config::Data, fetch::{collection_id::CollectionId, object_id::ObjectId}, }; use actix_web::HttpResponse; use community_featured::ApubCommunityFeatured; use community_follower::ApubCommunityFollower; use community_moderators::ApubCommunityModerators; use community_outbox::ApubCommunityOutbox; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson}, protocol::group::Group, utils::protocol::{AttributedTo, PersonOrGroupType}, }; use lemmy_db_schema::source::{comment::Comment, post::Post}; use lemmy_utils::{FEDERATION_CONTEXT, error::LemmyResult, spawn_try_task}; pub(crate) mod community_featured; pub(crate) mod community_follower; pub(crate) mod community_moderators; pub(crate) mod community_outbox; pub fn fetch_community_collections( community: ApubCommunity, group: Group, context: Data, ) { spawn_try_task(async move { let outbox: CollectionId = group.outbox.into(); outbox.dereference(&community, &context).await.ok(); if let Some(followers) = group.followers { let followers: CollectionId = followers.into(); followers.dereference(&community, &context).await.ok(); } // Dont fetch featured posts for new instances to save requests. // But need to run this in debug mode so that api tests can pass. if (cfg!(debug_assertions) || !is_new_instance(&context).await?) && let Some(featured) = group.featured { let featured: CollectionId = featured.into(); featured.dereference(&community, &context).await.ok(); } if let Some(moderators) = group.attributed_to { if let AttributedTo::Lemmy(l) = moderators { let moderators: CollectionId = l.moderators().into(); moderators.dereference(&community, &context).await.ok(); } else if let AttributedTo::Peertube(p) = moderators { let new_mods = p .iter() .filter(|p| p.kind == PersonOrGroupType::Person) .map(|p| ObjectId::::from(p.id.clone().into_inner())) .collect(); handle_community_moderators(&new_mods, &community, &context) .await .ok(); } } Ok(()) }); } impl UrlCollection { pub(crate) async fn new_response( post: &Post, id: String, context: &LemmyContext, ) -> LemmyResult { let mut ordered_items = vec![post.ap_id.clone().into()]; let comments = Comment::read_ap_ids_for_post(post.id, &mut context.pool()).await?; ordered_items.extend(comments.into_iter().map(Into::into)); let collection = Self { r#type: Default::default(), id, total_items: ordered_items.len().try_into()?, ordered_items, }; Ok(create_http_response(collection, &FEDERATION_CONTEXT)?) } /// Empty placeholder outbox used for Person, Instance, which dont implement a proper outbox. pub(crate) fn new_empty_response(id: String) -> LemmyResult { let collection = Self { r#type: Default::default(), id, ordered_items: vec![], total_items: 0, }; Ok(create_http_response(collection, &FEDERATION_CONTEXT)?) } } ================================================ FILE: crates/apub/apub/src/http/comment.rs ================================================ use super::check_community_content_fetchable; use crate::protocol::collections::url_collection::UrlCollection; use activitypub_federation::{config::Data, traits::Object}; use actix_web::{HttpRequest, HttpResponse, web::Path}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{objects::comment::ApubComment, utils::functions::context_url}; use lemmy_db_schema::{ newtypes::CommentId, source::{comment::Comment, community::Community, post::Post}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ FEDERATION_CONTEXT, error::{LemmyErrorType, LemmyResult}, }; use serde::Deserialize; #[derive(Deserialize)] pub(crate) struct CommentQuery { comment_id: String, } async fn get_comment( info: Path, context: &Data, request: &HttpRequest, ) -> LemmyResult { let id = CommentId(info.comment_id.parse::()?); // Can't use CommentView here because it excludes deleted/removed/local-only items let comment: ApubComment = Comment::read(&mut context.pool(), id).await?.into(); let post = Post::read(&mut context.pool(), comment.post_id).await?; let community = Community::read(&mut context.pool(), post.community_id).await?; check_community_content_fetchable(&community, request, context).await?; Ok(comment) } /// Return the ActivityPub json representation of a local comment over HTTP. pub(crate) async fn get_apub_comment( info: Path, context: Data, request: HttpRequest, ) -> LemmyResult { let comment = get_comment(info, &context, &request).await?; comment.http_response(&FEDERATION_CONTEXT, &context).await } pub(crate) async fn get_apub_comment_context( info: Path, context: Data, request: HttpRequest, ) -> LemmyResult { let comment = get_comment(info, &context, &request).await?; if !comment.local { return Err(LemmyErrorType::NotFound.into()); } let post = Post::read(&mut context.pool(), comment.post_id).await?; UrlCollection::new_response(&post, context_url(&comment.ap_id), &context).await } ================================================ FILE: crates/apub/apub/src/http/community.rs ================================================ use super::check_community_content_fetchable; use crate::{ collections::{ community_featured::ApubCommunityFeatured, community_follower::ApubCommunityFollower, community_moderators::ApubCommunityModerators, community_outbox::ApubCommunityOutbox, }, http::{check_community_fetchable, get_instance_id}, }; use activitypub_federation::{ actix_web::{response::create_http_response, signing_actor}, config::Data, fetch::object_id::ObjectId, traits::{Collection, Object}, }; use actix_web::{ HttpRequest, HttpResponse, web::{Path, Query}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{ objects::{ SiteOrMultiOrCommunityOrUser, community::ApubCommunity, multi_community::ApubMultiCommunity, multi_community_collection::ApubFeedCollection, }, protocol::tags::ApubCommunityTag, }; use lemmy_db_schema::{ source::{community::Community, community_tag::CommunityTag, multi_community::MultiCommunity}, traits::ApubActor, }; use lemmy_db_schema_file::enums::CommunityVisibility; use lemmy_db_views_community_follower_approval::PendingFollowerView; use lemmy_utils::{ FEDERATION_CONTEXT, error::{LemmyErrorType, LemmyResult}, }; use serde::Deserialize; #[derive(Deserialize, Clone)] pub(crate) struct CommunityPath { community_name: String, } #[derive(Deserialize, Clone)] pub(crate) struct CommunityIsFollowerQuery { is_follower: Option>, } /// Return the ActivityPub json representation of a local community over HTTP. pub(crate) async fn get_apub_community_http( info: Path, context: Data, ) -> LemmyResult { let community: ApubCommunity = Community::read_from_name(&mut context.pool(), &info.community_name, None, true) .await? .ok_or(LemmyErrorType::NotFound)? .into(); check_community_fetchable(&community)?; community.http_response(&FEDERATION_CONTEXT, &context).await } /// Returns an empty followers collection, only populating the size (for privacy). pub(crate) async fn get_apub_community_followers( info: Path, query: Query, context: Data, request: HttpRequest, ) -> LemmyResult { let community = Community::read_from_name(&mut context.pool(), &info.community_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)?; if let Some(is_follower) = &query.is_follower { return check_is_follower(community, is_follower, context, request).await; } check_community_fetchable(&community)?; let followers = ApubCommunityFollower::read_local(&community.into(), &context).await?; Ok(create_http_response(followers, &FEDERATION_CONTEXT)?) } /// Checks if a given actor follows the private community. Returns status 200 if true. async fn check_is_follower( community: Community, is_follower: &ObjectId, context: Data, request: HttpRequest, ) -> LemmyResult { if community.visibility != CommunityVisibility::Private { return Ok(HttpResponse::BadRequest().body("must be a private community")); } // also check for http sig so that followers are not exposed publicly let signing_actor = signing_actor::(&request, None, &context).await?; PendingFollowerView::check_has_followers_from_instance( community.id, get_instance_id(&signing_actor), &mut context.pool(), ) .await?; let instance_id = get_instance_id(&is_follower.dereference(&context).await?); let has_followers = PendingFollowerView::check_has_followers_from_instance( community.id, instance_id, &mut context.pool(), ) .await; if has_followers.is_ok() { Ok(HttpResponse::Ok().finish()) } else { Ok(HttpResponse::NotFound().finish()) } } /// Returns the community outbox, which is populated by a maximum of 20 posts (but no other /// activities like votes or comments). pub(crate) async fn get_apub_community_outbox( info: Path, context: Data, request: HttpRequest, ) -> LemmyResult { let community: ApubCommunity = Community::read_from_name(&mut context.pool(), &info.community_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)? .into(); check_community_content_fetchable(&community, &request, &context).await?; let outbox = ApubCommunityOutbox::read_local(&community, &context).await?; Ok(create_http_response(outbox, &FEDERATION_CONTEXT)?) } pub(crate) async fn get_apub_community_moderators( info: Path, context: Data, ) -> LemmyResult { let community: ApubCommunity = Community::read_from_name(&mut context.pool(), &info.community_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)? .into(); check_community_fetchable(&community)?; let moderators = ApubCommunityModerators::read_local(&community, &context).await?; Ok(create_http_response(moderators, &FEDERATION_CONTEXT)?) } /// Returns collection of featured (stickied) posts. pub(crate) async fn get_apub_community_featured( info: Path, context: Data, request: HttpRequest, ) -> LemmyResult { let community: ApubCommunity = Community::read_from_name(&mut context.pool(), &info.community_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)? .into(); check_community_content_fetchable(&community, &request, &context).await?; let featured = ApubCommunityFeatured::read_local(&community, &context).await?; Ok(create_http_response(featured, &FEDERATION_CONTEXT)?) } #[derive(Deserialize)] pub(crate) struct MultiCommunityQuery { multi_name: String, } pub(crate) async fn get_apub_person_multi_community( query: Path, context: Data, ) -> LemmyResult { let multi: ApubMultiCommunity = MultiCommunity::read_from_name(&mut context.pool(), &query.multi_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)? .into(); multi.http_response(&FEDERATION_CONTEXT, &context).await } pub(crate) async fn get_apub_person_multi_community_follows( query: Path, context: Data, ) -> LemmyResult { let multi = MultiCommunity::read_from_name(&mut context.pool(), &query.multi_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)? .into(); let collection = ApubFeedCollection::read_local(&multi, &context).await?; Ok(create_http_response(collection, &FEDERATION_CONTEXT)?) } #[derive(Deserialize, Clone)] pub(crate) struct CommunityTagPath { community_name: String, tag_name: String, } /// Return the ActivityPub json representation of a local community over HTTP. pub(crate) async fn get_apub_community_tag_http( info: Path, context: Data, ) -> LemmyResult { let community: ApubCommunity = Community::read_from_name(&mut context.pool(), &info.community_name, None, true) .await? .ok_or(LemmyErrorType::NotFound)? .into(); check_community_fetchable(&community)?; let tag = CommunityTag::read_for_community(&mut context.pool(), community.id) .await? .into_iter() .map(ApubCommunityTag::to_json) .find(|t| t.preferred_username == info.tag_name) .ok_or(LemmyErrorType::NotFound)?; Ok(create_http_response(tag, &FEDERATION_CONTEXT)?) } #[cfg(test)] pub(crate) mod tests { use super::*; use activitypub_federation::protocol::tombstone::Tombstone; use actix_web::{body::to_bytes, test::TestRequest}; use lemmy_apub_objects::protocol::group::Group; use lemmy_db_schema::{ source::{ community::CommunityInsertForm, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, }, test_data::TestData, }; use lemmy_diesel_utils::traits::Crud; use serde::de::DeserializeOwned; use serial_test::serial; use url::Url; async fn init( deleted: bool, visibility: CommunityVisibility, context: &Data, ) -> LemmyResult<(TestData, Community, Path)> { let data = TestData::create(&mut context.pool()).await?; let community_form = CommunityInsertForm { deleted: Some(deleted), ap_id: Some(Url::parse("http://lemmy-alpha")?.into()), visibility: Some(visibility), ..CommunityInsertForm::new( data.instance.id, "testcom6".to_string(), "nada".to_owned(), "pubkey".to_string(), ) }; let community = Community::create(&mut context.pool(), &community_form).await?; let path: Path = CommunityPath { community_name: community.name.clone(), } .into(); Ok((data, community, path)) } async fn decode_response(res: HttpResponse) -> LemmyResult { let body = to_bytes(res.into_body()).await.unwrap_or_default(); let body = std::str::from_utf8(&body)?; Ok(serde_json::from_str(body)?) } #[tokio::test] #[serial] async fn test_get_community() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let (data, community, path) = init(false, CommunityVisibility::Public, &context).await?; let request = TestRequest::default().to_http_request(); // fetch invalid community let query = CommunityPath { community_name: "asd".to_string(), }; let res = get_apub_community_http(query.into(), context.clone()).await; assert!(res.is_err()); // fetch valid community let res = get_apub_community_http(path.clone().into(), context.clone()).await?; assert_eq!(200, res.status()); let res_group: Group = decode_response(res).await?; let community: ApubCommunity = community.into(); let group = community.clone().into_json(&context).await?; assert_eq!(group, res_group); let res = get_apub_community_featured(path.clone().into(), context.clone(), request.clone()).await?; assert_eq!(200, res.status()); let query = Query(CommunityIsFollowerQuery { is_follower: None }); let res = get_apub_community_followers(path.clone().into(), query, context.clone(), request.clone()) .await?; assert_eq!(200, res.status()); let res = get_apub_community_moderators(path.clone().into(), context.clone()).await?; assert_eq!(200, res.status()); let res = get_apub_community_outbox(path, context.clone(), request).await?; assert_eq!(200, res.status()); data.delete(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_get_deleted_community() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let (data, _, path) = init(true, CommunityVisibility::Public, &context).await?; let request = TestRequest::default().to_http_request(); // should return tombstone let res = get_apub_community_http(path.clone().into(), context.clone()).await?; assert_eq!(410, res.status()); let res_tombstone = decode_response::(res).await; assert!(res_tombstone.is_ok()); let res = get_apub_community_featured(path.clone().into(), context.clone(), request.clone()).await; assert!(res.is_err()); let query = Query(CommunityIsFollowerQuery { is_follower: None }); let res = get_apub_community_followers(path.clone().into(), query, context.clone(), request.clone()) .await; assert!(res.is_err()); let res = get_apub_community_moderators(path.clone().into(), context.clone()).await; assert!(res.is_err()); let res = get_apub_community_outbox(path, context.clone(), request).await; assert!(res.is_err()); data.delete(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_get_local_only_community() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let (data, _, path) = init(false, CommunityVisibility::LocalOnlyPrivate, &context).await?; let request = TestRequest::default().to_http_request(); let res = get_apub_community_http(path.clone().into(), context.clone()).await; assert!(res.is_err()); let res = get_apub_community_featured(path.clone().into(), context.clone(), request.clone()).await; assert!(res.is_err()); let query = Query(CommunityIsFollowerQuery { is_follower: None }); let res = get_apub_community_followers(path.clone().into(), query, context.clone(), request.clone()) .await; assert!(res.is_err()); let res = get_apub_community_moderators(path.clone().into(), context.clone()).await; assert!(res.is_err()); let res = get_apub_community_outbox(path, context.clone(), request).await; assert!(res.is_err()); data.delete(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_outbox_deleted_user() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let (data, community, path) = init(false, CommunityVisibility::Public, &context).await?; let request = TestRequest::default().to_http_request(); // post from deleted user shouldnt break outbox let mut form = PersonInsertForm::new("jerry".to_string(), String::new(), data.instance.id); form.deleted = Some(true); let person = Person::create(&mut context.pool(), &form).await?; let form = PostInsertForm::new("title".to_string(), person.id, community.id); Post::create(&mut context.pool(), &form).await?; let res = get_apub_community_outbox(path, context.clone(), request).await?; assert_eq!(200, res.status()); data.delete(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/apub/src/http/mod.rs ================================================ use activitypub_federation::{ actix_web::{ inbox::{ReceiveActivityHook, receive_activity_with_hook}, response::create_http_response, signing_actor, }, config::Data, traits::{Activity, Object}, }; use actix_web::{ HttpRequest, HttpResponse, web::{self, Bytes}, }; use either::Either; use lemmy_api_utils::{context::LemmyContext, plugins::plugin_hook_after}; use lemmy_apub_activities::activity_lists::SharedInboxActivities; use lemmy_apub_objects::objects::{SiteOrMultiOrCommunityOrUser, UserOrCommunity}; use lemmy_db_schema::source::{ activity::{ReceivedActivity, SentActivity}, community::Community, }; use lemmy_db_schema_file::{InstanceId, enums::CommunityVisibility}; use lemmy_db_views_community_follower_approval::PendingFollowerView; use lemmy_utils::{ FEDERATION_CONTEXT, error::{LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError}, }; use serde::Deserialize; use std::time::Duration; use tokio::time::timeout; use tracing::debug; use url::Url; mod comment; mod community; mod person; mod post; pub mod routes; pub mod site; const INCOMING_ACTIVITY_TIMEOUT: Duration = Duration::from_secs(9); pub async fn shared_inbox( request: HttpRequest, body: Bytes, data: Data, ) -> LemmyResult { let receive_fut = receive_activity_with_hook::( request, body, Dummy, &data, ); // Set a timeout shorter than `REQWEST_TIMEOUT` for processing incoming activities. This is to // avoid taking a long time to process an incoming activity when a required data fetch times out. // In this case our own instance would timeout and be marked as dead by the sender. Better to // consider the activity broken and move on. timeout(INCOMING_ACTIVITY_TIMEOUT, receive_fut) .await .with_lemmy_type(UntranslatedError::InboxTimeout.into())? } struct Dummy; impl ReceiveActivityHook for Dummy { async fn hook( self, activity: &SharedInboxActivities, _actor: &UserOrCommunity, context: &Data, ) -> LemmyResult<()> { // Store received activities in the database. This ensures that the same activity doesn't get // received and processed more than once, which would be a waste of resources. debug!("Received activity {}", activity.id().to_string()); ReceivedActivity::create(&mut context.pool(), &activity.id().clone().into()).await?; // This could also take the actor as param, but lifetimes and serde derives are tricky. // It is really a before hook, but doesnt allow modifying the data. It could use a // separate method so that error in plugin causes activity to be rejected. plugin_hook_after("activity_after_receive", activity); // This method could also be used to check if actor is banned, instead of checking in each // activity handler. Ok(()) } } #[derive(Deserialize)] struct ActivityQuery { type_: String, id: String, } /// Return the ActivityPub json representation of a local activity over HTTP. async fn get_activity( info: web::Path, context: Data, ) -> LemmyResult { let settings = context.settings(); let activity_id = Url::parse(&format!( "{}/activities/{}/{}", settings.get_protocol_and_hostname(), info.type_, info.id ))? .into(); let activity = SentActivity::read_from_apub_id(&mut context.pool(), &activity_id).await?; let sensitive = activity.sensitive; if sensitive { Ok(HttpResponse::Forbidden().finish()) } else { Ok(create_http_response(&activity.data, &FEDERATION_CONTEXT)?) } } /// Ensure that the community is public and not removed/deleted. fn check_community_fetchable(community: &Community) -> LemmyResult<()> { if !community.visibility.can_federate() { return Err(LemmyErrorType::NotFound.into()); } Ok(()) } /// Check if posts or comments in the community are allowed to be fetched async fn check_community_content_fetchable( community: &Community, request: &HttpRequest, context: &Data, ) -> LemmyResult<()> { use CommunityVisibility::*; match community.visibility { Public | Unlisted => Ok(()), Private => { let signing_actor = signing_actor::(request, None, context).await?; if community.local { Ok( PendingFollowerView::check_has_followers_from_instance( community.id, get_instance_id(&signing_actor), &mut context.pool(), ) .await?, ) } else if let Some(followers_url) = community.followers_url.clone() { let mut followers_url = followers_url.inner().clone(); followers_url .query_pairs_mut() .append_pair("is_follower", signing_actor.id().as_str()); let req = context.client().get(followers_url.as_str()); let req = context.sign_request(req, Bytes::new()).await?; context.client().execute(req).await?.error_for_status()?; Ok(()) } else { Err(LemmyErrorType::NotFound.into()) } } LocalOnlyPublic | LocalOnlyPrivate => Err(LemmyErrorType::NotFound.into()), } } pub(in crate::http) fn get_instance_id(s: &SiteOrMultiOrCommunityOrUser) -> InstanceId { use Either::*; match s { Left(Left(s)) => s.instance_id, Left(Right(m)) => m.instance_id, Right(Left(u)) => u.instance_id, Right(Right(c)) => c.instance_id, } } ================================================ FILE: crates/apub/apub/src/http/person.rs ================================================ use crate::protocol::collections::url_collection::UrlCollection; use activitypub_federation::{config::Data, traits::Object}; use actix_web::{HttpResponse, web::Path}; use lemmy_api_utils::{context::LemmyContext, utils::generate_outbox_url}; use lemmy_apub_objects::objects::person::ApubPerson; use lemmy_db_schema::{source::person::Person, traits::ApubActor}; use lemmy_utils::{ FEDERATION_CONTEXT, error::{LemmyErrorType, LemmyResult}, }; use serde::Deserialize; #[derive(Deserialize)] pub(crate) struct PersonQuery { user_name: String, } /// Return the ActivityPub json representation of a local person over HTTP. pub(crate) async fn get_apub_person_http( info: Path, context: Data, ) -> LemmyResult { let user_name = info.into_inner().user_name; // This needs to be able to read deleted persons, so that it can send tombstones let person: ApubPerson = Person::read_from_name(&mut context.pool(), &user_name, None, true) .await? .ok_or(LemmyErrorType::NotFound)? .into(); person.http_response(&FEDERATION_CONTEXT, &context).await } pub(crate) async fn get_apub_person_outbox( info: Path, context: Data, ) -> LemmyResult { let person = Person::read_from_name(&mut context.pool(), &info.user_name, None, false) .await? .ok_or(LemmyErrorType::NotFound)?; let outbox_id = generate_outbox_url(&person.ap_id)?.to_string(); UrlCollection::new_empty_response(outbox_id) } ================================================ FILE: crates/apub/apub/src/http/post.rs ================================================ use super::check_community_content_fetchable; use crate::protocol::collections::url_collection::UrlCollection; use activitypub_federation::{config::Data, traits::Object}; use actix_web::{HttpRequest, HttpResponse, web}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::{objects::post::ApubPost, utils::functions::context_url}; use lemmy_db_schema::{ newtypes::PostId, source::{community::Community, post::Post}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ FEDERATION_CONTEXT, error::{LemmyErrorType, LemmyResult}, }; use serde::Deserialize; #[derive(Deserialize)] pub(crate) struct PostQuery { post_id: String, } async fn get_post( info: web::Path, context: &Data, request: &HttpRequest, ) -> LemmyResult { let id = PostId(info.post_id.parse::()?); // Can't use PostView here because it excludes deleted/removed/local-only items let post: ApubPost = Post::read(&mut context.pool(), id).await?.into(); let community = Community::read(&mut context.pool(), post.community_id).await?; check_community_content_fetchable(&community, request, context).await?; Ok(post) } /// Return the ActivityPub json representation of a local post over HTTP. pub(crate) async fn get_apub_post( info: web::Path, context: Data, request: HttpRequest, ) -> LemmyResult { let post = get_post(info, &context, &request).await?; post.http_response(&FEDERATION_CONTEXT, &context).await } pub(crate) async fn get_apub_post_context( info: web::Path, context: Data, request: HttpRequest, ) -> LemmyResult { let post = get_post(info, &context, &request).await?; if !post.local { return Err(LemmyErrorType::NotFound.into()); } UrlCollection::new_response(&post, context_url(&post.ap_id), &context).await } ================================================ FILE: crates/apub/apub/src/http/routes.rs ================================================ use crate::http::{ comment::{get_apub_comment, get_apub_comment_context}, community::{ get_apub_community_featured, get_apub_community_followers, get_apub_community_http, get_apub_community_moderators, get_apub_community_outbox, get_apub_community_tag_http, get_apub_person_multi_community, get_apub_person_multi_community_follows, }, get_activity, person::{get_apub_person_http, get_apub_person_outbox}, post::{get_apub_post, get_apub_post_context}, shared_inbox, site::{get_apub_site_http, get_apub_site_outbox}, }; use actix_web::{ guard::{Guard, GuardContext}, http::{Method, header}, web, }; pub fn config(cfg: &mut web::ServiceConfig) { cfg .route("/", web::get().to(get_apub_site_http)) .route("/site_outbox", web::get().to(get_apub_site_outbox)) .route( "/c/{community_name}", web::get().to(get_apub_community_http), ) .route( "/c/{community_name}/followers", web::get().to(get_apub_community_followers), ) .route( "/c/{community_name}/outbox", web::get().to(get_apub_community_outbox), ) .route( "/c/{community_name}/featured", web::get().to(get_apub_community_featured), ) .route( "/c/{community_name}/moderators", web::get().to(get_apub_community_moderators), ) .route( "/c/{community_name}/tag/{tag_name}", web::get().to(get_apub_community_tag_http), ) .route("/u/{user_name}", web::get().to(get_apub_person_http)) .route( "/u/{user_name}/outbox", web::get().to(get_apub_person_outbox), ) .route( "/m/{multi_name}", web::get().to(get_apub_person_multi_community), ) .route( "/m/{multi_name}/following", web::get().to(get_apub_person_multi_community_follows), ) .route("/post/{post_id}", web::get().to(get_apub_post)) .route( "/post/{post_id}/context", web::get().to(get_apub_post_context), ) .route("/comment/{comment_id}", web::get().to(get_apub_comment)) .route( "/comment/{comment_id}/context", web::get().to(get_apub_comment_context), ) .route("/activities/{type_}/{id}", web::get().to(get_activity)); cfg.service( web::scope("") .guard(InboxRequestGuard) .route("/inbox", web::post().to(shared_inbox)), ); } /// Without this, things like webfinger or RSS feeds stop working, as all requests seem to get /// routed into the inbox service (because it covers the root path). So we filter out anything that /// definitely can't be an inbox request (based on Accept header and request method). struct InboxRequestGuard; impl Guard for InboxRequestGuard { fn check(&self, ctx: &GuardContext) -> bool { if ctx.head().method != Method::POST { return false; } if let Some(val) = ctx.head().headers.get(header::CONTENT_TYPE) { return val.as_bytes().starts_with(b"application/"); } false } } ================================================ FILE: crates/apub/apub/src/http/site.rs ================================================ use crate::protocol::collections::url_collection::UrlCollection; use activitypub_federation::{config::Data, traits::Object}; use actix_web::HttpResponse; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::objects::instance::ApubSite; use lemmy_db_views_site::SiteView; use lemmy_utils::{FEDERATION_CONTEXT, error::LemmyResult}; pub(crate) async fn get_apub_site_http(context: Data) -> LemmyResult { let site: ApubSite = SiteView::read_local(&mut context.pool()).await?.site.into(); site.http_response(&FEDERATION_CONTEXT, &context).await } pub(crate) async fn get_apub_site_outbox(context: Data) -> LemmyResult { let outbox_id = format!( "{}/site_outbox", context.settings().get_protocol_and_hostname() ); UrlCollection::new_empty_response(outbox_id) } ================================================ FILE: crates/apub/apub/src/lib.rs ================================================ use activitypub_federation::{config::UrlVerifier, error::Error as ActivityPubError}; use async_trait::async_trait; use chrono::{Days, Utc}; use lemmy_api_utils::context::LemmyContext; use lemmy_apub_objects::utils::functions::{check_apub_id_valid, local_site_data_cached}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::connection::ActualDbPool; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError}; use url::Url; pub mod collections; pub mod http; pub mod protocol; /// Maximum number of outgoing HTTP requests to fetch a single object. Needs to be high enough /// to fetch a new community with posts, moderators and featured posts. pub const FEDERATION_HTTP_FETCH_LIMIT: u32 = 100; #[derive(Clone)] pub struct VerifyUrlData(pub ActualDbPool); #[async_trait] impl UrlVerifier for VerifyUrlData { async fn verify(&self, url: &Url) -> Result<(), ActivityPubError> { use UntranslatedError::*; let local_site_data = local_site_data_cached(&mut (&self.0).into()) .await .map_err(|e| ActivityPubError::Other(format!("Cant read local site data: {e}")))?; check_apub_id_valid(url, &local_site_data).map_err(|err| match err { LemmyError { error_type: LemmyErrorType::UntranslatedError(Some(FederationDisabled)), .. } => ActivityPubError::Other("Federation disabled".into()), LemmyError { error_type: LemmyErrorType::UntranslatedError(Some(DomainBlocked(domain))), .. } => ActivityPubError::Other(format!("Domain {domain:?} is blocked")), LemmyError { error_type: LemmyErrorType::UntranslatedError(Some(DomainNotInAllowList(domain))), .. } => ActivityPubError::Other(format!("Domain {domain:?} is not in allowlist")), _ => ActivityPubError::Other("Failed validating apub id".into()), })?; Ok(()) } } /// Returns true if the local instance was created in the last 24 hours. In this case Lemmy should /// fetch less data over federation, because the setup task fetches a lot of communities. async fn is_new_instance(context: &LemmyContext) -> LemmyResult { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; Ok(local_site.published_at - Days::new(1) < Utc::now()) } ================================================ FILE: crates/apub/apub/src/protocol/collections/group_featured.rs ================================================ use activitypub_federation::kinds::collection::OrderedCollectionType; use lemmy_apub_objects::protocol::page::Page; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GroupFeatured { pub(crate) r#type: OrderedCollectionType, pub(crate) id: Url, pub(crate) total_items: i64, pub(crate) ordered_items: Vec, } ================================================ FILE: crates/apub/apub/src/protocol/collections/group_followers.rs ================================================ use activitypub_federation::kinds::collection::CollectionType; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct GroupFollowers { pub(crate) id: Url, pub(crate) r#type: CollectionType, pub(crate) total_items: i32, pub(crate) items: Vec<()>, } ================================================ FILE: crates/apub/apub/src/protocol/collections/group_moderators.rs ================================================ use activitypub_federation::{ fetch::object_id::ObjectId, kinds::collection::OrderedCollectionType, }; use lemmy_apub_objects::objects::person::ApubPerson; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GroupModerators { pub(crate) r#type: OrderedCollectionType, pub(crate) id: Url, pub(crate) ordered_items: Vec>, } ================================================ FILE: crates/apub/apub/src/protocol/collections/group_outbox.rs ================================================ use activitypub_federation::kinds::collection::OrderedCollectionType; use lemmy_apub_activities::protocol::community::announce::AnnounceActivity; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GroupOutbox { pub(crate) r#type: OrderedCollectionType, pub(crate) id: Url, pub(crate) total_items: i32, pub(crate) ordered_items: Vec, } ================================================ FILE: crates/apub/apub/src/protocol/collections/mod.rs ================================================ pub(crate) mod group_featured; pub(crate) mod group_followers; pub(crate) mod group_moderators; pub(crate) mod group_outbox; pub mod url_collection; #[cfg(test)] #[expect(clippy::as_conversions)] mod tests { use crate::protocol::collections::{ group_featured::GroupFeatured, group_followers::GroupFollowers, group_moderators::GroupModerators, group_outbox::GroupOutbox, url_collection::UrlCollection, }; use lemmy_apub_objects::utils::test::{test_json, test_parse_lemmy_item}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; #[test] fn test_parse_lemmy_collections() -> LemmyResult<()> { test_parse_lemmy_item::("assets/lemmy/collections/group_followers.json")?; let outbox = test_parse_lemmy_item::("assets/lemmy/collections/group_outbox.json")?; assert_eq!(outbox.ordered_items.len(), outbox.total_items as usize); test_parse_lemmy_item::("assets/lemmy/collections/group_featured_posts.json")?; test_parse_lemmy_item::("assets/lemmy/collections/group_moderators.json")?; test_parse_lemmy_item::("assets/lemmy/collections/person_outbox.json")?; Ok(()) } #[test] fn test_parse_mastodon_collections() -> LemmyResult<()> { test_json::("assets/mastodon/collections/featured.json")?; Ok(()) } } ================================================ FILE: crates/apub/apub/src/protocol/collections/url_collection.rs ================================================ use activitypub_federation::kinds::collection::OrderedCollectionType; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub(crate) struct UrlCollection { pub(crate) r#type: OrderedCollectionType, pub(crate) id: String, pub(crate) total_items: i32, pub(crate) ordered_items: Vec, } ================================================ FILE: crates/apub/apub/src/protocol/mod.rs ================================================ pub(crate) mod collections; ================================================ FILE: crates/apub/objects/Cargo.toml ================================================ [package] name = "lemmy_apub_objects" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_apub_objects" path = "src/lib.rs" doctest = false [features] full = [] [lints] workspace = true [dependencies] lemmy_db_views_community_moderator = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_site = { workspace = true, features = ["full"] } lemmy_db_views_private_message = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true, features = ["full"] } activitypub_federation = { workspace = true } lemmy_db_schema_file = { workspace = true } chrono = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } itertools = { workspace = true } async-trait = "0.1.89" anyhow = { workspace = true } moka.workspace = true serde_with.workspace = true html2md = "0.2.15" html2text = { workspace = true } stringreader = "0.1.1" semver = "1.0.27" either = "1.15.0" assert-json-diff = "2.0.2" lemmy_diesel_utils = { workspace = true } regex = { workspace = true } [dev-dependencies] serial_test = { workspace = true } pretty_assertions = { workspace = true } [package.metadata.cargo-shear] ignored = ["futures-util"] ================================================ FILE: crates/apub/objects/src/lib.rs ================================================ pub mod objects; pub mod protocol; pub mod utils; ================================================ FILE: crates/apub/objects/src/objects/comment.rs ================================================ use crate::{ protocol::note::Note, utils::{ functions::{ append_attachments_to_comment, check_apub_id_valid_with_strictness, context_url, generate_to, read_from_string_or_source, verify_person_in_community, verify_visibility, }, markdown_links::markdown_rewrite_remote_links, mentions::{collect_non_local_mentions, get_comment_parent_creator}, protocol::{InCommunity, LanguageTag, Source}, }, }; use activitypub_federation::{ config::Data, kinds::object::NoteType, protocol::{ values::MediaTypeMarkdownOrHtml, verification::{verify_domains_match, verify_is_remote_object}, }, traits::Object, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, utils::{ check_comment_depth, check_is_mod_or_admin, get_url_blocklist, process_markdown, slur_regex, }, }; use lemmy_db_schema::source::{ comment::{Comment, CommentInsertForm, CommentUpdateForm}, community::Community, person::Person, post::Post, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyError, LemmyResult, UntranslatedError}, utils::markdown::markdown_to_html, }; use std::ops::Deref; use url::Url; #[derive(Clone, Debug)] pub struct ApubComment(pub Comment); impl Deref for ApubComment { type Target = Comment; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubComment { fn from(c: Comment) -> Self { ApubComment(c) } } #[async_trait::async_trait] impl Object for ApubComment { type DataType = LemmyContext; type Kind = Note; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } async fn read_from_id( object_id: Url, context: &Data, ) -> LemmyResult> { Ok( Comment::read_from_apub_id(&mut context.pool(), object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, context: &Data) -> LemmyResult<()> { if !self.deleted { let form = CommentUpdateForm { deleted: Some(true), ..Default::default() }; Comment::update(&mut context.pool(), self.id, &form).await?; } Ok(()) } fn is_deleted(&self) -> bool { self.removed || self.deleted } async fn into_json(self, context: &Data) -> LemmyResult { let creator_id = self.creator_id; let creator = Person::read(&mut context.pool(), creator_id).await?; let post_id = self.post_id; let post = Post::read(&mut context.pool(), post_id).await?; let community_id = post.community_id; let community = Community::read(&mut context.pool(), community_id).await?; let in_reply_to = if let Some(comment_id) = self.parent_comment_id() { let parent_comment = Comment::read(&mut context.pool(), comment_id).await?; parent_comment.ap_id.into() } else { post.ap_id.clone().into() }; let language = Some(LanguageTag::new_single(self.language_id, &mut context.pool()).await?); // Make this call optional in case the account was deleted. let parent_creator = get_comment_parent_creator(&mut context.pool(), &self) .await .ok(); let maa = collect_non_local_mentions(Some(&self.content), parent_creator, context).await?; let note = Note { r#type: NoteType::Note, id: self.ap_id.clone().into(), attributed_to: creator.ap_id.into(), to: generate_to(&community)?, cc: maa.ccs, content: markdown_to_html(&self.content), media_type: Some(MediaTypeMarkdownOrHtml::Html), source: Some(Source::new(self.content.clone())), in_reply_to, published: Some(self.published_at), updated: self.updated_at, tag: maa.mentions, distinguished: Some(self.distinguished), language, audience: Some(community.ap_id.into()), attachment: vec![], context: Some(context_url(&self.ap_id)), }; Ok(note) } /// Recursively fetches all parent comments. This can lead to a stack overflow so we need to /// Box::pin all large futures on the heap. async fn verify( note: &Note, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { verify_domains_match(note.id.inner(), expected_domain)?; verify_domains_match(note.attributed_to.inner(), note.id.inner())?; let community = Box::pin(note.community(context)).await?; verify_visibility(¬e.to, ¬e.cc, &community)?; Box::pin(check_apub_id_valid_with_strictness( note.id.inner(), community.local, context, )) .await?; if let Err(e) = verify_is_remote_object(¬e.id, context) { if let Ok(comment) = note.id.dereference_local(context).await { comment.set_not_pending(&mut context.pool()).await?; } return Err(e.into()); } Box::pin(verify_person_in_community( ¬e.attributed_to, &community, context, )) .await?; let (post, parent_comment) = Box::pin(note.get_parents(context)).await?; let creator = Box::pin(note.attributed_to.dereference(context)).await?; let is_mod_or_admin = check_is_mod_or_admin(&mut context.pool(), creator.id, community.id) .await .is_ok(); let locked = post.locked || parent_comment.is_some_and(|c| c.locked); if locked && !is_mod_or_admin { return Err(UntranslatedError::PostIsLocked.into()); } else { Ok(()) } } /// Converts a `Note` to `Comment`. /// /// If the parent community, post and comment(s) are not known locally, these are also fetched. async fn from_json(note: Note, context: &Data) -> LemmyResult { let creator = note.attributed_to.dereference(context).await?; let (post, parent_comment) = note.get_parents(context).await?; if let Some(c) = &parent_comment { check_comment_depth(c)?; } let content = read_from_string_or_source(¬e.content, ¬e.media_type, ¬e.source); let slur_regex = slur_regex(context).await?; let url_blocklist = get_url_blocklist(context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let content = append_attachments_to_comment(content, ¬e.attachment, context).await?; let content = process_markdown(&content, &slur_regex, &url_blocklist, &local_site, context).await?; let content = markdown_rewrite_remote_links(content, context).await; let language_id = Some( LanguageTag::to_language_id_single(note.language.unwrap_or_default(), &mut context.pool()) .await?, ); let mut form = CommentInsertForm { creator_id: creator.id, post_id: post.id, content, removed: None, published_at: note.published, updated_at: note.updated, deleted: Some(false), ap_id: Some(note.id.into()), distinguished: note.distinguished, local: Some(false), language_id, federation_pending: Some(false), locked: None, }; form = plugin_hook_before("federated_comment_before_receive", form).await?; let parent_comment_path = parent_comment.map(|t| t.0.path); let timestamp: DateTime = note.updated.or(note.published).unwrap_or_else(Utc::now); let comment = Comment::insert_apub( &mut context.pool(), Some(timestamp), &form, parent_comment_path.as_ref(), ) .await?; plugin_hook_after("federated_comment_after_receive", &comment); Ok(comment.into()) } } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::{ objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson, post::ApubPost}, utils::test::{file_to_json_object, parse_lemmy_community, parse_lemmy_person}, }; use assert_json_diff::assert_json_include; use html2md::parse_html; use lemmy_db_schema::{source::instance::Instance, test_data::TestData}; use pretty_assertions::assert_eq; use serial_test::serial; async fn prepare_comment_test( url: &Url, context: &Data, ) -> LemmyResult<(ApubPerson, ApubCommunity, ApubPost, ApubSite)> { // use separate counter so this doesn't affect tests let context2 = context.clone(); let (person, site) = parse_lemmy_person(&context2).await?; let community = parse_lemmy_community(&context2).await?; let post_json = file_to_json_object("../apub/assets/lemmy/objects/page.json")?; ApubPost::verify(&post_json, url, &context2).await?; let post = ApubPost::from_json(post_json, &context2).await?; Ok((person, community, post, site)) } #[tokio::test] #[serial] pub(crate) async fn test_parse_lemmy_comment() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; let url = Url::parse("https://enterprise.lemmy.ml/comment/38741")?; prepare_comment_test(&url, &context).await?; let json: Note = file_to_json_object("../apub/assets/lemmy/objects/comment.json")?; ApubComment::verify(&json, &url, &context).await?; let comment = ApubComment::from_json(json.clone(), &context).await?; assert_eq!(comment.ap_id, url.into()); assert_eq!(comment.content.len(), 14); assert!(!comment.local); assert_eq!(context.request_count(), 0); let to_apub = comment.into_json(&context).await?; assert_json_include!(actual: json, expected: to_apub); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_parse_pleroma_comment() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; let url = Url::parse("https://enterprise.lemmy.ml/comment/38741")?; prepare_comment_test(&url, &context).await?; let pleroma_url = Url::parse("https://queer.hacktivis.me/objects/8d4973f4-53de-49cd-8c27-df160e16a9c2")?; let person_json = file_to_json_object("../apub/assets/pleroma/objects/person.json")?; ApubPerson::verify(&person_json, &pleroma_url, &context).await?; ApubPerson::from_json(person_json, &context).await?; let json = file_to_json_object("../apub/assets/pleroma/objects/note.json")?; ApubComment::verify(&json, &pleroma_url, &context).await?; let comment = ApubComment::from_json(json, &context).await?; assert_eq!(comment.ap_id, pleroma_url.into()); assert_eq!(comment.content.len(), 10); assert!(!comment.local); assert_eq!(context.request_count(), 1); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_html_to_markdown_sanitize() { let parsed = parse_html("hello"); assert_eq!(parsed, "**hello**"); } } ================================================ FILE: crates/apub/objects/src/objects/community.rs ================================================ use crate::{ objects::instance::fetch_instance_actor_for_object, protocol::{group::Group, tags::ApubCommunityTag}, utils::{ functions::{ GetActorType, check_apub_id_valid_with_strictness, community_visibility, read_from_string_or_source_opt, }, markdown_links::markdown_rewrite_remote_links_opt, protocol::{AttributedTo, ImageObject, LanguageTag, Source}, }, }; use activitypub_federation::{ config::Data, kinds::actor::GroupType, protocol::{values::MediaTypeHtml, verification::verify_domains_match}, traits::{Actor, Object}, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, utils::{ check_nsfw_allowed, generate_featured_url, generate_moderators_url, generate_outbox_url, process_markdown_opt, proxy_image_link_opt_apub, slur_regex, }, }; use lemmy_db_schema::{ source::{ actor_language::CommunityLanguage, community::{Community, CommunityInsertForm, CommunityUpdateForm}, community_tag::CommunityTag, }, traits::ApubActor, }; use lemmy_db_schema_file::enums::{ActorType, CommunityVisibility}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud}; use lemmy_utils::{ error::{LemmyError, LemmyResult}, utils::{markdown::markdown_to_html, slurs::remove_slurs, validation::truncate_summary}, }; use regex::RegexSet; use std::{ops::Deref, sync::OnceLock}; use url::Url; #[expect(clippy::type_complexity)] pub static FETCH_COMMUNITY_COLLECTIONS: OnceLock< fn(ApubCommunity, Group, Data) -> (), > = OnceLock::new(); #[derive(Clone, Debug)] pub struct ApubCommunity(pub Community); impl Deref for ApubCommunity { type Target = Community; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubCommunity { fn from(c: Community) -> Self { ApubCommunity(c) } } #[async_trait::async_trait] impl Object for ApubCommunity { type DataType = LemmyContext; type Kind = Group; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } fn last_refreshed_at(&self) -> Option> { Some(self.last_refreshed_at) } async fn read_from_id( object_id: Url, context: &Data, ) -> LemmyResult> { Ok( Community::read_from_apub_id(&mut context.pool(), &object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, context: &Data) -> LemmyResult<()> { let form = CommunityUpdateForm { deleted: Some(true), ..Default::default() }; Community::update(&mut context.pool(), self.id, &form).await?; Ok(()) } fn is_deleted(&self) -> bool { self.removed || self.deleted } async fn into_json(self, data: &Data) -> LemmyResult { let community_id = self.id; let langs = CommunityLanguage::read(&mut data.pool(), community_id).await?; let language = LanguageTag::new_multiple(langs, &mut data.pool()).await?; let community_tags = CommunityTag::read_for_community(&mut data.pool(), community_id).await?; let group = Group { kind: GroupType::Group, id: self.id().clone().into(), preferred_username: self.name.clone(), name: Some(self.title.clone()), summary: self.sidebar.as_ref().map(|d| markdown_to_html(d)), source: self.sidebar.clone().map(Source::new), description: self.summary.clone(), media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html), icon: self.icon.clone().map(ImageObject::new), image: self.banner.clone().map(ImageObject::new), sensitive: Some(self.nsfw), featured: Some(generate_featured_url(&self.ap_id)?.into()), inbox: self.inbox_url.clone().into(), outbox: generate_outbox_url(&self.ap_id)?.into(), followers: self.followers_url.clone().map(Into::into), endpoints: None, public_key: self.public_key(), language, published: Some(self.published_at), updated: self.updated_at, posting_restricted_to_mods: Some(self.posting_restricted_to_mods), attributed_to: Some(AttributedTo::Lemmy( generate_moderators_url(&self.ap_id)?.into(), )), manually_approves_followers: Some(self.visibility == CommunityVisibility::Private), discoverable: Some(self.visibility != CommunityVisibility::Unlisted), tag: community_tags .into_iter() .map(ApubCommunityTag::to_json) .collect(), }; Ok(group) } async fn verify( group: &Group, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { check_apub_id_valid_with_strictness(group.id.inner(), true, context).await?; verify_domains_match(expected_domain, group.id.inner())?; // Doesnt call verify_is_remote_object() because the community might be edited by a // remote mod. This is safe as we validate `expected_domain`. Ok(()) } /// Converts a `Group` to `Community`, inserts it into the database and updates moderators. async fn from_json(group: Group, context: &Data) -> LemmyResult { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let instance_id = fetch_instance_actor_for_object(&group.id, context).await?; let slur_regex = slur_regex(context).await?; // Use empty regex so that url blocklist doesnt prevent community federation. let url_blocklist = RegexSet::empty(); let sidebar = read_from_string_or_source_opt(&group.summary, &None, &group.source); let sidebar = process_markdown_opt(&sidebar, &slur_regex, &url_blocklist, &local_site, context).await?; let sidebar = markdown_rewrite_remote_links_opt(sidebar, context).await; let icon = proxy_image_link_opt_apub(group.icon.clone().map(|i| i.url), &local_site, context).await?; let banner = proxy_image_link_opt_apub(group.image.clone().map(|i| i.url), &local_site, context).await?; let visibility = Some(community_visibility(&group)); let summary = group .description .clone() .as_deref() .map(truncate_summary) .map(|s| remove_slurs(&s, &slur_regex)); let name = group.preferred_username.clone(); let title = remove_slurs(&group.name.clone().unwrap_or(name.clone()), &slur_regex); // If NSFW is not allowed, then remove NSFW communities let removed = check_nsfw_allowed(group.sensitive, Some(&local_site)) .err() .map(|_| true); let form = CommunityInsertForm { published_at: group.published, updated_at: group.updated, deleted: Some(false), nsfw: Some(group.sensitive.unwrap_or(false)), ap_id: Some(group.id.clone().into()), // May be a local community which is updated by remote mod. local: Some(group.id.is_local(context)), last_refreshed_at: Some(Utc::now()), icon, banner, sidebar, removed, summary, followers_url: group.followers.clone().clone().map(Into::into), inbox_url: Some( group .endpoints .clone() .map(|e| e.shared_inbox) .unwrap_or(group.inbox.clone()) .into(), ), moderators_url: group .attributed_to .clone() .clone() .and_then(AttributedTo::url), posting_restricted_to_mods: group.posting_restricted_to_mods, featured_url: group.featured.clone().clone().map(Into::into), visibility, ..CommunityInsertForm::new( instance_id, name, title, group.public_key.public_key_pem.clone(), ) }; let languages = LanguageTag::to_language_id_multiple(group.language.clone(), &mut context.pool()).await?; let timestamp = group.updated.or(group.published).unwrap_or_else(Utc::now); let community = Community::insert_apub(&mut context.pool(), timestamp, &form).await?; CommunityLanguage::update(&mut context.pool(), languages, community.id).await?; let new_tags = group .tag .iter() .map(|t| t.to_insert_form(community.id)) .collect(); let existing_tags = CommunityTag::read_for_community(&mut context.pool(), community.id).await?; CommunityTag::update_many(&mut context.pool(), new_tags, existing_tags).await?; let community: ApubCommunity = community.into(); // These collections are not necessary for Lemmy to work, so ignore errors. Reset request count // to avoid fetch errors, as it needs to fetch a lot of extra data. if let Some(fetch_fn) = FETCH_COMMUNITY_COLLECTIONS.get() { fetch_fn( community.clone(), group.clone(), context.reset_request_count(), ); } Ok(community) } } impl Actor for ApubCommunity { fn public_key_pem(&self) -> &str { &self.public_key } fn private_key_pem(&self) -> Option { self.private_key.clone().map(SensitiveString::into_inner) } fn inbox(&self) -> Url { self.inbox_url.clone().into() } fn shared_inbox(&self) -> Option { None } } impl GetActorType for ApubCommunity { fn actor_type(&self) -> ActorType { ActorType::Community } } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::utils::test::{parse_lemmy_community, parse_lemmy_instance}; use lemmy_db_schema::{source::instance::Instance, test_data::TestData}; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_parse_lemmy_community() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; parse_lemmy_instance(&context).await?; let community = parse_lemmy_community(&context).await?; assert_eq!(community.title, "Ten Forward"); assert!(!community.local); // Test the sidebar and description assert_eq!( community.sidebar.as_ref().map(std::string::String::len), Some(63) ); assert_eq!( community.summary.as_ref().map(std::string::String::len), Some(29) ); Instance::delete_all(&mut context.pool()).await?; test_data.delete(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/objects/instance.rs ================================================ use crate::{ protocol::instance::Instance, utils::{ functions::{ GetActorType, check_apub_id_valid_with_strictness, read_from_string_or_source_opt, }, markdown_links::markdown_rewrite_remote_links_opt, protocol::{ImageObject, LanguageTag, Source}, }, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::actor::ApplicationType, protocol::{ values::MediaTypeHtml, verification::{verify_domains_match, verify_is_remote_object}, }, traits::{Actor, Object}, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, utils::{get_url_blocklist, process_markdown_opt, proxy_image_link_opt_apub, slur_regex}, }; use lemmy_db_schema::source::{ actor_language::SiteLanguage, instance::Instance as DbInstance, site::{Site, SiteInsertForm}, }; use lemmy_db_schema_file::{InstanceId, enums::ActorType}; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud}; use lemmy_utils::{ error::{LemmyError, LemmyResult, UntranslatedError}, utils::{markdown::markdown_to_html, slurs::remove_slurs}, }; use std::ops::Deref; use tracing::debug; use url::Url; #[derive(Clone, Debug)] pub struct ApubSite(pub Site); impl Deref for ApubSite { type Target = Site; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubSite { fn from(s: Site) -> Self { ApubSite(s) } } #[async_trait::async_trait] impl Object for ApubSite { type DataType = LemmyContext; type Kind = Instance; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } fn last_refreshed_at(&self) -> Option> { Some(self.last_refreshed_at) } async fn read_from_id(object_id: Url, data: &Data) -> LemmyResult> { Ok( Site::read_from_apub_id(&mut data.pool(), &object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, _data: &Data) -> LemmyResult<()> { Err(UntranslatedError::CantDeleteSite.into()) } async fn into_json(self, data: &Data) -> LemmyResult { let site_id = self.id; let langs = SiteLanguage::read(&mut data.pool(), site_id).await?; let language = LanguageTag::new_multiple(langs, &mut data.pool()).await?; let instance = Instance { kind: ApplicationType::Application, id: self.id().clone().into(), name: self.name.clone(), preferred_username: Some(data.domain().to_string()), content: self.sidebar.as_ref().map(|d| markdown_to_html(d)), source: self.sidebar.clone().map(Source::new), description: self.summary.clone(), media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html), icon: self.icon.clone().map(ImageObject::new), image: self.banner.clone().map(ImageObject::new), inbox: self.inbox_url.clone().into(), outbox: Url::parse(&format!("{}site_outbox", self.ap_id))?, public_key: self.public_key(), language, content_warning: self.content_warning.clone(), published: Some(self.published_at), updated: self.updated_at, }; Ok(instance) } async fn verify( apub: &Self::Kind, expected_domain: &Url, data: &Data, ) -> LemmyResult<()> { check_apub_id_valid_with_strictness(apub.id.inner(), true, data).await?; verify_domains_match(expected_domain, apub.id.inner())?; verify_is_remote_object(&apub.id, data)?; Ok(()) } async fn from_json(apub: Self::Kind, context: &Data) -> LemmyResult { let domain = apub .id .inner() .domain() .ok_or(UntranslatedError::UrlWithoutDomain)?; let instance = DbInstance::read_or_create(&mut context.pool(), domain).await?; let slur_regex = slur_regex(context).await?; let url_blocklist = get_url_blocklist(context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let sidebar = read_from_string_or_source_opt(&apub.content, &None, &apub.source); let sidebar = process_markdown_opt(&sidebar, &slur_regex, &url_blocklist, &local_site, context).await?; let sidebar = markdown_rewrite_remote_links_opt(sidebar, context).await; let summary = apub.description.map(|s| remove_slurs(&s, &slur_regex)); let icon = proxy_image_link_opt_apub(apub.icon.map(|i| i.url), &local_site, context).await?; let banner = proxy_image_link_opt_apub(apub.image.map(|i| i.url), &local_site, context).await?; let site_form = SiteInsertForm { name: apub.name.clone(), sidebar, published_at: apub.published, updated_at: apub.updated, icon, banner, summary, ap_id: Some(apub.id.clone().into()), last_refreshed_at: Some(Utc::now()), inbox_url: Some(apub.inbox.clone().into()), public_key: Some(apub.public_key.public_key_pem.clone()), private_key: None, instance_id: instance.id, content_warning: apub.content_warning, }; let languages = LanguageTag::to_language_id_multiple(apub.language, &mut context.pool()).await?; let site = Site::create(&mut context.pool(), &site_form).await?; SiteLanguage::update(&mut context.pool(), languages, &site).await?; Ok(site.into()) } } impl Actor for ApubSite { fn public_key_pem(&self) -> &str { &self.public_key } fn private_key_pem(&self) -> Option { self.private_key.clone().map(SensitiveString::into_inner) } fn inbox(&self) -> Url { self.inbox_url.clone().into() } } impl GetActorType for ApubSite { fn actor_type(&self) -> ActorType { ActorType::Site } } /// Try to fetch the instance actor (to make things like instance rules available). pub(crate) async fn fetch_instance_actor_for_object + Clone>( object_id: &T, context: &Data, ) -> LemmyResult { let object_id: Url = object_id.clone().into(); let instance_id = Site::instance_ap_id_from_url(object_id); let site = ObjectId::::from(instance_id.clone()) .dereference(context) .await; match site { Ok(s) => Ok(s.instance_id), Err(e) => { // Failed to fetch instance actor, its probably not a lemmy instance debug!("Failed to dereference site for {}: {}", &instance_id, e); let domain = instance_id .domain() .ok_or(UntranslatedError::UrlWithoutDomain)?; Ok( DbInstance::read_or_create(&mut context.pool(), domain) .await? .id, ) } } } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::utils::test::parse_lemmy_instance; use lemmy_db_schema::{source::instance::Instance, test_data::TestData}; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_parse_lemmy_instance() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; let site = parse_lemmy_instance(&context).await?; assert_eq!(site.name, "Enterprise"); assert_eq!( site.summary.as_ref().map(std::string::String::len), Some(15) ); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/objects/mod.rs ================================================ pub mod comment; pub mod community; pub mod instance; pub mod multi_community; pub mod multi_community_collection; pub mod person; pub mod post; pub mod private_message; use comment::ApubComment; use community::ApubCommunity; use either::Either; use instance::ApubSite; use multi_community::ApubMultiCommunity; use person::ApubPerson; use post::ApubPost; // TODO: some of these are redundant? pub type PostOrComment = Either; pub type SearchableObjects = Either, ApubMultiCommunity>; pub type ReportableObjects = Either; pub type UserOrCommunity = Either; pub type SiteOrMultiOrCommunityOrUser = Either, UserOrCommunity>; pub type CommunityOrMulti = Either; pub type UserOrCommunityOrMulti = Either; ================================================ FILE: crates/apub/objects/src/objects/multi_community.rs ================================================ use crate::{ objects::ApubSite, protocol::multi_community::Feed, utils::{ functions::{ GetActorType, check_apub_id_valid_with_strictness, read_from_string_or_source_opt, }, markdown_links::markdown_rewrite_remote_links_opt, protocol::Source, }, }; use activitypub_federation::{ config::Data, protocol::{ values::MediaTypeHtml, verification::{verify_domains_match, verify_is_remote_object}, }, traits::{Actor, Object}, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, utils::{process_markdown_opt, slur_regex}, }; use lemmy_db_schema::{ source::{ multi_community::{MultiCommunity, MultiCommunityInsertForm}, person::Person, }, traits::ApubActor, }; use lemmy_db_schema_file::enums::ActorType; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud}; use lemmy_utils::{ error::{LemmyError, LemmyErrorType, LemmyResult}, utils::{ markdown::markdown_to_html, slurs::remove_slurs, validation::{ is_valid_body_field, is_valid_display_name, summary_length_check, truncate_summary, }, }, }; use regex::RegexSet; use std::ops::Deref; use url::Url; #[derive(Clone, Debug)] pub struct ApubMultiCommunity(MultiCommunity); impl Deref for ApubMultiCommunity { type Target = MultiCommunity; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubMultiCommunity { fn from(m: MultiCommunity) -> Self { ApubMultiCommunity(m) } } #[async_trait::async_trait] impl Object for ApubMultiCommunity { type DataType = LemmyContext; type Kind = Feed; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } fn last_refreshed_at(&self) -> Option> { Some(self.last_refreshed_at) } async fn read_from_id( object_id: Url, context: &Data, ) -> LemmyResult> { Ok( MultiCommunity::read_from_apub_id(&mut context.pool(), &object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, _context: &Data) -> LemmyResult<()> { Err(LemmyErrorType::NotFound.into()) } fn is_deleted(&self) -> bool { self.deleted } async fn into_json(self, context: &Data) -> LemmyResult { let site_view = SiteView::read_local(&mut context.pool()).await?; let site = ApubSite(site_view.site.clone()); let creator = Person::read(&mut context.pool(), self.creator_id).await?; Ok(Feed { r#type: Default::default(), id: self.ap_id.clone().into(), inbox: site_view.site.inbox_url.into(), // reusing pubkey from site instead of generating new one public_key: site.public_key(), following: self.following_url.clone().into(), preferred_username: self.name.clone(), name: self.title.clone(), summary: self.sidebar.as_ref().map(|d| markdown_to_html(d)), source: self.sidebar.clone().map(Source::new), description: self.summary.clone(), media_type: self.sidebar.as_ref().map(|_| MediaTypeHtml::Html), attributed_to: creator.ap_id.into(), }) } async fn verify( json: &Self::Kind, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { check_apub_id_valid_with_strictness(json.id.inner(), true, context).await?; verify_domains_match(expected_domain, json.id.inner())?; verify_is_remote_object(&json.id, context)?; Ok(()) } async fn from_json(json: Self::Kind, context: &Data) -> LemmyResult { let creator = json.attributed_to.dereference(context).await?; let slur_regex = slur_regex(context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; // Use empty regex so that url blocklist doesnt prevent community federation. let url_blocklist = RegexSet::empty(); let sidebar = read_from_string_or_source_opt(&json.summary, &None, &json.source); let sidebar = process_markdown_opt(&sidebar, &slur_regex, &url_blocklist, &local_site, context).await?; let sidebar = markdown_rewrite_remote_links_opt(sidebar, context).await; if let Some(sidebar) = &sidebar { is_valid_body_field(sidebar, false)?; } let summary = json .description .clone() .as_deref() .map(truncate_summary) .map(|s| remove_slurs(&s, &slur_regex)); if let Some(summary) = &summary { summary_length_check(summary)?; } let name = json.preferred_username.clone(); let title = json.name.map(|t| remove_slurs(&t, &slur_regex)); if let Some(title) = &title { is_valid_display_name(title)?; } let form = MultiCommunityInsertForm { creator_id: creator.id, instance_id: creator.instance_id, name, ap_id: Some(json.id.into()), local: Some(false), title, summary, sidebar, public_key: json.public_key.public_key_pem, private_key: None, inbox_url: Some(json.inbox.into()), following_url: Some(json.following.clone().into()), last_refreshed_at: Some(Utc::now()), }; let multi = MultiCommunity::upsert(&mut context.pool(), &form) .await? .into(); json.following.dereference(&multi, context).await?; Ok(multi) } } impl Actor for ApubMultiCommunity { fn public_key_pem(&self) -> &str { &self.public_key } fn private_key_pem(&self) -> Option { self.private_key.clone().map(SensitiveString::into_inner) } fn inbox(&self) -> Url { self.inbox_url.clone().into() } fn shared_inbox(&self) -> Option { None } } impl GetActorType for ApubMultiCommunity { fn actor_type(&self) -> ActorType { ActorType::MultiCommunity } } ================================================ FILE: crates/apub/objects/src/objects/multi_community_collection.rs ================================================ use super::multi_community::ApubMultiCommunity; use crate::protocol::multi_community::FeedCollection; use activitypub_federation::{ config::Data, protocol::verification::verify_domains_match, traits::Collection, }; use futures::future::join_all; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, }; use lemmy_db_schema::{ newtypes::CommunityId, source::{ community::{CommunityActions, CommunityFollowerForm}, multi_community::MultiCommunity, }, traits::Followable, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyError, LemmyResult}; use tracing::info; use url::Url; pub struct ApubFeedCollection; #[async_trait::async_trait] impl Collection for ApubFeedCollection { type DataType = LemmyContext; type Kind = FeedCollection; type Owner = ApubMultiCommunity; type Error = LemmyError; async fn read_local( owner: &Self::Owner, context: &Data, ) -> Result { let entries = MultiCommunity::read_community_ap_ids(&mut context.pool(), &owner.name).await?; Ok(Self::Kind { r#type: Default::default(), id: owner.following_url.clone().into(), total_items: entries.len().try_into()?, items: entries.into_iter().map(Into::into).collect(), }) } async fn verify( json: &Self::Kind, expected_domain: &Url, _context: &Data, ) -> LemmyResult<()> { verify_domains_match(expected_domain, &json.id.clone().into())?; Ok(()) } async fn from_json( json: Self::Kind, owner: &Self::Owner, context: &Data, ) -> LemmyResult { let communities = join_all( json .items .into_iter() .map(|ap_id| async move { Ok(ap_id.dereference(context).await?.id) }), ) .await .into_iter() .flat_map(|c: LemmyResult| match c { Ok(c) => Some(c), Err(e) => { info!("Failed to fetch multi-community item: {e}"); None } }) .collect(); let (remote_added, remote_removed, has_local_followers) = MultiCommunity::update_entries(&mut context.pool(), owner.id, &communities).await?; // Have multi-comm follower bot follow all communities which were added to multi-comm, // and unfollow those that were removed. // If the multi-comm has no local followers its ignored. // TODO: This means there will be posts missing in multi-comm without local followers. if has_local_followers { let system_account = SiteView::read_system_account(&mut context.pool()).await?; for community in remote_added { let form = CommunityFollowerForm::new( community.id, system_account.id, CommunityFollowerState::Pending, ); CommunityActions::follow(&mut context.pool(), &form).await?; ActivityChannel::submit_activity( SendActivityData::FollowCommunity(community.clone(), system_account.clone(), true), context, )?; } for community in remote_removed { CommunityActions::unfollow(&mut context.pool(), system_account.id, community.id).await?; ActivityChannel::submit_activity( SendActivityData::FollowCommunity(community.clone(), system_account.clone(), false), context, )?; } } Ok(ApubFeedCollection) } } ================================================ FILE: crates/apub/objects/src/objects/person.rs ================================================ use crate::{ objects::instance::fetch_instance_actor_for_object, protocol::person::{Person, UserTypes}, utils::{ functions::{ GetActorType, check_apub_id_valid_with_strictness, read_from_string_or_source_opt, }, markdown_links::markdown_rewrite_remote_links_opt, protocol::{ImageObject, Source}, }, }; use activitypub_federation::{ config::Data, protocol::verification::{verify_domains_match, verify_is_remote_object}, traits::{Actor, Object}, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, utils::{ generate_outbox_url, get_url_blocklist, process_markdown_opt, proxy_image_link_opt_apub, slur_regex, }, }; use lemmy_db_schema::{ source::person::{Person as DbPerson, PersonInsertForm, PersonUpdateForm}, traits::ApubActor, }; use lemmy_db_schema_file::enums::ActorType; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{sensitive::SensitiveString, traits::Crud}; use lemmy_utils::{ error::{LemmyError, LemmyResult}, utils::{markdown::markdown_to_html, slurs::remove_slurs}, }; use std::ops::Deref; use url::Url; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ApubPerson(pub DbPerson); impl Deref for ApubPerson { type Target = DbPerson; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubPerson { fn from(p: DbPerson) -> Self { ApubPerson(p) } } #[async_trait::async_trait] impl Object for ApubPerson { type DataType = LemmyContext; type Kind = Person; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } fn last_refreshed_at(&self) -> Option> { Some(self.last_refreshed_at) } async fn read_from_id( object_id: Url, context: &Data, ) -> LemmyResult> { Ok( DbPerson::read_from_apub_id(&mut context.pool(), &object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, context: &Data) -> LemmyResult<()> { let form = PersonUpdateForm { deleted: Some(true), ..Default::default() }; DbPerson::update(&mut context.pool(), self.id, &form).await?; Ok(()) } fn is_deleted(&self) -> bool { self.deleted } async fn into_json(self, _context: &Data) -> LemmyResult { let kind = if self.bot_account { UserTypes::Service } else { UserTypes::Person }; let person = Person { kind, id: self.ap_id.clone().into(), preferred_username: self.name.clone(), name: self.display_name.clone(), summary: self.bio.as_ref().map(|b| markdown_to_html(b)), source: self.bio.clone().map(Source::new), icon: self.avatar.clone().map(ImageObject::new), image: self.banner.clone().map(ImageObject::new), matrix_user_id: self.matrix_user_id.clone(), published: Some(self.published_at), outbox: generate_outbox_url(&self.ap_id)?.into(), endpoints: None, public_key: self.public_key(), updated: self.updated_at, inbox: self.inbox_url.clone().into(), }; Ok(person) } async fn verify( person: &Person, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { verify_domains_match(person.id.inner(), expected_domain)?; verify_is_remote_object(&person.id, context)?; check_apub_id_valid_with_strictness(person.id.inner(), false, context).await?; Ok(()) } async fn from_json(person: Person, context: &Data) -> LemmyResult { let instance_id = fetch_instance_actor_for_object(&person.id, context).await?; let slur_regex = slur_regex(context).await?; let url_blocklist = get_url_blocklist(context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let bio = read_from_string_or_source_opt(&person.summary, &None, &person.source); let bio = process_markdown_opt(&bio, &slur_regex, &url_blocklist, &local_site, context).await?; let bio = markdown_rewrite_remote_links_opt(bio, context).await; let avatar = proxy_image_link_opt_apub(person.icon.map(|i| i.url), &local_site, context).await?; let banner = proxy_image_link_opt_apub(person.image.map(|i| i.url), &local_site, context).await?; let display_name = person.name.map(|s| remove_slurs(&s, &slur_regex)); let person_form = PersonInsertForm { name: person.preferred_username, display_name, deleted: Some(false), avatar, banner, published_at: person.published, updated_at: person.updated, ap_id: Some(person.id.into()), bio, local: Some(false), bot_account: Some(person.kind == UserTypes::Service), private_key: None, public_key: person.public_key.public_key_pem, last_refreshed_at: Some(Utc::now()), inbox_url: Some( person .endpoints .map(|e| e.shared_inbox) .unwrap_or(person.inbox) .into(), ), matrix_user_id: person.matrix_user_id, instance_id, }; let person = DbPerson::upsert(&mut context.pool(), &person_form).await?; Ok(person.into()) } } impl Actor for ApubPerson { fn public_key_pem(&self) -> &str { &self.public_key } fn private_key_pem(&self) -> Option { self.private_key.clone().map(SensitiveString::into_inner) } fn inbox(&self) -> Url { self.inbox_url.clone().into() } fn shared_inbox(&self) -> Option { None } } impl GetActorType for ApubPerson { fn actor_type(&self) -> ActorType { ActorType::Person } } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::{ objects::instance::ApubSite, utils::test::{file_to_json_object, parse_lemmy_person}, }; use activitypub_federation::fetch::object_id::ObjectId; use lemmy_db_schema::{source::instance::Instance, test_data::TestData}; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_parse_lemmy_person() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; let (person, _) = parse_lemmy_person(&context).await?; assert_eq!(person.display_name, Some("Jean-Luc Picard".to_string())); assert!(!person.local); assert_eq!(person.bio.as_ref().map(std::string::String::len), Some(39)); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_parse_pleroma_person() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; // create and parse a fake pleroma instance actor, to avoid network request during test let mut json: crate::protocol::instance::Instance = file_to_json_object("../apub/assets/lemmy/objects/instance.json")?; json.id = ObjectId::parse("https://queer.hacktivis.me/")?; let url = Url::parse("https://queer.hacktivis.me/users/lanodan")?; ApubSite::verify(&json, &url, &context).await?; ApubSite::from_json(json, &context).await?; let json = file_to_json_object("../apub/assets/pleroma/objects/person.json")?; ApubPerson::verify(&json, &url, &context).await?; let person = ApubPerson::from_json(json, &context).await?; assert_eq!(person.ap_id, url.into()); assert_eq!(person.name, "lanodan"); assert!(!person.local); assert_eq!(context.request_count(), 0); assert_eq!(person.bio.as_ref().map(std::string::String::len), Some(812)); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/objects/post.rs ================================================ use crate::{ protocol::{ page::{Attachment, Page, PageType}, tags::{ApubCommunityTag, ApubTag, Hashtag, HashtagType}, }, utils::{ functions::{ check_apub_id_valid_with_strictness, context_url, generate_to, read_from_string_or_source_opt, verify_person_in_community, verify_visibility, }, markdown_links::{markdown_rewrite_remote_links_opt, to_local_url}, mentions::collect_non_local_mentions, protocol::{AttributedTo, ImageObject, InCommunity, LanguageTag, Source}, }, }; use activitypub_federation::{ config::Data, protocol::{ values::MediaTypeMarkdownOrHtml, verification::{verify_domains_match, verify_is_remote_object}, }, traits::Object, }; use anyhow::anyhow; use chrono::Utc; use html2text::{from_read_with_decorator, render::TrivialDecorator}; use lemmy_api_utils::{ context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, request::generate_post_link_metadata, utils::{ check_nsfw_allowed, get_url_blocklist, process_markdown_opt, slur_regex, update_post_tags, }, }; use lemmy_db_schema::source::{ community::Community, community_tag::CommunityTag, local_site::LocalSite, person::Person, post::{Post, PostInsertForm, PostUpdateForm}, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyError, LemmyResult}, spawn_try_task, utils::{ markdown::markdown_to_html, slurs::remove_slurs, validation::{is_url_blocked, is_valid_url}, }, }; use std::{collections::HashSet, ops::Deref}; use stringreader::StringReader; use url::Url; const MAX_TITLE_LENGTH: usize = 200; #[derive(Clone, Debug, PartialEq)] pub struct ApubPost(pub Post); impl Deref for ApubPost { type Target = Post; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubPost { fn from(p: Post) -> Self { ApubPost(p) } } #[async_trait::async_trait] impl Object for ApubPost { type DataType = LemmyContext; type Kind = Page; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } async fn read_from_id( object_id: Url, context: &Data, ) -> LemmyResult> { Ok( Post::read_from_apub_id(&mut context.pool(), object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, context: &Data) -> LemmyResult<()> { if !self.deleted { let form = PostUpdateForm { deleted: Some(true), ..Default::default() }; Post::update(&mut context.pool(), self.id, &form).await?; } Ok(()) } fn is_deleted(&self) -> bool { self.removed || self.deleted } // Turn a Lemmy post into an ActivityPub page that can be sent out over the network. async fn into_json(self, context: &Data) -> LemmyResult { let creator_id = self.creator_id; let creator = Person::read(&mut context.pool(), creator_id).await?; let community_id = self.community_id; let community = Community::read(&mut context.pool(), community_id).await?; let language = Some(LanguageTag::new_single(self.language_id, &mut context.pool()).await?); let attachment = self .url .clone() .map(|url| { Attachment::new( url.into(), self.url_content_type.clone(), self.alt_text.clone(), ) }) .into_iter() .collect(); // Add tags defined by community and applied to this post let mut tags: Vec = CommunityTag::read_for_post(&mut context.pool(), self.id) .await? .into_iter() .map(|tag| ApubTag::CommunityTag(ApubCommunityTag::to_json(tag))) .collect(); // Add automatic hashtag based on community name let hashtag = Hashtag { href: self.ap_id.clone().into(), name: format!("#{}", &community.name), kind: HashtagType::Hashtag, }; tags.push(ApubTag::Hashtag(hashtag)); let maa = collect_non_local_mentions(self.body.as_deref(), None, context).await?; tags.extend(maa.mentions); let page = Page { kind: PageType::Page, id: self.ap_id.clone().into(), attributed_to: AttributedTo::Lemmy(creator.ap_id.into()), to: generate_to(&community)?, cc: maa.ccs, name: Some(self.name.clone()), content: self.body.as_ref().map(|b| markdown_to_html(b)), media_type: Some(MediaTypeMarkdownOrHtml::Html), source: self.body.clone().map(Source::new), attachment, image: self.thumbnail_url.clone().map(ImageObject::new), sensitive: Some(self.nsfw), language, published: Some(self.published_at), updated: self.updated_at, audience: Some(community.ap_id.into()), in_reply_to: None, tag: tags, context: Some(context_url(&self.ap_id)), }; Ok(page) } async fn verify( page: &Page, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { verify_domains_match(page.id.inner(), expected_domain)?; let community = page.community(context).await?; // Doesnt call verify_is_remote_object() because the community might be edited by a // remote mod. This is safe as we validate `expected_domain`. check_apub_id_valid_with_strictness(page.id.inner(), community.local, context).await?; verify_person_in_community(&page.creator()?, &community, context).await?; verify_domains_match(page.creator()?.inner(), page.id.inner())?; verify_visibility(&page.to, &page.cc, &community)?; if let Err(e) = verify_is_remote_object(&page.id, context) { if let Ok(post) = page.id.dereference_local(context).await { post.set_not_pending(&mut context.pool()).await?; } return Err(e.into()); } Ok(()) } async fn from_json(page: Page, context: &Data) -> LemmyResult { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let creator = page.creator()?.dereference(context).await?; let community = page.community(context).await?; let slur_regex = slur_regex(context).await?; // Prevent posts from non-mod users in local, restricted community. If its a remote community // then its possible that the restricted setting was enabled recently, so existing user posts // should still be fetched. if community.local && community.posting_restricted_to_mods { CommunityModeratorView::check_is_community_moderator( &mut context.pool(), community.id, creator.id, ) .await?; } let mut name = page .name .clone() .or_else(|| { // Posts coming from Mastodon or similar platforms don't have a title. Instead we take the // first line of the content and convert it from HTML to plaintext. We also remove mentions // of the community name. let c = page .content .as_deref() .map(StringReader::new) .map(|c| from_read_with_decorator(c, MAX_TITLE_LENGTH, TrivialDecorator::new()))?; c.unwrap_or_default().lines().next().map(|s| { s.replace(&format!("@{}", community.name), "") .trim() .to_string() }) }) .map(|s| remove_slurs(&s, &slur_regex)) .ok_or_else(|| anyhow!("Object must have name or content"))?; if name.chars().count() > MAX_TITLE_LENGTH { name = name.chars().take(MAX_TITLE_LENGTH).collect(); } let first_attachment = page.attachment.first(); let url = if let Some(attachment) = first_attachment.cloned() { Some(attachment.url()) } else if page.kind == PageType::Video { // we cant display videos directly, so insert a link to external video page Some(page.id.inner().clone()) } else { None }; let url_blocklist = get_url_blocklist(context).await?; let url = if let Some(url) = url { is_url_blocked(&url, &url_blocklist)?; is_valid_url(&url)?; if page.kind != PageType::Video { to_local_url(url.as_str(), context).await.or(Some(url)) } else { Some(url) } } else { None }; let alt_text = first_attachment.cloned().and_then(Attachment::alt_text); let body = read_from_string_or_source_opt(&page.content, &page.media_type, &page.source); let body = process_markdown_opt(&body, &slur_regex, &url_blocklist, &local_site, context).await?; let body = markdown_rewrite_remote_links_opt(body, context).await; let language_id = Some( LanguageTag::to_language_id_single( page.language.clone().unwrap_or_default(), &mut context.pool(), ) .await?, ); let orig_post = Post::read_from_apub_id(&mut context.pool(), page.id.clone().into()).await; let mut form = PostInsertForm { url: url.map(Into::into), body, alt_text, published_at: page.published, updated_at: page.updated, deleted: Some(false), nsfw: post_nsfw(&page, &community, Some(&local_site), context).await?, ap_id: Some(page.id.clone().into()), // May be a local post which is updated by remote mod. local: Some(page.id.is_local(context)), language_id, ..PostInsertForm::new(name, creator.id, community.id) }; form = plugin_hook_before("federated_post_before_receive", form).await?; let timestamp = page.updated.or(page.published).unwrap_or_else(Utc::now); let post = Post::insert_apub(&mut context.pool(), timestamp, &form).await?; plugin_hook_after("federated_post_after_receive", &post); update_apub_post_tags(&page, &post, context).await?; let post_ = post.clone(); let context_ = context.clone(); // Avoid regenerating metadata if the post already existed with the same url let no_generate_metadata = orig_post.ok().flatten().is_some_and(|p| p.url == post.url); if !no_generate_metadata { // Generates a post thumbnail in background task, because some sites can be very slow to // respond. spawn_try_task( async move { generate_post_link_metadata(post_, None, |_| None, context_).await }, ); } Ok(post.into()) } } pub async fn update_apub_post_tags( page: &Page, post: &Post, context: &LemmyContext, ) -> LemmyResult<()> { let post_tag_ap_ids = page .tag .iter() .filter_map(ApubTag::community_tag_id) .collect::>(); let community_tags = CommunityTag::read_for_community(&mut context.pool(), post.community_id).await?; let post_tags = community_tags .into_iter() .filter(|t| post_tag_ap_ids.contains(&*t.ap_id.0)) .map(|t| t.id) .collect::>(); update_post_tags(post, &post_tags, context).await?; Ok(()) } pub async fn post_nsfw( page: &Page, community: &Community, local_site: Option<&LocalSite>, context: &LemmyContext, ) -> LemmyResult> { // Ensure that all posts in NSFW communities are marked as NSFW let nsfw = if community.nsfw { Some(true) } else { page.sensitive }; // If NSFW is not allowed, reject NSFW posts and delete existing // posts that get updated to be NSFW let block_for_nsfw = check_nsfw_allowed(nsfw, local_site); if let Err(e) = block_for_nsfw { // TODO: Remove locally generated thumbnail if one exists, depends on // https://github.com/LemmyNet/lemmy/issues/5564 to be implemented to be able to // safely do this. Post::delete_from_apub_id(&mut context.pool(), page.id.inner().clone()).await?; return Err(e); } Ok(nsfw) } #[cfg(test)] mod tests { use super::*; use crate::{ objects::ApubPerson, utils::test::{file_to_json_object, parse_lemmy_community, parse_lemmy_person}, }; use lemmy_db_schema::{source::instance::Instance, test_data::TestData}; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_parse_lemmy_post() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; parse_lemmy_person(&context).await?; parse_lemmy_community(&context).await?; let json = file_to_json_object("../apub/assets/lemmy/objects/page.json")?; let url = Url::parse("https://enterprise.lemmy.ml/post/55143")?; ApubPost::verify(&json, &url, &context).await?; let post = ApubPost::from_json(json, &context).await?; assert_eq!(post.ap_id, url.into()); assert_eq!(post.name, "Post title"); assert!(post.body.is_some()); assert_eq!(post.body.as_ref().map(std::string::String::len), Some(45)); assert!(!post.locked); assert!(!post.featured_community); assert_eq!(context.request_count(), 0); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_convert_mastodon_post_title() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; parse_lemmy_community(&context).await?; let json = file_to_json_object("../apub/assets/mastodon/objects/person.json")?; ApubPerson::from_json(json, &context).await?; let json = file_to_json_object("../apub/assets/mastodon/objects/page.json")?; let post = ApubPost::from_json(json, &context).await?; assert_eq!(post.name, "Variable never resetting at refresh"); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/objects/private_message.rs ================================================ use crate::{ protocol::private_message::{PrivateMessage, PrivateMessageType}, utils::{ functions::{check_apub_id_valid_with_strictness, read_from_string_or_source}, markdown_links::markdown_rewrite_remote_links, protocol::Source, }, }; use activitypub_federation::{ config::Data, protocol::{ values::MediaTypeHtml, verification::{verify_domains_match, verify_is_remote_object}, }, traits::Object, }; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, notify::notify_private_message, plugins::{plugin_hook_after, plugin_hook_before}, utils::{check_private_messages_enabled, get_url_blocklist, process_markdown, slur_regex}, }; use lemmy_db_schema::{ source::{ instance::{Instance, InstanceActions}, person::{Person, PersonActions}, private_message::{PrivateMessage as DbPrivateMessage, PrivateMessageInsertForm}, }, traits::Blockable, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyError, LemmyErrorType, LemmyResult}, utils::markdown::markdown_to_html, }; use semver::{Version, VersionReq}; use std::ops::Deref; use url::Url; #[derive(Clone, Debug)] pub struct ApubPrivateMessage(pub DbPrivateMessage); impl Deref for ApubPrivateMessage { type Target = DbPrivateMessage; fn deref(&self) -> &Self::Target { &self.0 } } impl From for ApubPrivateMessage { fn from(pm: DbPrivateMessage) -> Self { ApubPrivateMessage(pm) } } #[async_trait::async_trait] impl Object for ApubPrivateMessage { type DataType = LemmyContext; type Kind = PrivateMessage; type Error = LemmyError; fn id(&self) -> &Url { self.ap_id.inner() } async fn read_from_id( object_id: Url, context: &Data, ) -> LemmyResult> { Ok( DbPrivateMessage::read_from_apub_id(&mut context.pool(), object_id.into()) .await? .map(Into::into), ) } async fn delete(&self, _context: &Data) -> LemmyResult<()> { // do nothing, because pm can't be fetched over http Err(LemmyErrorType::NotFound.into()) } fn is_deleted(&self) -> bool { self.removed || self.deleted } async fn into_json(self, context: &Data) -> LemmyResult { let creator_id = self.creator_id; let creator = Person::read(&mut context.pool(), creator_id).await?; let recipient_id = self.recipient_id; let recipient = Person::read(&mut context.pool(), recipient_id).await?; let instance = Instance::read(&mut context.pool(), recipient.instance_id).await?; let mut kind = PrivateMessageType::Note; // Deprecated: For Lemmy versions before 0.20, send private messages with old type if let (Some(software), Some(version)) = (instance.software, &instance.version) { let req = VersionReq::parse("<0.20")?; if software == "lemmy" && req.matches(&Version::parse(version)?) { kind = PrivateMessageType::ChatMessage } } let note = PrivateMessage { kind, id: self.ap_id.clone().into(), attributed_to: creator.ap_id.into(), to: [recipient.ap_id.into()], content: markdown_to_html(&self.content), media_type: Some(MediaTypeHtml::Html), source: Some(Source::new(self.content.clone())), published: Some(self.published_at), updated: self.updated_at, }; Ok(note) } async fn verify( note: &PrivateMessage, expected_domain: &Url, context: &Data, ) -> LemmyResult<()> { verify_domains_match(note.id.inner(), expected_domain)?; verify_domains_match(note.attributed_to.inner(), note.id.inner())?; verify_is_remote_object(¬e.id, context)?; check_apub_id_valid_with_strictness(note.id.inner(), false, context).await?; let person = note.attributed_to.dereference(context).await?; InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?; Ok(()) } async fn from_json( note: PrivateMessage, context: &Data, ) -> LemmyResult { let creator = note.attributed_to.dereference(context).await?; let recipient = note.to[0].dereference(context).await?; PersonActions::read_block(&mut context.pool(), recipient.id, creator.id).await?; // Check that they can receive private messages if let Ok(recipient_local_user) = LocalUserView::read_person(&mut context.pool(), recipient.id).await { check_private_messages_enabled(&recipient_local_user)?; } let slur_regex = slur_regex(context).await?; let url_blocklist = get_url_blocklist(context).await?; let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let content = read_from_string_or_source(¬e.content, &None, ¬e.source); let content = process_markdown(&content, &slur_regex, &url_blocklist, &local_site, context).await?; let content = markdown_rewrite_remote_links(content, context).await; let mut form = PrivateMessageInsertForm { creator_id: creator.id, recipient_id: recipient.id, content, published_at: note.published, updated_at: note.updated, deleted: Some(false), ap_id: Some(note.id.into()), local: Some(false), }; form = plugin_hook_before("federated_private_message_before_receive", form).await?; let timestamp = note.updated.or(note.published).unwrap_or_else(Utc::now); let pm = DbPrivateMessage::insert_apub(&mut context.pool(), timestamp, &form).await?; plugin_hook_after("federated_private_message_after_receive", &pm); let view = PrivateMessageView::read(&mut context.pool(), pm.id, None).await?; notify_private_message(&view, pm.updated_at.is_none(), context); Ok(pm.into()) } } #[cfg(test)] mod tests { use super::*; use crate::{ objects::{instance::ApubSite, person::ApubPerson}, utils::test::{file_to_json_object, parse_lemmy_instance}, }; use assert_json_diff::assert_json_include; use lemmy_db_schema::test_data::TestData; use pretty_assertions::assert_eq; use serial_test::serial; async fn prepare_comment_test( url: &Url, context: &Data, ) -> LemmyResult<(ApubPerson, ApubPerson, ApubSite)> { let context2 = context.clone(); let lemmy_person = file_to_json_object("../apub/assets/lemmy/objects/person.json")?; let site = parse_lemmy_instance(&context2).await?; ApubPerson::verify(&lemmy_person, url, &context2).await?; let person1 = ApubPerson::from_json(lemmy_person, &context2).await?; let pleroma_person = file_to_json_object("../apub/assets/pleroma/objects/person.json")?; let pleroma_url = Url::parse("https://queer.hacktivis.me/users/lanodan")?; ApubPerson::verify(&pleroma_person, &pleroma_url, &context2).await?; let person2 = ApubPerson::from_json(pleroma_person, &context2).await?; Ok((person1, person2, site)) } #[tokio::test] #[serial] async fn test_parse_lemmy_pm() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; let url = Url::parse("https://enterprise.lemmy.ml/private_message/1621")?; prepare_comment_test(&url, &context).await?; let json: PrivateMessage = file_to_json_object("../apub/assets/lemmy/objects/private_message.json")?; ApubPrivateMessage::verify(&json, &url, &context).await?; let pm = ApubPrivateMessage::from_json(json.clone(), &context).await?; assert_eq!(pm.ap_id.clone(), url.into()); assert_eq!(pm.content.len(), 20); assert_eq!(context.request_count(), 0); let to_apub = pm.into_json(&context).await?; assert_json_include!(actual: json, expected: to_apub); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } #[tokio::test] #[serial] async fn test_parse_pleroma_pm() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let test_data = TestData::create(&mut context.pool()).await?; let url = Url::parse("https://enterprise.lemmy.ml/private_message/1621")?; prepare_comment_test(&url, &context).await?; let pleroma_url = Url::parse("https://queer.hacktivis.me/objects/2")?; let json = file_to_json_object("../apub/assets/pleroma/objects/chat_message.json")?; ApubPrivateMessage::verify(&json, &pleroma_url, &context).await?; let pm = ApubPrivateMessage::from_json(json, &context).await?; assert_eq!(pm.ap_id, pleroma_url.into()); assert_eq!(pm.content.len(), 3); assert_eq!(context.request_count(), 0); test_data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/protocol/group.rs ================================================ use crate::{ objects::community::ApubCommunity, protocol::tags::ApubCommunityTag, utils::protocol::{AttributedTo, Endpoints, ImageObject, LanguageTag, Source}, }; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::actor::GroupType, protocol::{ helpers::{deserialize_last, deserialize_skip_error}, public_key::PublicKey, values::MediaTypeHtml, }, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::fmt::Debug; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Group { #[serde(rename = "type")] pub(crate) kind: GroupType, pub id: ObjectId, /// username, set at account creation and usually fixed after that pub preferred_username: String, pub inbox: Url, pub followers: Option, pub public_key: PublicKey, /// title / display name pub name: Option, // short description pub(crate) description: Option, /// sidebar #[serde(deserialize_with = "deserialize_skip_error", default)] pub source: Option, pub(crate) media_type: Option, // sidebar pub summary: Option, #[serde(deserialize_with = "deserialize_last", default)] pub icon: Option, /// banner #[serde(deserialize_with = "deserialize_last", default)] pub image: Option, // lemmy extension pub sensitive: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub attributed_to: Option, // lemmy extension pub posting_restricted_to_mods: Option, pub outbox: Url, pub endpoints: Option, pub featured: Option, #[serde(default)] pub(crate) language: Vec, /// True if this is a private community pub(crate) manually_approves_followers: Option, pub published: Option>, pub updated: Option>, /// https://docs.joinmastodon.org/spec/activitypub/#discoverable pub(crate) discoverable: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) tag: Vec, } ================================================ FILE: crates/apub/objects/src/protocol/instance.rs ================================================ use crate::{ objects::instance::ApubSite, utils::protocol::{ImageObject, LanguageTag, Source}, }; use activitypub_federation::{ fetch::object_id::ObjectId, kinds::actor::ApplicationType, protocol::{helpers::deserialize_skip_error, public_key::PublicKey, values::MediaTypeHtml}, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Instance { #[serde(rename = "type")] pub(crate) kind: ApplicationType, pub(crate) id: ObjectId, /// site name pub(crate) name: String, /// instance domain, necessary for mastodon authorized fetch pub(crate) preferred_username: Option, pub(crate) inbox: Url, /// mandatory field in activitypub, lemmy currently serves an empty outbox pub(crate) outbox: Url, pub(crate) public_key: PublicKey, // sidebar pub(crate) content: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) source: Option, pub(crate) media_type: Option, // short description pub(crate) description: Option, /// instance icon pub(crate) icon: Option, /// instance banner pub(crate) image: Option, #[serde(default)] pub(crate) language: Vec, /// nonstandard field pub(crate) content_warning: Option, pub(crate) published: Option>, pub(crate) updated: Option>, } ================================================ FILE: crates/apub/objects/src/protocol/mod.rs ================================================ pub mod group; pub mod instance; pub mod multi_community; pub mod note; pub mod page; pub mod person; pub mod private_message; pub mod tags; #[cfg(test)] mod tests { use super::{ group::Group, instance::Instance, note::Note, page::Page, person::Person, private_message::PrivateMessage, }; use crate::utils::test::{test_json, test_parse_lemmy_item}; use activitypub_federation::protocol::tombstone::Tombstone; use lemmy_utils::error::LemmyResult; #[test] fn test_parse_objects_lemmy() -> LemmyResult<()> { test_parse_lemmy_item::("../apub/assets/lemmy/objects/instance.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/group.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/person.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/page.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/comment.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/private_message.json")?; test_parse_lemmy_item::("../apub/assets/lemmy/objects/tombstone.json")?; Ok(()) } #[test] fn test_parse_objects_pleroma() -> LemmyResult<()> { test_json::("../apub/assets/pleroma/objects/person.json")?; test_json::("../apub/assets/pleroma/objects/note.json")?; Ok(()) } #[test] fn test_parse_objects_smithereen() -> LemmyResult<()> { test_json::("../apub/assets/smithereen/objects/person.json")?; test_json::("../apub/assets/smithereen/objects/note.json")?; Ok(()) } #[test] fn test_parse_objects_mastodon() -> LemmyResult<()> { test_json::("../apub/assets/mastodon/objects/person.json")?; test_json::("../apub/assets/mastodon/objects/note_1.json")?; test_json::("../apub/assets/mastodon/objects/note_2.json")?; test_json::("../apub/assets/mastodon/objects/page.json")?; Ok(()) } #[test] fn test_parse_objects_lotide() -> LemmyResult<()> { test_json::("../apub/assets/lotide/objects/group.json")?; test_json::("../apub/assets/lotide/objects/person.json")?; test_json::("../apub/assets/lotide/objects/note.json")?; test_json::("../apub/assets/lotide/objects/page.json")?; test_json::("../apub/assets/lotide/objects/tombstone.json")?; Ok(()) } #[test] fn test_parse_object_friendica() -> LemmyResult<()> { test_json::("../apub/assets/friendica/objects/person_1.json")?; test_json::("../apub/assets/friendica/objects/person_2.json")?; test_json::("../apub/assets/friendica/objects/page_1.json")?; test_json::("../apub/assets/friendica/objects/page_2.json")?; test_json::("../apub/assets/friendica/objects/note_1.json")?; test_json::("../apub/assets/friendica/objects/note_2.json")?; Ok(()) } #[test] fn test_parse_object_gnusocial() -> LemmyResult<()> { test_json::("../apub/assets/gnusocial/objects/person.json")?; test_json::("../apub/assets/gnusocial/objects/group.json")?; test_json::("../apub/assets/gnusocial/objects/page.json")?; test_json::("../apub/assets/gnusocial/objects/note.json")?; Ok(()) } #[test] fn test_parse_object_peertube() -> LemmyResult<()> { test_json::("../apub/assets/peertube/objects/person.json")?; test_json::("../apub/assets/peertube/objects/group.json")?; test_json::("../apub/assets/peertube/objects/video.json")?; test_json::("../apub/assets/peertube/objects/note.json")?; Ok(()) } #[test] fn test_parse_object_mobilizon() -> LemmyResult<()> { test_json::("../apub/assets/mobilizon/objects/group.json")?; test_json::("../apub/assets/mobilizon/objects/event.json")?; test_json::("../apub/assets/mobilizon/objects/person.json")?; Ok(()) } #[test] fn test_parse_object_discourse() -> LemmyResult<()> { test_json::("../apub/assets/discourse/objects/group.json")?; test_json::("../apub/assets/discourse/objects/page.json")?; test_json::("../apub/assets/discourse/objects/person.json")?; Ok(()) } #[test] fn test_parse_object_nodebb() -> LemmyResult<()> { test_json::("../apub/assets/nodebb/objects/group.json")?; test_json::("../apub/assets/nodebb/objects/page.json")?; test_json::("../apub/assets/nodebb/objects/person.json")?; Ok(()) } #[test] fn test_parse_object_wordpress() -> LemmyResult<()> { test_json::("../apub/assets/wordpress/objects/group.json")?; test_json::("../apub/assets/wordpress/objects/page.json")?; test_json::("../apub/assets/wordpress/objects/person.json")?; test_json::("../apub/assets/wordpress/objects/note.json")?; Ok(()) } #[test] fn test_parse_object_mbin() -> LemmyResult<()> { test_json::("../apub/assets/mbin/objects/instance.json")?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/protocol/multi_community.rs ================================================ use crate::{ objects::{ community::ApubCommunity, multi_community::ApubMultiCommunity, multi_community_collection::ApubFeedCollection, person::ApubPerson, }, utils::protocol::Source, }; use activitypub_federation::{ fetch::{collection_id::CollectionId, object_id::ObjectId}, kinds::collection::CollectionType, protocol::{helpers::deserialize_skip_error, public_key::PublicKey, values::MediaTypeHtml}, }; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Feed { pub r#type: FeedType, pub id: ObjectId, pub inbox: Url, pub public_key: PublicKey, pub following: CollectionId, /// username, set at account creation and usually fixed after that pub preferred_username: String, /// title pub name: Option, /// short description pub(crate) description: Option, /// sidebar #[serde(deserialize_with = "deserialize_skip_error", default)] pub source: Option, pub(crate) media_type: Option, // sidebar pub summary: Option, pub attributed_to: ObjectId, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)] pub enum FeedType { #[default] Feed, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct FeedCollection { pub r#type: CollectionType, pub id: CollectionId, pub total_items: i32, pub items: Vec>, } ================================================ FILE: crates/apub/objects/src/protocol/note.rs ================================================ use crate::{ objects::{ PostOrComment, comment::ApubComment, community::ApubCommunity, person::ApubPerson, post::ApubPost, }, protocol::{page::Attachment, tags::ApubTag}, utils::protocol::{InCommunity, LanguageTag, Source}, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::object::NoteType, protocol::{ helpers::{deserialize_one_or_many, deserialize_skip_error}, values::MediaTypeMarkdownOrHtml, }, }; use chrono::{DateTime, Utc}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::{community::Community, post::Post}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ MAX_COMMENT_DEPTH_LIMIT, error::{LemmyErrorType, LemmyResult}, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Note { pub(crate) r#type: NoteType, pub id: ObjectId, pub attributed_to: ObjectId, #[serde(deserialize_with = "deserialize_one_or_many")] pub(crate) to: Vec, #[serde(deserialize_with = "deserialize_one_or_many", default)] pub cc: Vec, pub(crate) content: String, pub(crate) in_reply_to: ObjectId, pub(crate) media_type: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) source: Option, pub(crate) published: Option>, pub(crate) updated: Option>, #[serde(default)] pub tag: Vec, // lemmy extension pub distinguished: Option, pub(crate) language: Option, pub(crate) audience: Option>, #[serde(default)] pub(crate) attachment: Vec, pub(crate) context: Option, } impl Note { pub async fn get_parents( &self, context: &Data, ) -> LemmyResult<(ApubPost, Option)> { // We use recursion here to fetch the entire comment chain up to the top-level parent. This is // necessary because we need to know the post and parent comment in order to insert a new // comment. However it can also lead to too much resource consumption when fetching many // comments recursively. To avoid this we check the request count against max comment depth. // // A separate task is spawned for the recursive call. Otherwise, when the async executor polls // the task this is in, the poll function's call stack would grow with the level of recursion, // so a stack overflow would be possible. // // The stack overflow prevention relies on the total laziness that the async keyword provides // (https://rust-lang.github.io/rfcs/2394-async_await.html#async-functions). This means you need // to be careful if you want to change `Note::get_parents` and `CreateOrUpdateNote::verify` from // `async fn foo(...) -> T` to `fn foo(...) -> impl Future`. Between each level of // recursion, there must be the beginning of at least one `async` block or `async fn`, // otherwise there might be multiple levels of recursion before the first poll. if context.request_count() > MAX_COMMENT_DEPTH_LIMIT.try_into()? { return Err(LemmyErrorType::MaxCommentDepthReached.into()); } let parent = tokio::spawn({ let in_reply_to = self.in_reply_to.clone(); let context = context.clone(); // This is wrapped in an async block only to satisfy the borrow checker. This wrapping is not // needed for the stack overflow prevention. async move { in_reply_to.dereference(&context).await } }) .await??; match parent { PostOrComment::Left(p) => Ok((p.clone(), None)), PostOrComment::Right(c) => { let post_id = c.post_id; let post = Post::read(&mut context.pool(), post_id).await?; Ok((post.into(), Some(c.clone()))) } } } } impl InCommunity for Note { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let (post, _) = self.get_parents(context).await?; let community = Community::read(&mut context.pool(), post.community_id).await?; Ok(community.into()) } } ================================================ FILE: crates/apub/objects/src/protocol/page.rs ================================================ use crate::{ objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, protocol::tags::ApubTag, utils::protocol::{ AttributedTo, ImageObject, InCommunity, LanguageTag, PersonOrGroupType, Source, }, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::{ link::LinkType, object::{DocumentType, ImageType}, }, protocol::{ helpers::{deserialize_one_or_many, deserialize_skip_error}, values::MediaTypeMarkdownOrHtml, }, traits::{Activity, Object}, }; use chrono::{DateTime, Utc}; use itertools::Itertools; use lemmy_api_utils::{context::LemmyContext, utils::proxy_image_link}; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult, UntranslatedError}; use serde::{Deserialize, Deserializer, Serialize, de::Error}; use serde_with::skip_serializing_none; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub enum PageType { Page, Article, Note, Video, Event, } #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Page { #[serde(rename = "type")] pub(crate) kind: PageType, pub id: ObjectId, pub(crate) attributed_to: AttributedTo, #[serde(deserialize_with = "deserialize_one_or_many", default)] pub(crate) to: Vec, // If there is inReplyTo field this is actually a comment and must not be parsed #[serde(deserialize_with = "deserialize_not_present", default)] pub(crate) in_reply_to: Option, pub(crate) name: Option, #[serde(deserialize_with = "deserialize_one_or_many", default)] pub(crate) cc: Vec, pub(crate) content: Option, pub(crate) media_type: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) source: Option, /// most software uses array type for attachment field, so we do the same. nevertheless, we only /// use the first item #[serde(default)] pub(crate) attachment: Vec, pub(crate) image: Option, pub(crate) sensitive: Option, pub(crate) published: Option>, pub(crate) updated: Option>, pub(crate) language: Option, pub(crate) audience: Option>, /// Contains hashtags and post tags. /// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag #[serde(deserialize_with = "deserialize_skip_error", default)] pub tag: Vec, pub(crate) context: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Link { href: Url, media_type: Option, r#type: LinkType, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Image { #[serde(rename = "type")] kind: ImageType, url: Url, /// Used for alt_text name: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Document { #[serde(rename = "type")] kind: DocumentType, url: Url, media_type: Option, /// Used for alt_text name: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum Attachment { Link(Link), Image(Image), Document(Document), } impl Attachment { pub(crate) fn url(self) -> Url { match self { // url as sent by Lemmy (new) Attachment::Link(l) => l.href, // image sent by lotide Attachment::Image(i) => i.url, // sent by mobilizon Attachment::Document(d) => d.url, } } pub(crate) fn alt_text(self) -> Option { match self { Attachment::Image(i) => i.name, Attachment::Document(d) => d.name, _ => None, } } pub(crate) async fn as_markdown(&self, context: &Data) -> LemmyResult { let (url, name, media_type) = match self { Attachment::Image(i) => (i.url.clone(), i.name.clone(), Some(String::from("image"))), Attachment::Document(d) => (d.url.clone(), d.name.clone(), d.media_type.clone()), Attachment::Link(l) => (l.href.clone(), None, l.media_type.clone()), }; let is_image = media_type.is_some_and(|media| media.starts_with("video") || media.starts_with("image")); // Markdown images can't have linebreaks in them, so to prevent creating // broken image embeds, replace them with spaces let name = name.map(|n| n.split_whitespace().collect::>().join(" ")); if is_image { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let url = proxy_image_link(url, &local_site, false, context).await?; Ok(format!("![{}]({url})", name.unwrap_or_default())) } else { Ok(format!("[{url}]({url})")) } } } impl Page { pub fn creator(&self) -> LemmyResult> { match &self.attributed_to { AttributedTo::Lemmy(l) => Ok(l.creator()), AttributedTo::Peertube(p) => p .iter() .find(|a| a.kind == PersonOrGroupType::Person) .map(|a| ObjectId::::from(a.id.clone().into_inner())) .ok_or_else(|| UntranslatedError::PageDoesNotSpecifyCreator.into()), } } } impl Attachment { /// Creates new attachment for a given link and mime type. pub(crate) fn new(url: Url, media_type: Option, alt_text: Option) -> Attachment { let is_image = media_type.clone().unwrap_or_default().starts_with("image"); if is_image { Attachment::Image(Image { kind: Default::default(), url, name: alt_text, }) } else { Attachment::Link(Link { href: url, media_type, r#type: Default::default(), }) } } } // Used for community outbox, so that it can be compatible with Pleroma/Mastodon. #[async_trait::async_trait] impl Activity for Page { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { self.id.inner() } fn actor(&self) -> &Url { debug_assert!(false); self.id.inner() } async fn verify(&self, data: &Data) -> LemmyResult<()> { ApubPost::verify(self, self.id.inner(), data).await } async fn receive(self, data: &Data) -> LemmyResult<()> { ApubPost::from_json(self, data).await?; Ok(()) } } impl InCommunity for Page { async fn community(&self, context: &Data) -> LemmyResult { if let Some(audience) = &self.audience { return audience.dereference(context).await; } let community = match &self.attributed_to { AttributedTo::Lemmy(_) => { let mut iter = self.to.iter().merge(self.cc.iter()); loop { if let Some(cid) = iter.next() { // to and cc fields can also contain this value to indicate a public object. // Skip it to avoid unnecessary http requests. if cid.as_str() == "https://www.w3.org/ns/activitystreams#Public" { continue; } let cid = ObjectId::::from(cid.clone()); if let Ok(c) = cid.dereference(context).await { break c; } } else { return Err(LemmyErrorType::NotFound.into()); } } } AttributedTo::Peertube(p) => { p.iter() .find(|a| a.kind == PersonOrGroupType::Group) .map(|a| ObjectId::::from(a.id.clone().into_inner())) .ok_or(LemmyErrorType::NotFound)? .dereference(context) .await? } }; Ok(community) } } /// Only allows deserialization if the field is missing or null. If it is present, throws an error. fn deserialize_not_present<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let result: Option = Deserialize::deserialize(deserializer)?; match result { None => Ok(None), Some(_) => Err(D::Error::custom("Post must not have inReplyTo property")), } } #[cfg(test)] mod tests { use crate::{protocol::page::Page, utils::test::test_parse_lemmy_item}; #[test] fn test_not_parsing_note_as_page() { assert!(test_parse_lemmy_item::("assets/lemmy/objects/note.json").is_err()); } } ================================================ FILE: crates/apub/objects/src/protocol/person.rs ================================================ use crate::{ objects::person::ApubPerson, utils::protocol::{Endpoints, ImageObject, Source}, }; use activitypub_federation::{ fetch::object_id::ObjectId, protocol::{ helpers::{deserialize_last, deserialize_skip_error}, public_key::PublicKey, }, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] pub enum UserTypes { Person, Service, Organization, } #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Person { #[serde(rename = "type")] pub(crate) kind: UserTypes, pub(crate) id: ObjectId, /// username, set at account creation and usually fixed after that pub(crate) preferred_username: String, pub(crate) inbox: Url, /// mandatory field in activitypub, lemmy currently serves an empty outbox pub(crate) outbox: Url, pub(crate) public_key: PublicKey, /// displayname pub(crate) name: Option, /// bio pub(crate) summary: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) source: Option, /// user avatar #[serde(deserialize_with = "deserialize_last", default)] pub(crate) icon: Option, /// user banner #[serde(deserialize_with = "deserialize_last", default)] pub(crate) image: Option, pub(crate) matrix_user_id: Option, pub(crate) endpoints: Option, pub(crate) published: Option>, pub(crate) updated: Option>, } ================================================ FILE: crates/apub/objects/src/protocol/private_message.rs ================================================ use crate::{ objects::{person::ApubPerson, private_message::ApubPrivateMessage}, utils::protocol::Source, }; use activitypub_federation::{ fetch::object_id::ObjectId, protocol::{ helpers::{deserialize_one, deserialize_skip_error}, values::MediaTypeHtml, }, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PrivateMessage { #[serde(rename = "type")] pub(crate) kind: PrivateMessageType, pub id: ObjectId, pub attributed_to: ObjectId, #[serde(deserialize_with = "deserialize_one")] pub to: [ObjectId; 1], pub(crate) content: String, pub(crate) media_type: Option, #[serde(deserialize_with = "deserialize_skip_error", default)] pub(crate) source: Option, pub(crate) published: Option>, pub(crate) updated: Option>, } #[derive(Clone, Debug, Deserialize, Serialize)] pub enum PrivateMessageType { /// Deprecated, for compatibility with Lemmy 0.19 and earlier /// https://docs.pleroma.social/backend/development/ap_extensions/#chatmessages ChatMessage, Note, } ================================================ FILE: crates/apub/objects/src/protocol/tags.rs ================================================ use crate::objects::person::ApubPerson; use activitypub_federation::{fetch::object_id::ObjectId, kinds::link::MentionType}; use lemmy_db_schema::{ newtypes::CommunityId, source::community_tag::{CommunityTag, CommunityTagInsertForm}, }; use lemmy_db_schema_file::enums::TagColor; use serde::{Deserialize, Serialize}; use serde_json::Value; use url::Url; /// Possible values in the `tag` field of a federated post or comment. Note that we don't support /// hashtags or community tags in comments, but its easier to use the same struct for both /// (anyway unsupported values are ignored). #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum ApubTag { Hashtag(Hashtag), CommunityTag(ApubCommunityTag), Mention(Mention), Unknown(Value), } impl ApubTag { pub(crate) fn community_tag_id(&self) -> Option<&Url> { match self { ApubTag::CommunityTag(t) => Some(&t.id), _ => None, } } pub fn mention_id(&self) -> Option<&ObjectId> { match self { ApubTag::Mention(m) => Some(&m.href), _ => None, } } } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Mention { pub href: ObjectId, pub(crate) name: Option, #[serde(rename = "type")] pub kind: MentionType, } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Hashtag { pub(crate) href: Url, pub(crate) name: String, #[serde(rename = "type")] pub(crate) kind: HashtagType, } #[derive(Clone, Debug, Deserialize, Serialize)] pub enum HashtagType { Hashtag, } /// The [ActivityStreams vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tag) /// defines that any object can have a list of tags associated with it. /// Tags in AS can be of any type, so we define our own types. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)] enum CommunityTagType { #[default] CommunityPostTag, } /// A tag that a community owns, that is added to a post. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ApubCommunityTag { #[serde(rename = "type")] kind: CommunityTagType, pub id: Url, pub name: Option, pub preferred_username: String, pub content: Option, pub color: Option, } impl ApubCommunityTag { pub fn to_json(tag: CommunityTag) -> Self { ApubCommunityTag { kind: Default::default(), id: tag.ap_id.into(), name: tag.display_name, preferred_username: tag.name, content: tag.summary, color: Some(tag.color), } } pub fn to_insert_form(&self, community_id: CommunityId) -> CommunityTagInsertForm { CommunityTagInsertForm { ap_id: self.id.clone().into(), name: self.preferred_username.clone(), display_name: self.name.clone(), summary: self.content.clone(), community_id, deleted: Some(false), color: self.color, } } } ================================================ FILE: crates/apub/objects/src/utils/functions.rs ================================================ use super::protocol::Source; use crate::{ objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson}, protocol::{group::Group, page::Attachment}, }; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::public, protocol::values::MediaTypeMarkdownOrHtml, }; use either::Either; use html2md::parse_html; use lemmy_api_utils::{context::LemmyContext, utils::check_is_mod_or_admin}; use lemmy_db_schema::source::{ community::Community, instance::{Instance, InstanceActions}, local_site::LocalSite, }; use lemmy_db_schema_file::enums::{ActorType, CommunityVisibility}; use lemmy_db_views_community_moderator::CommunityPersonBanView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::connection::DbPool; use lemmy_utils::{ CACHE_DURATION_FEDERATION, CacheLock, error::{LemmyError, LemmyResult, UntranslatedError}, }; use moka::future::Cache; use std::sync::{Arc, LazyLock}; use url::Url; pub fn read_from_string_or_source( content: &str, media_type: &Option, source: &Option, ) -> String { if let Some(s) = source { // markdown sent by lemmy in source field s.content.clone() } else if media_type == &Some(MediaTypeMarkdownOrHtml::Markdown) { // markdown sent by peertube in content field content.to_string() } else { // otherwise, convert content html to markdown parse_html(content) } } pub fn read_from_string_or_source_opt( content: &Option, media_type: &Option, source: &Option, ) -> Option { content .as_ref() .map(|content| read_from_string_or_source(content, media_type, source)) } #[derive(Clone)] pub struct LocalSiteData { local_site: Option, allowed_instances: Vec, blocked_instances: Vec, } pub async fn local_site_data_cached(pool: &mut DbPool<'_>) -> LemmyResult> { // All incoming and outgoing federation actions read the blocklist/allowlist and slur filters // multiple times. This causes a huge number of database reads if we hit the db directly. So we // cache these values for a short time, which will already make a huge difference and ensures that // changes take effect quickly. static CACHE: CacheLock> = LazyLock::new(|| { Cache::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_FEDERATION) .build() }); Ok( Box::pin(CACHE .try_get_with((), async { let (local_site, allowed_instances, blocked_instances) = lemmy_diesel_utils::try_join_with_pool!(pool => ( // LocalSite may be missing |pool| async { Ok(SiteView::read_local(pool).await.ok().map(|s| s.local_site)) }, Instance::allowlist, Instance::blocklist ))?; Ok::<_, LemmyError>(Arc::new(LocalSiteData { local_site, allowed_instances, blocked_instances, })) })) .await.map_err(|e| anyhow::anyhow!("err getting activity: {e:?}"))? ) } pub async fn check_apub_id_valid_with_strictness( apub_id: &Url, is_strict: bool, context: &LemmyContext, ) -> LemmyResult<()> { let domain = apub_id .domain() .ok_or(UntranslatedError::UrlWithoutDomain)? .to_string(); let local_instance = context.settings().get_hostname_without_port()?; if domain == local_instance { return Ok(()); } let local_site_data = local_site_data_cached(&mut context.pool()).await?; check_apub_id_valid(apub_id, &local_site_data)?; // Only check allowlist if this is a community, and there are instances in the allowlist if is_strict && !local_site_data.allowed_instances.is_empty() { // need to allow this explicitly because apub receive might contain objects from our local // instance. let mut allowed_and_local = local_site_data .allowed_instances .iter() .map(|i| i.domain.clone()) .collect::>(); let local_instance = context.settings().get_hostname_without_port()?; allowed_and_local.push(local_instance); let domain = apub_id .domain() .ok_or(UntranslatedError::UrlWithoutDomain)? .to_string(); if !allowed_and_local.contains(&domain) { return Err(UntranslatedError::FederationDisabledByStrictAllowList.into()); } } Ok(()) } /// Checks if the ID is allowed for sending or receiving. /// /// In particular, it checks for: /// - federation being enabled (if its disabled, only local URLs are allowed) /// - the correct scheme (either http or https) /// - URL being in the allowlist (if it is active) /// - URL not being in the blocklist (if it is active) pub fn check_apub_id_valid(apub_id: &Url, local_site_data: &LocalSiteData) -> LemmyResult<()> { let domain = apub_id .domain() .ok_or(UntranslatedError::UrlWithoutDomain)? .to_string(); if !local_site_data .local_site .as_ref() .map(|l| l.federation_enabled) .unwrap_or(true) { return Err(UntranslatedError::FederationDisabled.into()); } if local_site_data .blocked_instances .iter() .any(|i| domain.to_lowercase().eq(&i.domain.to_lowercase())) { return Err(UntranslatedError::DomainBlocked(domain.clone()).into()); } // Only check this if there are instances in the allowlist if !local_site_data.allowed_instances.is_empty() && !local_site_data .allowed_instances .iter() .any(|i| domain.to_lowercase().eq(&i.domain.to_lowercase())) { return Err(UntranslatedError::DomainNotInAllowList(domain).into()); } Ok(()) } pub trait GetActorType { fn actor_type(&self) -> ActorType; } impl GetActorType for either::Either { fn actor_type(&self) -> ActorType { match self { Either::Right(r) => r.actor_type(), Either::Left(l) => l.actor_type(), } } } /// Marks object as public only if the community is public pub fn generate_to(community: &Community) -> LemmyResult> { let ap_id = community.ap_id.clone().into(); if community.visibility == CommunityVisibility::Public { Ok(vec![ap_id, public()]) } else { Ok(vec![ ap_id.clone(), Url::parse(&format!("{}/followers", ap_id))?, ]) } } /// Fetches the person and community to verify their type, then checks if person is banned from site /// or community. pub async fn verify_person_in_community( person_id: &ObjectId, community: &ApubCommunity, context: &Data, ) -> LemmyResult<()> { let person = person_id.dereference(context).await?; InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?; CommunityPersonBanView::check(&mut context.pool(), person.id, community.id).await } /// Fetches the person and community or site to verify their type, then checks if person is banned /// from local site or community. pub async fn verify_person_in_site_or_community( person_id: &ObjectId, site_or_community: &Either, context: &Data, ) -> LemmyResult<()> { let person = person_id.dereference(context).await?; InstanceActions::check_ban(&mut context.pool(), person.id, person.instance_id).await?; if let Either::Right(community) = site_or_community { let person_id = person.id; let community_id = community.id; CommunityPersonBanView::check(&mut context.pool(), person_id, community_id).await?; } Ok(()) } pub fn verify_is_public(to: &[Url], cc: &[Url]) -> LemmyResult<()> { if ![to, cc].iter().any(|set| set.contains(&public())) { Err(UntranslatedError::ObjectIsNotPublic.into()) } else { Ok(()) } } /// Returns an error if object visibility doesnt match community visibility /// (ie content in private community must also be private). pub fn verify_visibility(to: &[Url], cc: &[Url], community: &ApubCommunity) -> LemmyResult<()> { use CommunityVisibility::*; let object_is_public = [to, cc].iter().any(|set| set.contains(&public())); match community.visibility { Public | Unlisted if !object_is_public => Err(UntranslatedError::ObjectIsNotPublic.into()), Private if object_is_public => Err(UntranslatedError::ObjectIsNotPrivate.into()), _ => Ok(()), } } pub async fn append_attachments_to_comment( content: String, attachments: &[Attachment], context: &Data, ) -> LemmyResult { let mut content = content; // Don't modify comments with no attachments if !attachments.is_empty() { content += "\n"; for attachment in attachments { content = content + "\n" + &attachment.as_markdown(context).await?; } } Ok(content) } pub fn community_visibility(group: &Group) -> CommunityVisibility { if group.manually_approves_followers.unwrap_or_default() { CommunityVisibility::Private } else if !group.discoverable.unwrap_or(true) { CommunityVisibility::Unlisted } else { CommunityVisibility::Public } } /// Format context url for a post or comment. Returns plain string for simplicity. pub fn context_url(id: &Url) -> String { format!("{id}/context") } /// Verify that mod action in community was performed by a moderator. Also checks that the moderator /// doesn't have a community ban. /// /// Do not call together with `verify_person_in_community()`. /// Moderators with site ban are allowed, see https://github.com/LemmyNet/lemmy/issues/4409 /// /// * `mod_id` - Activitypub ID of the mod or admin who performed the action /// * `object_id` - Activitypub ID of the actor or object that is being moderated /// * `community` - The community inside which moderation is happening pub async fn verify_mod_action( mod_id: &ObjectId, community: &Community, context: &Data, ) -> LemmyResult<()> { // mod action comes from the same instance as the community, so it was presumably done // by an instance admin. // TODO: federate instance admin status and check it here if mod_id.inner().domain() == community.ap_id.domain() { return Ok(()); } let mod_ = mod_id.dereference(context).await?; check_is_mod_or_admin(&mut context.pool(), mod_.id, community.id).await?; CommunityPersonBanView::check(&mut context.pool(), mod_.id, community.id).await?; Ok(()) } ================================================ FILE: crates/apub/objects/src/utils/markdown_links.rs ================================================ use crate::objects::SearchableObjects; use activitypub_federation::{config::Data, fetch::object_id::ObjectId}; use either::Either::*; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::traits::ApubActor; use lemmy_utils::utils::markdown::image_links::{markdown_find_links, markdown_handle_title}; use url::Url; pub async fn markdown_rewrite_remote_links_opt( src: Option, context: &Data, ) -> Option { match src { Some(t) => Some(markdown_rewrite_remote_links(t, context).await), None => None, } } /// Goes through all remote markdown links and attempts to resolve them as Activitypub objects. /// If successful, the link is rewritten to a local link, so it can be viewed without leaving the /// local instance. /// /// As it relies on ObjectId::dereference, it can only be used for incoming federated objects, not /// for the API. pub async fn markdown_rewrite_remote_links( mut src: String, context: &Data, ) -> String { let links_offsets = markdown_find_links(&src); // Go through the collected links in reverse order for (start, end) in links_offsets.into_iter().rev() { let (url, extra) = markdown_handle_title(&src, start, end); if let Some(local_url) = to_local_url(url, context).await { let mut local_url = local_url.to_string(); // restore title if let Some(extra) = extra { local_url.push(' '); local_url.push_str(extra); } src.replace_range(start..end, local_url.as_str()); } } src } pub(crate) async fn to_local_url(url: &str, context: &Data) -> Option { let local_domain = &context.settings().get_protocol_and_hostname(); let object_id = ObjectId::::parse(url).ok()?; let object_domain = object_id.inner().domain(); if object_domain == Some(local_domain) { return None; } let dereferenced = object_id.dereference_local(context).await.ok()?; match dereferenced { Left(Left(Left(post))) => post.local_url(context.settings()), Left(Left(Right(comment))) => comment.local_url(context.settings()), Left(Right(Left(user))) => user.actor_url(context.settings()), Left(Right(Right(community))) => community.actor_url(context.settings()), Right(multi) => multi.actor_url(context.settings()), } .ok() } #[cfg(test)] mod tests { use super::*; use lemmy_db_schema::{ source::{ community::{Community, CommunityInsertForm}, instance::Instance, post::{Post, PostInsertForm}, }, test_data::TestData, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[serial] #[tokio::test] async fn test_markdown_rewrite_remote_links() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let data = TestData::create(&mut context.pool()).await?; let community = Community::create( &mut context.pool(), &CommunityInsertForm::new( data.instance.id, "my_community".to_string(), "My Community".to_string(), "pubkey".to_string(), ), ) .await?; let user = LocalUserView::create_test_user(&mut context.pool(), "garda", "garda bio", false).await?; // insert a remote post which is already fetched let post_form = PostInsertForm { ap_id: Some(Url::parse("https://example.com/post/123")?.into()), ..PostInsertForm::new("My post".to_string(), user.person.id, community.id) }; let post = Post::create(&mut context.pool(), &post_form).await?; let markdown_local_post_url = format!("[link](https://lemmy-alpha/post/{})", post.id); let tests: Vec<_> = vec![ ( "rewrite remote post link", format!("[link]({})", post.ap_id), markdown_local_post_url.as_ref(), ), ( "rewrite community link", format!("[link]({})", community.ap_id), "[link](https://lemmy-alpha/c/my_community@changeme.invalid)", ), ( "dont rewrite local post link", "[link](https://lemmy-alpha/post/2)".to_string(), "[link](https://lemmy-alpha/post/2)", ), ( "dont rewrite local community link", "[link](https://lemmy-alpha/c/test)".to_string(), "[link](https://lemmy-alpha/c/test)", ), ( "dont rewrite non-fediverse link", "[link](https://example.com/)".to_string(), "[link](https://example.com/)", ), ( "dont rewrite invalid url", "[link](example-com)".to_string(), "[link](example-com)", ), ]; let context = LemmyContext::init_test_context().await; for (msg, input, expected) in &tests { let result = markdown_rewrite_remote_links(input.clone(), &context).await; assert_eq!( &result, expected, "Testing {}, with original input '{}'", msg, input ); } data.delete(&mut context.pool()).await?; Instance::delete_all(&mut context.pool()).await?; Ok(()) } } ================================================ FILE: crates/apub/objects/src/utils/mentions.rs ================================================ use crate::{ objects::person::ApubPerson, protocol::tags::{ApubTag, Mention}, }; use activitypub_federation::{ config::Data, fetch::webfinger::webfinger_resolve_actor, kinds::link::MentionType, traits::Object, }; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::{comment::Comment, person::Person, post::Post}; use lemmy_diesel_utils::{connection::DbPool, traits::Crud}; use lemmy_utils::{ error::{LemmyResult, UntranslatedError}, utils::mention::scrape_text_for_mentions, }; use url::Url; pub(crate) struct MentionsAndAddresses { pub ccs: Vec, pub mentions: Vec, } /// This takes a markdown text, and builds a list of to_addresses, inboxes, /// and mention tags, so they know where to be sent to. /// Addresses are the persons / addresses that go in the cc field. pub(crate) async fn collect_non_local_mentions( content: Option<&str>, parent_creator: Option, context: &Data, ) -> LemmyResult { let mut addressed_ccs: Vec = vec![]; let mut mentions = vec![]; if let Some(parent_creator) = parent_creator { addressed_ccs.push(parent_creator.id().clone()); mentions.push(Mention { href: parent_creator.id().clone().into(), name: Some(format!( "@{}@{}", &parent_creator.name, &parent_creator .id() .domain() .ok_or(UntranslatedError::UrlWithoutDomain)? )), kind: MentionType::Mention, }); } // Get the person IDs for any mentions let scraped = content .map(scrape_text_for_mentions) .into_iter() .flatten() // Filter only the non-local ones .filter(|m| !m.is_local(&context.settings().hostname)); for mention in scraped { let identifier = format!("{}@{}", mention.name, mention.domain); let person = webfinger_resolve_actor::(&identifier, context).await; if let Ok(person) = person { addressed_ccs.push(person.ap_id.to_string().parse()?); let mention_tag = Mention { href: person.id().clone().into(), name: Some(mention.full_name()), kind: MentionType::Mention, }; mentions.push(mention_tag); } } Ok(MentionsAndAddresses { ccs: addressed_ccs, mentions: mentions.into_iter().map(ApubTag::Mention).collect(), }) } /// Returns the apub ID of the person this comment is responding to. Meaning, in case this is a /// top-level comment, the creator of the post, otherwise the creator of the parent comment. pub(crate) async fn get_comment_parent_creator( pool: &mut DbPool<'_>, comment: &Comment, ) -> LemmyResult { let parent_creator_id = if let Some(parent_comment_id) = comment.parent_comment_id() { let parent_comment = Comment::read(pool, parent_comment_id).await?; parent_comment.creator_id } else { let parent_post_id = comment.post_id; let parent_post = Post::read(pool, parent_post_id).await?; parent_post.creator_id }; Ok(Person::read(pool, parent_creator_id).await?.into()) } ================================================ FILE: crates/apub/objects/src/utils/mod.rs ================================================ pub mod functions; pub mod markdown_links; pub mod mentions; pub mod protocol; pub mod test; ================================================ FILE: crates/apub/objects/src/utils/protocol.rs ================================================ use crate::objects::{UserOrCommunity, community::ApubCommunity, person::ApubPerson}; use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, kinds::object::ImageType, protocol::{tombstone::Tombstone, values::MediaTypeMarkdown}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ impls::actor_language::UNDETERMINED_ID, newtypes::LanguageId, source::language::Language, }; use lemmy_diesel_utils::{connection::DbPool, dburl::DbUrl}; use lemmy_utils::error::LemmyResult; use serde::{Deserialize, Serialize}; use std::{future::Future, ops::Deref}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Source { pub(crate) content: String, pub(crate) media_type: MediaTypeMarkdown, } impl Source { pub(crate) fn new(content: String) -> Self { Source { content, media_type: MediaTypeMarkdown::Markdown, } } } pub trait InCommunity { fn community( &self, context: &Data, ) -> impl Future> + Send; } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ImageObject { #[serde(rename = "type")] kind: ImageType, pub url: Url, } impl ImageObject { pub(crate) fn new(url: DbUrl) -> Self { ImageObject { kind: ImageType::Image, url: url.into(), } } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(untagged)] pub enum AttributedTo { Lemmy(PersonOrGroupModerators), Peertube(Vec), } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum PersonOrGroupType { Person, Group, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AttributedToPeertube { #[serde(rename = "type")] pub kind: PersonOrGroupType, pub id: ObjectId, } impl AttributedTo { pub fn url(self) -> Option { match self { AttributedTo::Lemmy(l) => Some(l.moderators().into()), AttributedTo::Peertube(_) => None, } } } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct PersonOrGroupModerators(Url); impl Deref for PersonOrGroupModerators { type Target = Url; fn deref(&self) -> &Self::Target { &self.0 } } impl From for PersonOrGroupModerators { fn from(value: DbUrl) -> Self { PersonOrGroupModerators(value.into()) } } impl PersonOrGroupModerators { pub(crate) fn creator(&self) -> ObjectId { self.deref().clone().into() } pub fn moderators(&self) -> Url { self.deref().clone() } } /// As specified in https://schema.org/Language #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct LanguageTag { pub(crate) identifier: String, pub(crate) name: String, } impl Default for LanguageTag { fn default() -> Self { LanguageTag { identifier: "und".to_string(), name: "Undetermined".to_string(), } } } impl LanguageTag { pub(crate) async fn new_single( lang: LanguageId, pool: &mut DbPool<'_>, ) -> LemmyResult { let lang = Language::read_from_id(pool, lang).await?; // undetermined if lang.id == UNDETERMINED_ID { Ok(LanguageTag::default()) } else { Ok(LanguageTag { identifier: lang.code, name: lang.name, }) } } pub(crate) async fn new_multiple( lang_ids: Vec, pool: &mut DbPool<'_>, ) -> LemmyResult> { let mut langs = Vec::::new(); for l in lang_ids { langs.push(Language::read_from_id(pool, l).await?); } let langs = langs .into_iter() .map(|l| LanguageTag { identifier: l.code, name: l.name, }) .collect(); Ok(langs) } pub(crate) async fn to_language_id_single( lang: Self, pool: &mut DbPool<'_>, ) -> LemmyResult { Language::read_id_from_code(pool, &lang.identifier).await } pub(crate) async fn to_language_id_multiple( langs: Vec, pool: &mut DbPool<'_>, ) -> LemmyResult> { let mut language_ids = Vec::new(); for l in langs { let id = l.identifier; language_ids.push(Language::read_id_from_code(pool, &id).await?); } Ok(language_ids.into_iter().collect()) } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Endpoints { pub shared_inbox: Url, } pub trait Id { fn id(&self) -> &Url; } impl Id for Tombstone { fn id(&self) -> &Url { &self.id } } ================================================ FILE: crates/apub/objects/src/utils/test.rs ================================================ use crate::{ objects::{community::ApubCommunity, instance::ApubSite, person::ApubPerson}, protocol::{group::Group, instance::Instance}, }; use activitypub_federation::{config::Data, protocol::context::WithContext, traits::Object}; use assert_json_diff::assert_json_include; use lemmy_api_utils::context::LemmyContext; use lemmy_utils::error::LemmyResult; use serde::{Serialize, de::DeserializeOwned}; use std::{collections::HashMap, fs::File, io::BufReader}; use url::Url; pub fn file_to_json_object(path: &str) -> LemmyResult { let file = File::open(path)?; let reader = BufReader::new(file); Ok(serde_json::from_reader(reader)?) } pub fn test_json(path: &str) -> LemmyResult> { file_to_json_object::>(path) } /// Check that json deserialize -> serialize -> deserialize gives identical file as initial one. /// Ensures that there are no breaking changes in sent data. pub fn test_parse_lemmy_item( path: &str, ) -> LemmyResult { // parse file as T let parsed = file_to_json_object::(path)?; // parse file into hashmap, which ensures that every field is included let raw = file_to_json_object::>(path)?; // assert that all fields are identical, otherwise print diff assert_json_include!(actual: &parsed, expected: raw); Ok(parsed) } pub(crate) async fn parse_lemmy_instance(context: &Data) -> LemmyResult { let json: Instance = file_to_json_object("../apub/assets/lemmy/objects/instance.json")?; let id = Url::parse("https://enterprise.lemmy.ml/")?; ApubSite::verify(&json, &id, context).await?; let site = ApubSite::from_json(json, context).await?; assert_eq!(context.request_count(), 0); Ok(site) } pub async fn parse_lemmy_person( context: &Data, ) -> LemmyResult<(ApubPerson, ApubSite)> { let site = parse_lemmy_instance(context).await?; let json = file_to_json_object("../apub/assets/lemmy/objects/person.json")?; let url = Url::parse("https://enterprise.lemmy.ml/u/picard")?; ApubPerson::verify(&json, &url, context).await?; let person = ApubPerson::from_json(json, context).await?; assert_eq!(context.request_count(), 0); Ok((person, site)) } pub async fn parse_lemmy_community(context: &Data) -> LemmyResult { // use separate counter so this doesn't affect tests let context2 = context.clone(); let mut json: Group = file_to_json_object("../apub/assets/lemmy/objects/group.json")?; // change these links so they dont fetch over the network json.attributed_to = None; json.outbox = Url::parse("https://enterprise.lemmy.ml/c/tenforward/not_outbox")?; json.followers = Some(Url::parse( "https://enterprise.lemmy.ml/c/tenforward/not_followers", )?); let url = Url::parse("https://enterprise.lemmy.ml/c/tenforward")?; ApubCommunity::verify(&json, &url, &context2).await?; let community = ApubCommunity::from_json(json, &context2).await?; Ok(community) } ================================================ FILE: crates/apub/send/Cargo.toml ================================================ [package] name = "lemmy_apub_send" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lints] workspace = true [features] full = [ "lemmy_db_views_community_follower/full", "lemmy_db_schema/full", "lemmy_api_utils/full", ] [dependencies] lemmy_db_views_community_follower = { workspace = true } lemmy_api_utils.workspace = true lemmy_apub_objects.workspace = true lemmy_db_schema = { workspace = true } lemmy_utils.workspace = true lemmy_db_schema_file = { workspace = true } either.workspace = true activitypub_federation.workspace = true anyhow.workspace = true async-trait.workspace = true futures.workspace = true chrono.workspace = true diesel = { workspace = true } diesel-async = { workspace = true } reqwest.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["full"] } serde.workspace = true tracing.workspace = true moka.workspace = true tokio-util = "0.7.18" lemmy_diesel_utils = { workspace = true } [dev-dependencies] serial_test = { workspace = true } url.workspace = true actix-web.workspace = true tracing-test = "0.2.6" uuid.workspace = true test-context = "0.5.5" mockall = "0.14.0" [lib] doctest = false ================================================ FILE: crates/apub/send/src/inboxes.rs ================================================ use crate::util::LEMMY_TEST_FAST_FEDERATION; use chrono::{DateTime, TimeZone, Utc}; use lemmy_db_schema::{ newtypes::CommunityId, source::{activity::SentActivity, site::Site}, }; use lemmy_db_schema_file::InstanceId; use lemmy_db_views_community_follower::CommunityFollowerView; use lemmy_diesel_utils::{ connection::{ActualDbPool, DbPool}, dburl::DbUrl, }; use lemmy_utils::error::LemmyResult; use reqwest::Url; use std::{ collections::{HashMap, HashSet}, sync::LazyLock, }; /// interval with which new additions to community_followers are queried. /// /// The first time some user on an instance follows a specific remote community (or, more precisely: /// the first time a (followed_community_id, follower_inbox_url) tuple appears), this delay limits /// the maximum time until the follow actually results in activities from that community id being /// sent to that inbox url. This delay currently needs to not be too small because the DB load is /// currently fairly high because of the current structure of storing inboxes for every person, not /// having a separate list of shared_inboxes, and the architecture of having every instance queue be /// fully separate. (see https://github.com/LemmyNet/lemmy/issues/3958) #[expect(clippy::expect_used)] static FOLLOW_ADDITIONS_RECHECK_DELAY: LazyLock = LazyLock::new(|| { if *LEMMY_TEST_FAST_FEDERATION { chrono::TimeDelta::try_seconds(1).expect("TimeDelta out of bounds") } else { chrono::TimeDelta::try_minutes(2).expect("TimeDelta out of bounds") } }); /// The same as FOLLOW_ADDITIONS_RECHECK_DELAY, but triggering when the last person on an instance /// unfollows a specific remote community. This is expected to happen pretty rarely and updating it /// in a timely manner is not too important. #[expect(clippy::expect_used)] static FOLLOW_REMOVALS_RECHECK_DELAY: LazyLock = LazyLock::new(|| chrono::TimeDelta::try_hours(1).expect("TimeDelta out of bounds")); pub trait DataSource: Send + Sync { async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> LemmyResult; async fn get_instance_followed_community_inboxes( &self, instance_id: InstanceId, last_fetch: DateTime, ) -> LemmyResult>; } pub struct DbDataSource { pool: ActualDbPool, } impl DbDataSource { pub fn new(pool: ActualDbPool) -> Self { Self { pool } } } impl DataSource for DbDataSource { async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> LemmyResult { Site::read_from_instance_id(&mut DbPool::Pool(&self.pool), instance_id).await } async fn get_instance_followed_community_inboxes( &self, instance_id: InstanceId, last_fetch: DateTime, ) -> LemmyResult> { CommunityFollowerView::get_instance_followed_community_inboxes( &mut DbPool::Pool(&self.pool), instance_id, last_fetch, ) .await } } pub(crate) struct CommunityInboxCollector { // load site lazily because if an instance is first seen due to being on allowlist, // the corresponding row in `site` may not exist yet since that is only added once // `fetch_instance_actor_for_object` is called. // (this should be unlikely to be relevant outside of the federation tests) site_loaded: bool, site: Option, followed_communities: HashMap>, last_full_communities_fetch: DateTime, last_incremental_communities_fetch: DateTime, instance_id: InstanceId, domain: String, pub(crate) data_source: T, } pub type RealCommunityInboxCollector = CommunityInboxCollector; impl CommunityInboxCollector { pub fn new_real( pool: ActualDbPool, instance_id: InstanceId, domain: String, ) -> RealCommunityInboxCollector { CommunityInboxCollector::new(DbDataSource::new(pool), instance_id, domain) } pub fn new( data_source: T, instance_id: InstanceId, domain: String, ) -> CommunityInboxCollector { CommunityInboxCollector { data_source, site_loaded: false, site: None, followed_communities: HashMap::new(), last_full_communities_fetch: Utc.timestamp_nanos(0), last_incremental_communities_fetch: Utc.timestamp_nanos(0), instance_id, domain, } } /// get inbox urls of sending the given activity to the given instance /// most often this will return 0 values (if instance doesn't care about the activity) /// or 1 value (the shared inbox) /// > 1 values only happens for non-lemmy software pub async fn get_inbox_urls(&mut self, activity: &SentActivity) -> LemmyResult> { let mut inbox_urls: HashSet = HashSet::new(); if activity.send_all_instances { if !self.site_loaded { self.site = self .data_source .read_site_from_instance_id(self.instance_id) .await .ok(); self.site_loaded = true; } if let Some(site) = &self.site { // Nutomic: Most non-lemmy software wont have a site row. That means it cant handle these // activities. So handling it like this is fine. inbox_urls.insert(site.inbox_url.inner().clone()); } } if let Some(t) = &activity.send_community_followers_of && let Some(urls) = self.followed_communities.get(t) { inbox_urls.extend(urls.iter().cloned()); } inbox_urls.extend( activity .send_inboxes .iter() .filter_map(std::option::Option::as_ref) // a similar filter also happens within the activitypub-federation crate. but that filter // happens much later - by doing it here, we can ensure that in the happy case, this // function returns 0 urls which means the system doesn't have to create a tokio // task for sending at all (since that task has a fair amount of overhead) .filter(|&u| u.domain() == Some(&self.domain)) .map(|u| u.inner().clone()), ); tracing::trace!( "get_inbox_urls: {:?}, send_inboxes: {:?}", inbox_urls, activity.send_inboxes ); Ok(inbox_urls.into_iter().collect()) } pub async fn update_communities(&mut self) -> LemmyResult<()> { if (Utc::now() - self.last_full_communities_fetch) > *FOLLOW_REMOVALS_RECHECK_DELAY { tracing::debug!("{}: fetching full list of communities", self.domain); // process removals every hour (self.followed_communities, self.last_full_communities_fetch) = self .get_communities(self.instance_id, Utc.timestamp_nanos(0)) .await?; self.last_incremental_communities_fetch = self.last_full_communities_fetch; } if (Utc::now() - self.last_incremental_communities_fetch) > *FOLLOW_ADDITIONS_RECHECK_DELAY { // process additions every minute let (news, time) = self .get_communities(self.instance_id, self.last_incremental_communities_fetch) .await?; if !news.is_empty() { tracing::debug!( "{}: fetched {} incremental new followed communities", self.domain, news.len() ); } self.followed_communities.extend(news); self.last_incremental_communities_fetch = time; } Ok(()) } /// get a list of local communities with the remote inboxes on the given instance that cares about /// them async fn get_communities( &mut self, instance_id: InstanceId, last_fetch: DateTime, ) -> LemmyResult<(HashMap>, DateTime)> { // update to time before fetch to ensure overlap. subtract some time to ensure overlap even if // published date is not exact let new_last_fetch = Utc::now() - *FOLLOW_ADDITIONS_RECHECK_DELAY / 2; let inboxes = self .data_source .get_instance_followed_community_inboxes(instance_id, last_fetch) .await?; let map: HashMap> = inboxes.into_iter().fold(HashMap::new(), |mut map, (c, u)| { map.entry(c).or_default().insert(u.into()); map }); Ok((map, new_last_fetch)) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use lemmy_db_schema::{ newtypes::{ActivityId, CommunityId, SiteId}, source::activity::SentActivity, }; use lemmy_db_schema_file::{InstanceId, enums::ActorType}; use lemmy_utils::error::LemmyResult; use mockall::mock; use serde_json::json; mock! { DataSource {} impl DataSource for DataSource { async fn read_site_from_instance_id(&self, instance_id: InstanceId) -> LemmyResult; async fn get_instance_followed_community_inboxes( &self, instance_id: InstanceId, last_fetch: DateTime, ) -> LemmyResult>; } } fn setup_collector() -> CommunityInboxCollector { let mock_data_source = MockDataSource::new(); let instance_id = InstanceId(1); let domain = "example.com".to_string(); CommunityInboxCollector::new(mock_data_source, instance_id, domain) } #[tokio::test] async fn test_get_inbox_urls_empty() -> LemmyResult<()> { let mut collector = setup_collector(); let activity = SentActivity { id: ActivityId(1), ap_id: Url::parse("https://example.com/activities/1")?.into(), data: json!({}), sensitive: false, published_at: Utc::now(), send_inboxes: vec![], send_community_followers_of: None, send_all_instances: false, actor_type: ActorType::Person, actor_apub_id: None, }; let result = collector.get_inbox_urls(&activity).await?; assert!(result.is_empty()); Ok(()) } #[tokio::test] async fn test_get_inbox_urls_send_all_instances() -> LemmyResult<()> { let mut collector = setup_collector(); let site_inbox = Url::parse("https://example.com/inbox")?; let site = Site { id: SiteId(1), name: "Test Site".to_string(), sidebar: None, published_at: Utc::now(), updated_at: None, icon: None, banner: None, summary: None, ap_id: Url::parse("https://example.com/site")?.into(), last_refreshed_at: Utc::now(), inbox_url: site_inbox.clone().into(), private_key: None, public_key: "test_key".to_string(), instance_id: InstanceId(1), content_warning: None, }; collector .data_source .expect_read_site_from_instance_id() .return_once(move |_| Ok(site)); let activity = SentActivity { id: ActivityId(1), ap_id: Url::parse("https://example.com/activities/1")?.into(), data: json!({}), sensitive: false, published_at: Utc::now(), send_inboxes: vec![], send_community_followers_of: None, send_all_instances: true, actor_type: ActorType::Person, actor_apub_id: None, }; let result = collector.get_inbox_urls(&activity).await?; assert_eq!(result.len(), 1); assert_eq!(result[0], site_inbox); Ok(()) } #[tokio::test] async fn test_get_inbox_urls_community_followers() -> LemmyResult<()> { let mut collector = setup_collector(); let community_id = CommunityId(1); let url1 = "https://follower1.example.com/inbox"; let url2 = "https://follower2.example.com/inbox"; collector .data_source .expect_get_instance_followed_community_inboxes() .return_once(move |_, _| { Ok(vec![ (community_id, Url::parse(url1)?.into()), (community_id, Url::parse(url2)?.into()), ]) }); collector.update_communities().await?; let activity = SentActivity { id: ActivityId(1), ap_id: Url::parse("https://example.com/activities/1")?.into(), data: json!({}), sensitive: false, published_at: Utc::now(), send_inboxes: vec![], send_community_followers_of: Some(community_id), send_all_instances: false, actor_type: ActorType::Person, actor_apub_id: None, }; let result = collector.get_inbox_urls(&activity).await?; assert_eq!(result.len(), 2); assert!(result.contains(&Url::parse(url1)?)); assert!(result.contains(&Url::parse(url2)?)); Ok(()) } #[tokio::test] async fn test_get_inbox_urls_send_inboxes() -> LemmyResult<()> { let mut collector = setup_collector(); collector.domain = "example.com".to_string(); let inbox_user_1 = Url::parse("https://example.com/user1/inbox")?; let inbox_user_2 = Url::parse("https://example.com/user2/inbox")?; let other_domain_inbox = Url::parse("https://other-domain.com/user3/inbox")?; let activity = SentActivity { id: ActivityId(1), ap_id: Url::parse("https://example.com/activities/1")?.into(), data: json!({}), sensitive: false, published_at: Utc::now(), send_inboxes: vec![ Some(inbox_user_1.clone().into()), Some(inbox_user_2.clone().into()), Some(other_domain_inbox.clone().into()), ], send_community_followers_of: None, send_all_instances: false, actor_type: ActorType::Person, actor_apub_id: None, }; let result = collector.get_inbox_urls(&activity).await?; assert_eq!(result.len(), 2); assert!(result.contains(&inbox_user_1)); assert!(result.contains(&inbox_user_2)); assert!(!result.contains(&other_domain_inbox)); Ok(()) } #[tokio::test] async fn test_get_inbox_urls_combined() -> LemmyResult<()> { let mut collector = setup_collector(); collector.domain = "example.com".to_string(); let community_id = CommunityId(1); let site_inbox = Url::parse("https://example.com/site_inbox")?; let site = Site { id: SiteId(1), name: "Test Site".to_string(), sidebar: None, published_at: Utc::now(), updated_at: None, icon: None, banner: None, summary: None, ap_id: Url::parse("https://example.com/site")?.into(), last_refreshed_at: Utc::now(), inbox_url: site_inbox.clone().into(), private_key: None, public_key: "test_key".to_string(), instance_id: InstanceId(1), content_warning: None, }; collector .data_source .expect_read_site_from_instance_id() .return_once(move |_| Ok(site)); let subdomain_inbox = "https://follower.example.com/inbox"; collector .data_source .expect_get_instance_followed_community_inboxes() .return_once(move |_, _| Ok(vec![(community_id, Url::parse(subdomain_inbox)?.into())])); collector.update_communities().await?; let user1_inbox = Url::parse("https://example.com/user1/inbox")?; let user2_inbox = Url::parse("https://other-domain.com/user2/inbox")?; let activity = SentActivity { id: ActivityId(1), ap_id: Url::parse("https://example.com/activities/1")?.into(), data: json!({}), sensitive: false, published_at: Utc::now(), send_inboxes: vec![ Some(user1_inbox.clone().into()), Some(user2_inbox.clone().into()), ], send_community_followers_of: Some(community_id), send_all_instances: true, actor_type: ActorType::Person, actor_apub_id: None, }; let result = collector.get_inbox_urls(&activity).await?; assert_eq!(result.len(), 3); assert!(result.contains(&site_inbox)); assert!(result.contains(&Url::parse(subdomain_inbox)?)); assert!(result.contains(&user1_inbox)); assert!(!result.contains(&user2_inbox)); Ok(()) } #[expect(clippy::expect_used)] #[tokio::test] async fn test_update_communities() -> LemmyResult<()> { let mut collector = setup_collector(); let community_id1 = CommunityId(1); let community_id2 = CommunityId(2); let community_id3 = CommunityId(3); let user1_inbox_str = "https://follower1.example.com/inbox"; let user1_inbox = Url::parse(user1_inbox_str)?; let user2_inbox_str = "https://follower2.example.com/inbox"; let user2_inbox = Url::parse(user2_inbox_str)?; let user3_inbox_str = "https://follower3.example.com/inbox"; let user3_inbox = Url::parse(user3_inbox_str)?; collector .data_source .expect_get_instance_followed_community_inboxes() .times(2) .returning(move |_, last_fetch| { if last_fetch == Utc.timestamp_nanos(0) { Ok(vec![ (community_id1, Url::parse(user1_inbox_str)?.into()), (community_id2, Url::parse(user2_inbox_str)?.into()), ]) } else { Ok(vec![(community_id3, Url::parse(user3_inbox_str)?.into())]) } }); // First update collector.update_communities().await?; assert_eq!(collector.followed_communities.len(), 2); assert!(collector.followed_communities[&community_id1].contains(&user1_inbox)); assert!(collector.followed_communities[&community_id2].contains(&user2_inbox)); // Simulate time passing collector.last_full_communities_fetch = Utc::now() - chrono::TimeDelta::try_minutes(3).expect("TimeDelta out of bounds"); collector.last_incremental_communities_fetch = Utc::now() - chrono::TimeDelta::try_minutes(3).expect("TimeDelta out of bounds"); // Second update (incremental) collector.update_communities().await?; assert_eq!(collector.followed_communities.len(), 3); assert!(collector.followed_communities[&community_id1].contains(&user1_inbox)); assert!(collector.followed_communities[&community_id3].contains(&user3_inbox)); assert!(collector.followed_communities[&community_id2].contains(&user2_inbox)); Ok(()) } #[tokio::test] async fn test_get_inbox_urls_no_duplicates() -> LemmyResult<()> { let mut collector = setup_collector(); collector.domain = "example.com".to_string(); let community_id = CommunityId(1); let site_inbox = Url::parse("https://example.com/site_inbox")?; let site_inbox_clone = site_inbox.clone(); let site = Site { id: SiteId(1), name: "Test Site".to_string(), sidebar: None, published_at: Utc::now(), updated_at: None, icon: None, banner: None, summary: None, ap_id: Url::parse("https://example.com/site")?.into(), last_refreshed_at: Utc::now(), inbox_url: site_inbox.clone().into(), private_key: None, public_key: "test_key".to_string(), instance_id: InstanceId(1), content_warning: None, }; collector .data_source .expect_read_site_from_instance_id() .return_once(move |_| Ok(site)); collector .data_source .expect_get_instance_followed_community_inboxes() .return_once(move |_, _| Ok(vec![(community_id, site_inbox_clone.into())])); collector.update_communities().await?; let activity = SentActivity { id: ActivityId(1), ap_id: Url::parse("https://example.com/activities/1")?.into(), data: json!({}), sensitive: false, published_at: Utc::now(), send_inboxes: vec![Some(site_inbox.into())], send_community_followers_of: Some(community_id), send_all_instances: true, actor_type: ActorType::Person, actor_apub_id: None, }; let result = collector.get_inbox_urls(&activity).await?; assert_eq!(result.len(), 1); assert!(result.contains(&Url::parse("https://example.com/site_inbox")?)); Ok(()) } } ================================================ FILE: crates/apub/send/src/lib.rs ================================================ use crate::{util::CancellableTask, worker::InstanceWorker}; use activitypub_federation::config::FederationConfig; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::instance::Instance; use lemmy_db_schema_file::InstanceId; use lemmy_utils::{error::LemmyResult, settings::structs::FederationWorkerConfig}; use stats::receive_print_stats; use std::{collections::HashMap, time::Duration}; use tokio::{ sync::mpsc::{UnboundedSender, unbounded_channel}, task::JoinHandle, time::sleep, }; use tokio_util::sync::CancellationToken; use tracing::info; use util::FederationQueueStateWithDomain; mod inboxes; mod send; mod stats; mod util; mod worker; static WORKER_EXIT_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(debug_assertions)] static INSTANCES_RECHECK_DELAY: Duration = Duration::from_secs(5); #[cfg(not(debug_assertions))] static INSTANCES_RECHECK_DELAY: Duration = Duration::from_secs(60); #[derive(Clone)] pub struct Opts { /// how many processes you are starting in total pub process_count: i32, /// the index of this process (1-based: 1 - process_count) pub process_index: i32, } pub struct SendManager { opts: Opts, workers: HashMap, context: FederationConfig, stats_sender: UnboundedSender, exit_print: JoinHandle<()>, federation_worker_config: FederationWorkerConfig, } impl SendManager { fn new( opts: Opts, context: FederationConfig, federation_worker_config: FederationWorkerConfig, ) -> Self { assert!(opts.process_count > 0); assert!(opts.process_index > 0); assert!(opts.process_index <= opts.process_count); let (stats_sender, stats_receiver) = unbounded_channel(); Self { opts, workers: HashMap::new(), stats_sender, exit_print: tokio::spawn(receive_print_stats( context.inner_pool().clone(), stats_receiver, )), context, federation_worker_config, } } pub fn run( opts: Opts, context: FederationConfig, config: FederationWorkerConfig, ) -> CancellableTask { CancellableTask::spawn(WORKER_EXIT_TIMEOUT, move |cancel| { let opts = opts.clone(); let config = config.clone(); let context = context.clone(); let mut manager = Self::new(opts, context, config); async move { let result = manager.do_loop(cancel).await; // the loop function will only return if there is (a) an internal error (e.g. db connection // failure) or (b) it was cancelled from outside. if let Err(e) = result { // don't let this error bubble up, just log it, so the below cancel function will run // regardless tracing::error!("SendManager failed: {e}"); } // cancel all the dependent workers as well to ensure they don't get orphaned and keep // running. manager.cancel().await?; LemmyResult::Ok(()) // if the task was not intentionally cancelled, then this whole lambda will be run again by // CancellableTask after this } }) } async fn do_loop(&mut self, cancel: CancellationToken) -> LemmyResult<()> { let process_index = self.opts.process_index - 1; info!( "Starting federation workers for process count {} and index {}", self.opts.process_count, process_index ); let local_domain = self.context.settings().get_hostname_without_port()?; let mut pool = self.context.pool(); loop { let mut total_count = 0; let mut dead_count = 0; let mut disallowed_count = 0; for (instance, allowed, is_dead) in Instance::read_federated_with_blocked_and_dead(&mut pool).await? { if instance.domain == local_domain { continue; } if instance.id.inner() % self.opts.process_count != process_index { continue; } total_count += 1; if !allowed { disallowed_count += 1; } if is_dead { dead_count += 1; } let should_federate = allowed && !is_dead; if should_federate { if self.workers.contains_key(&instance.id) { // worker already running continue; } // create new worker let context = self.context.clone(); let stats_sender = self.stats_sender.clone(); let federation_worker_config = self.federation_worker_config.clone(); self.workers.insert( instance.id, CancellableTask::spawn(WORKER_EXIT_TIMEOUT, move |stop| { // if the instance worker ends unexpectedly due to internal/db errors, this lambda is // rerun by cancellabletask. let instance = instance.clone(); InstanceWorker::init_and_loop( instance, context.clone(), federation_worker_config.clone(), stop, stats_sender.clone(), ) }), ); } else if !should_federate && let Some(worker) = self.workers.remove(&instance.id) && let Err(e) = worker.cancel().await { tracing::error!("error stopping worker: {e}"); } } let worker_count = self.workers.len(); tracing::info!( "Federating to {worker_count}/{total_count} instances ({dead_count} dead, {disallowed_count} disallowed)" ); tokio::select! { () = sleep(INSTANCES_RECHECK_DELAY) => {}, _ = cancel.cancelled() => { return Ok(()) } } } } pub async fn cancel(self) -> LemmyResult<()> { drop(self.stats_sender); tracing::warn!( "Waiting for {} workers ({:.2?} max)", self.workers.len(), WORKER_EXIT_TIMEOUT ); // the cancel futures need to be awaited concurrently for the shutdown processes to be triggered // concurrently futures::future::join_all( self .workers .into_values() .map(util::CancellableTask::cancel), ) .await; self.exit_print.await?; Ok(()) } } #[cfg(test)] #[expect(clippy::unwrap_used)] #[expect(clippy::indexing_slicing)] mod test { use super::*; use activitypub_federation::config::Data; use chrono::DateTime; use lemmy_db_schema::source::{ federation_allowlist::{FederationAllowList, FederationAllowListForm}, federation_blocklist::{FederationBlockList, FederationBlockListForm}, instance::InstanceForm, person::{Person, PersonInsertForm}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyError; use serial_test::serial; use std::{ collections::HashSet, sync::{Arc, Mutex}, }; use tokio::spawn; struct TestData { send_manager: SendManager, context: Data, instances: Vec, } impl TestData { async fn init(process_count: i32, process_index: i32) -> LemmyResult { let context = LemmyContext::init_test_context().await; let opts = Opts { process_count, process_index, }; let federation_config = FederationConfig::builder() .domain("local.com") .app_data(context.app_data().clone()) .build() .await?; let concurrent_sends_per_instance = std::env::var("LEMMY_TEST_FEDERATION_CONCURRENT_SENDS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(1); let federation_worker_config = FederationWorkerConfig { concurrent_sends_per_instance, }; let pool = &mut context.pool(); let instances = vec![ Instance::read_or_create(pool, "alpha.com").await?, Instance::read_or_create(pool, "beta.com").await?, Instance::read_or_create(pool, "gamma.com").await?, ]; let send_manager = SendManager::new(opts, federation_config, federation_worker_config); Ok(Self { send_manager, context, instances, }) } async fn run(&mut self) -> LemmyResult<()> { // start it and cancel after workers are running let cancel = CancellationToken::new(); let cancel_ = cancel.clone(); spawn(async move { sleep(Duration::from_millis(100)).await; cancel_.cancel(); }); self.send_manager.do_loop(cancel.clone()).await?; Ok(()) } async fn cleanup(self) -> LemmyResult<()> { self.send_manager.cancel().await?; Instance::delete_all(&mut self.context.pool()).await?; Ok(()) } } /// Basic test with default params and only active/allowed instances #[tokio::test] #[serial] async fn test_send_manager() -> LemmyResult<()> { let mut data = TestData::init(1, 1).await?; data.run().await?; assert_eq!(3, data.send_manager.workers.len()); let workers: HashSet<_> = data.send_manager.workers.keys().cloned().collect(); let instances: HashSet<_> = data.instances.iter().map(|i| i.id).collect(); assert_eq!(instances, workers); data.cleanup().await?; Ok(()) } /// Running with multiple processes should start correct workers #[tokio::test] #[serial] async fn test_send_manager_processes() -> LemmyResult<()> { let active = Arc::new(Mutex::new(vec![])); let execute = |count, index, active: Arc>>| async move { let mut data = TestData::init(count, index).await?; data.run().await?; assert_eq!(1, data.send_manager.workers.len()); for k in data.send_manager.workers.keys() { active.lock().unwrap().push(*k); } data.cleanup().await?; Ok::<(), LemmyError>(()) }; execute(3, 1, active.clone()).await?; execute(3, 2, active.clone()).await?; execute(3, 3, active.clone()).await?; // Should run exactly three workers assert_eq!(3, active.lock().unwrap().len()); Ok(()) } /// Use blocklist, should not send to blocked instances #[tokio::test] #[serial] async fn test_send_manager_blocked() -> LemmyResult<()> { let mut data = TestData::init(1, 1).await?; let instance_id = data.instances[0].id; let form = PersonInsertForm::new("tim".to_string(), String::new(), instance_id); let person = Person::create(&mut data.context.pool(), &form).await?; let form = FederationBlockListForm::new(instance_id, None); FederationBlockList::block(&mut data.context.pool(), &form).await?; data.run().await?; let workers = &data.send_manager.workers; assert_eq!(2, workers.len()); assert!(workers.contains_key(&data.instances[1].id)); assert!(workers.contains_key(&data.instances[2].id)); Person::delete(&mut data.context.pool(), person.id).await?; data.cleanup().await?; Ok(()) } /// Use allowlist, should only send to allowed instance #[tokio::test] #[serial] async fn test_send_manager_allowed() -> LemmyResult<()> { let mut data = TestData::init(1, 1).await?; let instance_id = data.instances[0].id; let form = PersonInsertForm::new("tim".to_string(), String::new(), instance_id); let person = Person::create(&mut data.context.pool(), &form).await?; let form = FederationAllowListForm::new(data.instances[0].id); FederationAllowList::allow(&mut data.context.pool(), &form).await?; data.run().await?; let workers = &data.send_manager.workers; assert_eq!(1, workers.len()); assert!(workers.contains_key(&data.instances[0].id)); Person::delete(&mut data.context.pool(), person.id).await?; data.cleanup().await?; Ok(()) } /// Mark instance as dead, there should be no worker created for it #[tokio::test] #[serial] async fn test_send_manager_dead() -> LemmyResult<()> { let mut data = TestData::init(1, 1).await?; let instance = &data.instances[0]; let form = InstanceForm { updated_at: DateTime::from_timestamp(0, 0), ..InstanceForm::new(instance.domain.clone()) }; Instance::update(&mut data.context.pool(), instance.id, form).await?; data.run().await?; let workers = &data.send_manager.workers; assert_eq!(2, workers.len()); assert!(workers.contains_key(&data.instances[1].id)); assert!(workers.contains_key(&data.instances[2].id)); data.cleanup().await?; Ok(()) } } ================================================ FILE: crates/apub/send/src/send.rs ================================================ use crate::util::get_actor_cached; use activitypub_federation::{ activity_sending::SendActivityTask, config::Data, protocol::context::WithContext, traits::Activity, }; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{newtypes::ActivityId, source::activity::SentActivity}; use lemmy_utils::{ FEDERATION_CONTEXT, error::{LemmyError, LemmyResult}, federate_retry_sleep_duration, }; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::ops::Deref; use tokio::{sync::mpsc::UnboundedSender, time::sleep}; use tokio_util::sync::CancellationToken; #[derive(Debug, Eq)] pub(crate) struct SendSuccessInfo { pub activity_id: ActivityId, pub published_at: Option>, // true if the activity was skipped because the target instance is not interested in this // activity pub was_skipped: bool, } impl PartialEq for SendSuccessInfo { fn eq(&self, other: &Self) -> bool { self.activity_id == other.activity_id } } /// order backwards because the binary heap is a max heap, and we need the smallest element to be on /// top impl PartialOrd for SendSuccessInfo { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for SendSuccessInfo { fn cmp(&self, other: &Self) -> std::cmp::Ordering { other.activity_id.cmp(&self.activity_id) } } /// Represents the result of sending an activity. /// /// This enum is used to communicate the outcome of a send operation from a send task /// to the main instance worker. It's designed to maintain a clean separation between /// the send task and the main thread, allowing the send.rs file to be self-contained /// and easier to understand. /// /// The use of a channel for communication (rather than shared atomic variables) was chosen /// because: /// 1. It keeps the send task cleanly separated with no direct interaction with the main thread. /// 2. The failure event needs to be transferred to the main task for database updates anyway. /// 3. The main fail_count should only be updated under certain conditions, which are best handled /// in the main task. /// 4. It maintains consistency in how data is communicated (all via channels rather than a mix of /// channels and atomics). /// 5. It simplifies concurrency management and makes the flow of data more predictable. pub(crate) enum SendActivityResult { Success(SendSuccessInfo), Failure { fail_count: i32 }, } /// Represents a task for retrying to send an activity. /// /// This struct encapsulates all the necessary information and resources for attempting /// to send an activity to multiple inbox URLs, with built-in retry logic. pub(crate) struct SendRetryTask<'a> { pub activity: &'a SentActivity, /// The activity data to be sent. Has type `SharedInboxActivities`, but uses `Value` to avoid /// dependency. pub object: &'a Value, /// Must not be empty at this point pub inbox_urls: Vec, /// Channel to report results back to the main instance worker pub report: &'a mut UnboundedSender, /// The first request will be sent immediately, but subsequent requests will be delayed /// according to the number of previous fails + 1 /// /// This is a read-only immutable variable that is passed only one way, from the main /// thread to each send task. It allows the task to determine how long to sleep initially /// if the request fails. pub initial_fail_count: i32, /// For logging purposes pub domain: String, pub context: Data, pub stop: CancellationToken, } impl SendRetryTask<'_> { // this function will return successfully when (a) send succeeded or (b) worker cancelled // and will return an error if an internal error occurred (send errors cause an infinite loop) pub async fn send_retry_loop(self) -> Result<()> { let SendRetryTask { activity, object, inbox_urls, report, initial_fail_count, domain, context, stop, } = self; debug_assert!(!inbox_urls.is_empty()); let pool = &mut context.pool(); let Some(actor_apub_id) = &activity.actor_apub_id else { return Err(anyhow::anyhow!("activity is from before lemmy 0.19")); }; let actor = get_actor_cached(pool, activity.actor_type, actor_apub_id) .await .context("failed getting actor instance (was it marked deleted / removed?)")?; let object: DummyActivity = serde_json::from_value(object.clone())?; let object = WithContext::new(object, FEDERATION_CONTEXT.deref().clone()); let requests = SendActivityTask::prepare(&object, actor.as_ref(), inbox_urls, &context).await?; for task in requests { // usually only one due to shared inbox tracing::debug!("sending out {}", task); let mut fail_count = initial_fail_count; while let Err(e) = task.sign_and_send(&context).await { fail_count += 1; report.send(SendActivityResult::Failure { fail_count, // activity_id: activity.id, })?; let retry_delay = federate_retry_sleep_duration(fail_count); tracing::info!( "{}: retrying {:?} attempt {} with delay {retry_delay:.2?}. ({e})", domain, activity.id, fail_count ); tokio::select! { () = sleep(retry_delay) => {}, () = stop.cancelled() => { // cancel sending without reporting any result. // the InstanceWorker needs to be careful to not hang on receive of that // channel when cancelled (see handle_send_results) return Ok(()); } } } } report.send(SendActivityResult::Success(SendSuccessInfo { activity_id: activity.id, published_at: Some(activity.published_at), was_skipped: false, }))?; Ok(()) } } #[derive(Serialize, Deserialize, Debug)] struct DummyActivity { id: Url, actor: Url, #[serde(flatten)] other: Value, } #[async_trait::async_trait] impl Activity for DummyActivity { type DataType = LemmyContext; type Error = LemmyError; fn id(&self) -> &Url { &self.id } fn actor(&self) -> &Url { &self.actor } async fn verify(&self, _context: &Data) -> LemmyResult<()> { Ok(()) } async fn receive(self, _context: &Data) -> LemmyResult<()> { Ok(()) } } ================================================ FILE: crates/apub/send/src/stats.rs ================================================ use crate::util::{FederationQueueStateWithDomain, get_latest_activity_id}; use chrono::Local; use lemmy_db_schema::newtypes::ActivityId; use lemmy_db_schema_file::InstanceId; use lemmy_diesel_utils::connection::{ActualDbPool, DbPool}; use lemmy_utils::{error::LemmyResult, federate_retry_sleep_duration}; use std::{collections::HashMap, time::Duration}; use tokio::{sync::mpsc::UnboundedReceiver, time::interval}; use tracing::{debug, info, warn}; /// every 60s, print the state for every instance. exits if the receiver is done (all senders /// dropped) pub(crate) async fn receive_print_stats( pool: ActualDbPool, mut receiver: UnboundedReceiver, ) { let pool = &mut DbPool::Pool(&pool); let mut printerval = interval(Duration::from_secs(60)); let mut stats = HashMap::new(); loop { tokio::select! { ele = receiver.recv() => { match ele { // update stats for instance Some(ele) => {stats.insert(ele.state.instance_id, ele);}, // receiver closed, print stats and exit None => { print_stats(pool, &stats).await; return; } } }, _ = printerval.tick() => { print_stats(pool, &stats).await; } } } } async fn print_stats( pool: &mut DbPool<'_>, stats: &HashMap, ) { let res = print_stats_with_error(pool, stats).await; if let Err(e) = res { warn!("Failed to print stats: {e}"); } } async fn print_stats_with_error( pool: &mut DbPool<'_>, stats: &HashMap, ) -> LemmyResult<()> { let last_id = get_latest_activity_id(pool).await?.unwrap_or(ActivityId(0)); // it's expected that the values are a bit out of date, everything < SAVE_STATE_EVERY should be // considered up to date info!("Federation state as of {}:", Local::now().to_rfc3339()); // todo: more stats (act/sec, avg http req duration) let mut ok_count = 0; let mut behind_count = 0; for ele in stats.values() { let stat = &ele.state; let domain = &ele.domain; let behind = last_id.0 - stat.last_successful_id.map(|e| e.0).unwrap_or(0); if stat.fail_count > 0 { info!( "{domain}: Warning. {behind} behind, {} consecutive fails, current retry delay {:.2?}", stat.fail_count, federate_retry_sleep_duration(stat.fail_count) ); } else if behind > 0 { debug!("{}: Ok. {} activities behind", domain, behind); behind_count += 1; } else { ok_count += 1; } } info!("{ok_count} others up to date. {behind_count} instances behind."); Ok(()) } ================================================ FILE: crates/apub/send/src/util.rs ================================================ use anyhow::{Context, Result, anyhow}; use diesel::prelude::*; use diesel_async::RunQueryDsl; use either::Either::*; use lemmy_apub_objects::objects::SiteOrMultiOrCommunityOrUser; use lemmy_db_schema::{ newtypes::ActivityId, source::{ activity::SentActivity, community::Community, federation_queue_state::FederationQueueState, multi_community::MultiCommunity, person::Person, site::Site, }, traits::ApubActor, }; use lemmy_db_schema_file::enums::ActorType; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::LemmyError; use moka::future::Cache; use reqwest::Url; use std::{ fmt::Debug, future::Future, pin::Pin, sync::{Arc, LazyLock}, time::Duration, }; use tokio::{task::JoinHandle, time::sleep}; use tokio_util::sync::CancellationToken; /// Decrease the delays of the federation queue. /// Should only be used for federation tests since it significantly increases CPU and DB load of the /// federation queue. This is intentionally a separate flag from other flags like debug_assertions, /// since this is a invasive change we only need rarely. pub(crate) static LEMMY_TEST_FAST_FEDERATION: LazyLock = LazyLock::new(|| { std::env::var("LEMMY_TEST_FAST_FEDERATION") .map(|s| !s.is_empty()) .unwrap_or(false) }); /// Recheck for new federation work every n seconds within each InstanceWorker. /// /// When the queue is processed faster than new activities are added and it reaches the current time /// with an empty batch, this is the delay the queue waits before it checks if new activities have /// been added to the sent_activities table. This delay is only applied if no federated activity /// happens during sending activities of the last batch, which means on high-activity instances it /// may never be used. This means that it does not affect the maximum throughput of the queue. /// /// /// This is thus the interval with which tokio wakes up each of the /// InstanceWorkers to check for new work, if the queue previously was empty. /// If the delay is too short, the workers (one per federated instance) will wake up too /// often and consume a lot of CPU. If the delay is long, then activities on low-traffic instances /// will on average take delay/2 seconds to federate. pub(crate) static WORK_FINISHED_RECHECK_DELAY: LazyLock = LazyLock::new(|| { if *LEMMY_TEST_FAST_FEDERATION { Duration::from_millis(100) } else { Duration::from_secs(30) } }); /// Cache the latest activity id for a certain duration. /// /// This cache is common to all the instance workers and prevents there from being more than one /// call per N seconds between each DB query to find max(activity_id). pub(crate) static CACHE_DURATION_LATEST_ID: LazyLock = LazyLock::new(|| { if *LEMMY_TEST_FAST_FEDERATION { // in test mode, we use the same cache duration as the recheck delay so when recheck happens // data is fresh, accelerating the time the tests take. *WORK_FINISHED_RECHECK_DELAY } else { // in normal mode, we limit the query to one per second Duration::from_secs(1) } }); /// A task that will be run in an infinite loop, unless it is cancelled. /// If the task exits without being cancelled, an error will be logged and the task will be /// restarted. pub struct CancellableTask { f: Pin> + Send + 'static>>, } impl CancellableTask { /// spawn a task but with graceful shutdown pub fn spawn( timeout: Duration, task: impl Fn(CancellationToken) -> F + Send + 'static, ) -> CancellableTask where F: Future + Send + 'static, R: Send + Debug + 'static, { let stop = CancellationToken::new(); let stop2 = stop.clone(); let task: JoinHandle<()> = tokio::spawn(async move { loop { let res = task(stop2.clone()).await; if stop2.is_cancelled() { return; } else { tracing::warn!("task exited, restarting: {res:?}"); } } }); let abort = task.abort_handle(); CancellableTask { f: Box::pin(async move { stop.cancel(); tokio::select! { r = task => { r.context("CancellableTask failed to cancel cleanly, returned error")?; Ok(()) }, _ = sleep(timeout) => { abort.abort(); Err(anyhow!("CancellableTask aborted due to shutdown timeout")) } } }), } } /// cancel the cancel signal, wait for timeout for the task to stop gracefully, otherwise abort it pub async fn cancel(self) -> Result<(), anyhow::Error> { self.f.await } } /// assuming apub priv key and ids are immutable, then we don't need to have TTL /// TODO: capacity should be configurable maybe based on memory use pub(crate) async fn get_actor_cached( pool: &mut DbPool<'_>, actor_type: ActorType, actor_apub_id: &Url, ) -> Result> { static CACHE: LazyLock>> = LazyLock::new(|| Cache::builder().max_capacity(10000).build()); CACHE .try_get_with(actor_apub_id.clone(), async { let url = actor_apub_id.clone().into(); let actor = match actor_type { ActorType::Site => Left(Left( Site::read_from_apub_id(pool, &url) .await? .context("apub site not found")? .into(), )), ActorType::Community => Right(Right( Community::read_from_apub_id(pool, &url) .await? .context("apub community not found")? .into(), )), ActorType::Person => Right(Left( Person::read_from_apub_id(pool, &url) .await? .context("apub person not found")? .into(), )), ActorType::MultiCommunity => Left(Right( MultiCommunity::read_from_apub_id(pool, &url) .await? .context("apub multi-comm not found")? .into(), )), }; Result::<_, LemmyError>::Ok(Arc::new(actor)) }) .await .map_err(|e| anyhow::anyhow!("err getting actor {actor_type:?} {actor_apub_id}: {e:?}")) } type CachedActivityInfo = Option>; /// activities are immutable so cache does not need to have TTL /// May return None if the corresponding id does not exist or is a received activity. /// Holes in serials are expected behaviour in postgresql /// todo: cache size should probably be configurable / dependent on desired memory usage pub(crate) async fn get_activity_cached( pool: &mut DbPool<'_>, activity_id: ActivityId, ) -> Result { static ACTIVITIES: LazyLock> = LazyLock::new(|| Cache::builder().max_capacity(10000).build()); ACTIVITIES .try_get_with(activity_id, async { Ok(Some(Arc::new(SentActivity::read(pool, activity_id).await?))) }) .await .map_err(|e: Arc| anyhow::anyhow!("err getting activity: {e:?}")) } /// return the most current activity id (with 1 second cache) pub(crate) async fn get_latest_activity_id(pool: &mut DbPool<'_>) -> Result> { static CACHE: LazyLock>> = LazyLock::new(|| { Cache::builder() .time_to_live(*CACHE_DURATION_LATEST_ID) .build() }); CACHE .try_get_with((), async { use lemmy_db_schema_file::schema::sent_activity::dsl::{id, sent_activity}; let conn = &mut get_conn(pool).await?; let latest_id: Option = sent_activity .select(diesel::dsl::max(id)) .get_result(conn) .await?; anyhow::Result::<_, anyhow::Error>::Ok(latest_id) }) .await .map_err(|e| anyhow::anyhow!("err getting id: {e:?}")) } /// the domain name is needed for logging, pass it to the stats printer so it doesn't need to look /// up the domain itself #[derive(Debug)] pub(crate) struct FederationQueueStateWithDomain { pub domain: String, pub state: FederationQueueState, } ================================================ FILE: crates/apub/send/src/worker.rs ================================================ use crate::{ inboxes::RealCommunityInboxCollector, send::{SendActivityResult, SendRetryTask, SendSuccessInfo}, util::{ FederationQueueStateWithDomain, WORK_FINISHED_RECHECK_DELAY, get_activity_cached, get_latest_activity_id, }, }; use activitypub_federation::config::FederationConfig; use anyhow::{Context, Result}; use chrono::{DateTime, Days, TimeZone, Utc}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ newtypes::ActivityId, source::{ federation_queue_state::FederationQueueState, instance::{Instance, InstanceForm}, }, }; use lemmy_diesel_utils::connection::{ActualDbPool, DbPool}; use lemmy_utils::{ error::LemmyResult, federate_retry_sleep_duration, settings::structs::FederationWorkerConfig, }; use std::{cmp::max, collections::BinaryHeap, ops::Add, time::Duration}; use tokio::{ sync::mpsc::{self, UnboundedSender}, time::sleep, }; use tokio_util::sync::CancellationToken; /// Save state to db after this time has passed since the last state (so if the server crashes or is /// SIGKILLed, less than X seconds of activities are resent) #[cfg(not(test))] static SAVE_STATE_EVERY_TIME: Duration = Duration::from_secs(60); #[cfg(test)] /// in test mode, we want it to save state and send it to print_stats after every send static SAVE_STATE_EVERY_TIME: Duration = Duration::from_secs(0); /// Maximum number of successful sends to allow out of order const MAX_SUCCESSFULS: usize = 1000; /// in prod mode, try to collect multiple send results at the same time to reduce load #[cfg(not(test))] const MIN_ACTIVITY_SEND_RESULTS_TO_HANDLE: usize = 4; #[cfg(test)] const MIN_ACTIVITY_SEND_RESULTS_TO_HANDLE: usize = 0; /// /// SendManager --(has many)--> InstanceWorker --(has many)--> SendRetryTask /// | | | /// -----|------create worker -> loop activities--create task-> send activity /// | | vvvv /// | | fail or success /// | | <-report result-- | /// | <---order and aggrate results--- | /// | <---send stats--- | | /// filter and print stats | | pub(crate) struct InstanceWorker { instance: Instance, stop: CancellationToken, federation_lib_config: FederationConfig, federation_worker_config: FederationWorkerConfig, state: FederationQueueState, last_state_insert: DateTime, pool: ActualDbPool, inbox_collector: RealCommunityInboxCollector, // regularily send stats back to the SendManager stats_sender: UnboundedSender, // each HTTP send will report back to this channel concurrently receive_send_result: mpsc::UnboundedReceiver, // this part of the channel is cloned and passed to the SendRetryTasks report_send_result: mpsc::UnboundedSender, // activities that have been successfully sent but // that are not the lowest number and thus can't be written to the database yet successfuls: BinaryHeap, // number of activities that currently have a task spawned to send it in_flight: i8, } impl InstanceWorker { pub(crate) async fn init_and_loop( instance: Instance, config: FederationConfig, federation_worker_config: FederationWorkerConfig, stop: CancellationToken, stats_sender: UnboundedSender, ) -> LemmyResult<()> { let pool = config.to_request_data().inner_pool().clone(); let state = FederationQueueState::load(&mut DbPool::Pool(&pool), instance.id).await?; let (report_send_result, receive_send_result) = tokio::sync::mpsc::unbounded_channel::(); let mut worker = InstanceWorker { inbox_collector: RealCommunityInboxCollector::new_real( pool.clone(), instance.id, instance.domain.clone(), ), federation_worker_config, instance, stop, federation_lib_config: config, stats_sender, state, last_state_insert: Utc.timestamp_nanos(0), pool, receive_send_result, report_send_result, successfuls: BinaryHeap::::new(), in_flight: 0, }; worker.loop_until_stopped().await } /// loop fetch new activities from db and send them to the inboxes of the given instances /// this worker only returns if (a) there is an internal error or (b) the cancellation token is /// cancelled (graceful exit) async fn loop_until_stopped(&mut self) -> LemmyResult<()> { self.initial_fail_sleep().await?; let mut last_sent_id = self.get_last_sent_id().await?; while !self.stop.is_cancelled() { // check if we need to wait for a send to finish before sending the next one // we wait if (a) the last request failed, only if a request is already in flight (not at the // start of the loop) or (b) if we have too many successfuls in memory or (c) if we have // too many in flight let need_wait_for_event = (self.in_flight != 0 && self.state.fail_count > 0) || self.successfuls.len() >= MAX_SUCCESSFULS || self.in_flight >= self.federation_worker_config.concurrent_sends_per_instance; if need_wait_for_event || self.receive_send_result.len() > MIN_ACTIVITY_SEND_RESULTS_TO_HANDLE { // if len() > 0 then this does not block and allows us to write to db more often // if len is 0 then this means we wait for something to change our above conditions, // which can only happen by an event sent into the channel self.handle_send_results().await?; // handle_send_results does not guarantee that we are now in a condition where we want to // send a new one, so repeat this check until the if no longer applies continue; } // send a new activity if there is one self.inbox_collector.update_communities().await?; let next_id_to_send = ActivityId(last_sent_id.0 + 1); let successfuls_len: i64 = self.successfuls.len().try_into()?; { // sanity check: calculate next id to send based on the last id and the in flight requests let expected_next_id = self.state.last_successful_id.map(|last_successful_id| { last_successful_id.0 + successfuls_len + i64::from(self.in_flight) + 1 }); // compare to next id based on incrementing if expected_next_id != Some(next_id_to_send.0) { return Err( anyhow::anyhow!( "{}: next id to send is not as expected: {:?} != {:?}", self.instance.domain, expected_next_id, next_id_to_send ) .into(), ); } } let newest_id_opt = get_latest_activity_id(&mut self.pool()).await?; let newest_id = newest_id_opt.unwrap_or(ActivityId(0)); if next_id_to_send > newest_id { // If next id to send for this instance is higher than the highest sent_activity table id // there may be a problem and activities wont send. // However this can occur normally if there was no outgoing activity for a week // and sent_activity was completely emptied by scheduled task. if newest_id_opt.is_some() && next_id_to_send > ActivityId(newest_id.0 + 1) { tracing::error!( "{}: next send id {} is higher than latest id {}+1 in table sent_activity (did the db get cleared?)", self.instance.domain, next_id_to_send.0, newest_id.0 ); } // no more work to be done, wait before rechecking tokio::select! { () = sleep(*WORK_FINISHED_RECHECK_DELAY) => {}, () = self.stop.cancelled() => { tracing::debug!("cancelled worker loop while waiting for new work") } } continue; } self.in_flight += 1; last_sent_id = next_id_to_send; self.spawn_send_if_needed(next_id_to_send).await?; } tracing::debug!("cancelled worker loop after send"); // final update of state in db on shutdown self.save_and_send_state().await?; Ok(()) } async fn initial_fail_sleep(&mut self) -> Result<()> { // before starting queue, sleep remaining duration if last request failed if self.state.fail_count > 0 { let last_retry = self .state .last_retry_at .context("impossible: if fail count set last retry also set")?; let elapsed = (Utc::now() - last_retry).to_std()?; let required = federate_retry_sleep_duration(self.state.fail_count); if elapsed >= required { return Ok(()); } let remaining = required - elapsed; tracing::debug!( "{}: fail-sleeping for {:?} before starting queue", self.instance.domain, remaining ); tokio::select! { () = sleep(remaining) => {}, () = self.stop.cancelled() => { tracing::debug!("cancelled worker loop during initial fail sleep") } } } Ok(()) } /// Return the last successfully sent id. /// Sets last_successful_id in database if it's the first time this instance is seen. async fn get_last_sent_id(&mut self) -> Result { let last = if let Some(last) = self.state.last_successful_id { last } else { let latest_id = get_latest_activity_id(&mut self.pool()) .await? .unwrap_or(ActivityId(0)); // this is the initial creation (instance first seen) of the federation queue for this // instance // skip all past activities: self.state.last_successful_id = Some(latest_id); // save here to ensure it's not read as 0 again later if no activities have happened self.save_and_send_state().await?; latest_id }; Ok(last) } async fn handle_send_results(&mut self) -> Result<(), anyhow::Error> { let mut force_write = false; let mut events = Vec::new(); // Wait for at least one event but if there's multiple handle them all. // We need to listen to the cancel event here as well in order to prevent a hang on shutdown: // If the SendRetryTask gets cancelled, it immediately exits without reporting any state. // So if the worker is waiting for a send result and all SendRetryTask gets cancelled, this recv // could hang indefinitely otherwise. The tasks will also drop their handle of // report_send_result which would cause the recv_many method to return 0 elements, but since // InstanceWorker holds a copy of the send result channel as well, that won't happen. tokio::select! { _ = self.receive_send_result.recv_many(&mut events, 1000) => {}, () = self.stop.cancelled() => { tracing::debug!("cancelled worker loop while waiting for send results"); return Ok(()); } } for event in events { match event { SendActivityResult::Success(s) => { self.in_flight -= 1; if !s.was_skipped { self.state.fail_count = max(0, self.state.fail_count - 1); self.mark_instance_alive().await?; } self.successfuls.push(s); } SendActivityResult::Failure { fail_count, .. } => { if fail_count > self.state.fail_count { // override fail count - if multiple activities are currently sending this value may get // conflicting info but that's fine. // This needs to be this way, all alternatives would be worse. The reason is that if 10 // simultaneous requests fail within a 1s period, we don't want the next retry to be // exponentially 2**10 s later. Any amount of failures within a fail-sleep period should // only count as one failure. self.state.fail_count = fail_count; self.state.last_retry_at = Some(Utc::now()); force_write = true; } } } } self.pop_successfuls_and_write(force_write).await?; Ok(()) } async fn mark_instance_alive(&mut self) -> Result<()> { // Activity send successful, mark instance as alive if it hasn't been updated in a while. let updated = self .instance .updated_at .unwrap_or(self.instance.published_at); if updated.add(Days::new(1)) < Utc::now() { self.instance.updated_at = Some(Utc::now()); let form = InstanceForm { updated_at: Some(Utc::now()), ..InstanceForm::new(self.instance.domain.clone()) }; Instance::update(&mut self.pool(), self.instance.id, form) .await .map_err(|e| anyhow::anyhow!(e))?; } Ok(()) } /// Checks that sequential activities `last_successful_id + 1`, `last_successful_id + 2` etc have /// been sent successfully. In that case updates `last_successful_id` and saves the state to the /// database if the time since the last save is greater than `SAVE_STATE_EVERY_TIME`. async fn pop_successfuls_and_write(&mut self, force_write: bool) -> Result<()> { let Some(mut last_id) = self.state.last_successful_id else { tracing::warn!( "{} should be impossible: last successful id is None", self.instance.domain ); return Ok(()); }; tracing::debug!( "{} last: {:?}, next: {:?}, currently in successfuls: {:?}", self.instance.domain, last_id, self.successfuls.peek(), self.successfuls.iter() ); while self .successfuls .peek() .map(|a| a.activity_id == ActivityId(last_id.0 + 1)) .unwrap_or(false) { let next = self .successfuls .pop() .context("peek above ensures pop has value")?; last_id = next.activity_id; self.state.last_successful_id = Some(next.activity_id); self.state.last_successful_published_time_at = next.published_at; } let save_state_every = chrono::Duration::from_std(SAVE_STATE_EVERY_TIME)?; if force_write || (Utc::now() - self.last_state_insert) > save_state_every { self.save_and_send_state().await?; } Ok(()) } /// we collect the relevant inboxes in the main instance worker task, and only spawn the send task /// if we have inboxes to send to this limits CPU usage and reduces overhead for the (many) /// cases where we don't have any inboxes async fn spawn_send_if_needed(&mut self, activity_id: ActivityId) -> LemmyResult<()> { let Ok(Some(ele)) = get_activity_cached(&mut self.pool(), activity_id).await else { tracing::debug!("{}: {:?} does not exist", self.instance.domain, activity_id); self .report_send_result .send(SendActivityResult::Success(SendSuccessInfo { activity_id, published_at: None, was_skipped: true, }))?; return Ok(()); }; let activity = &ele; let inbox_urls = self.inbox_collector.get_inbox_urls(activity).await?; if inbox_urls.is_empty() { // this is the case when the activity is not relevant to this receiving instance (e.g. no user // subscribed to the relevant community) tracing::debug!("{}: {:?} no inboxes", self.instance.domain, activity.id); self .report_send_result .send(SendActivityResult::Success(SendSuccessInfo { activity_id, // it would be valid here to either return None or Some(activity.published). The published // time is only used for stats pages that track federation delay. None can be a bit // misleading because if you look at / chart the published time for federation from a // large to a small instance that's only subscribed to a few small communities, // then it will show the last published time as a days ago even though // federation is up to date. published_at: Some(activity.published_at), was_skipped: true, }))?; return Ok(()); } let initial_fail_count = self.state.fail_count; let data = self.federation_lib_config.to_request_data(); let stop = self.stop.clone(); let domain = self.instance.domain.clone(); let mut report = self.report_send_result.clone(); tokio::spawn(async move { let res = SendRetryTask { activity: &ele, object: &ele.data, inbox_urls, report: &mut report, initial_fail_count, domain, context: data, stop, } .send_retry_loop() .await; if let Err(e) = res { tracing::warn!( "sending {} errored internally, skipping activity: {:?}", ele.ap_id, e ); // An error in this location means there is some deeper internal issue with the activity, // for example the actor can't be loaded or similar. These issues are probably not // solveable by retrying and would cause the federation for this instance to permanently be // stuck in a retry loop. So we log the error and skip the activity (by reporting success to // the worker) report .send(SendActivityResult::Success(SendSuccessInfo { activity_id, published_at: None, was_skipped: true, })) .ok(); } }); Ok(()) } async fn save_and_send_state(&mut self) -> Result<()> { tracing::debug!("{}: saving and sending state", self.instance.domain); self.last_state_insert = Utc::now(); FederationQueueState::upsert(&mut self.pool(), &self.state) .await .map_err(|e| anyhow::anyhow!(e))?; self.stats_sender.send(FederationQueueStateWithDomain { state: self.state.clone(), domain: self.instance.domain.clone(), })?; Ok(()) } fn pool(&self) -> DbPool<'_> { DbPool::Pool(&self.pool) } } #[cfg(test)] #[expect(clippy::unwrap_used)] #[expect(clippy::indexing_slicing)] mod test { use super::*; use activitypub_federation::{ http_signatures::generate_actor_keypair, protocol::context::WithContext, }; use actix_web::{App, HttpResponse, HttpServer, dev::ServerHandle, web}; use futures::future::try_join_all; use lemmy_api_utils::utils::generate_inbox_url; use lemmy_db_schema::source::{ activity::{SentActivity, SentActivityForm}, person::{Person, PersonInsertForm}, }; use lemmy_db_schema_file::enums::ActorType; use lemmy_diesel_utils::{dburl::DbUrl, traits::Crud}; use lemmy_utils::error::LemmyResult; use serde_json::{Value, json}; use serial_test::serial; use std::sync::{Arc, RwLock}; use test_context::{AsyncTestContext, test_context}; use tokio::{ spawn, sync::mpsc::{UnboundedReceiver, error::TryRecvError, unbounded_channel}, }; use tracing_test::traced_test; use url::Url; struct Data { context: activitypub_federation::config::Data, instance: Instance, person: Person, stats_receiver: UnboundedReceiver, inbox_receiver: UnboundedReceiver, cancel: CancellationToken, cleaned_up: bool, wait_stop_server: ServerHandle, is_concurrent: bool, respond_with_error: Arc>, } impl Data { async fn init() -> LemmyResult { let context = LemmyContext::init_test_federation_config().await; let instance = Instance::read_or_create(&mut context.pool(), "localhost").await?; let actor_keypair = generate_actor_keypair()?; let ap_id: DbUrl = Url::parse("http://local.com/u/alice")?.into(); let person_form = PersonInsertForm { ap_id: Some(ap_id.clone()), private_key: (Some(actor_keypair.private_key)), inbox_url: Some(generate_inbox_url()?), ..PersonInsertForm::new("alice".to_string(), actor_keypair.public_key, instance.id) }; let person = Person::create(&mut context.pool(), &person_form).await?; let cancel = CancellationToken::new(); let (stats_sender, stats_receiver) = unbounded_channel(); let (inbox_sender, inbox_receiver) = unbounded_channel(); // listen for received activities in background let respond_with_error = Arc::new(RwLock::new(false)); let wait_stop_server = listen_activities(inbox_sender, respond_with_error.clone())?; let concurrent_sends_per_instance = std::env::var("LEMMY_TEST_FEDERATION_CONCURRENT_SENDS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(10); let fed_config = FederationWorkerConfig { concurrent_sends_per_instance, }; spawn(InstanceWorker::init_and_loop( instance.clone(), context.clone(), fed_config, cancel.clone(), stats_sender, )); // wait for startup sleep(*WORK_FINISHED_RECHECK_DELAY).await; Ok(Self { context: context.to_request_data(), instance, person, stats_receiver, inbox_receiver, cancel, wait_stop_server, cleaned_up: false, is_concurrent: concurrent_sends_per_instance > 1, respond_with_error, }) } async fn cleanup(&mut self) -> LemmyResult<()> { if self.cleaned_up { return Ok(()); } self.cleaned_up = true; self.cancel.cancel(); sleep(*WORK_FINISHED_RECHECK_DELAY).await; Instance::delete_all(&mut self.context.pool()).await?; Person::delete(&mut self.context.pool(), self.person.id).await?; self.wait_stop_server.stop(true).await; Ok(()) } } /// In order to guarantee that the webserver is stopped via the cleanup function, /// we implement a test context. impl AsyncTestContext for Data { async fn setup() -> Data { Data::init().await.unwrap() } async fn teardown(mut self) { self.cleanup().await.unwrap() } } #[test_context(Data)] #[tokio::test] #[traced_test] #[serial] async fn test_stats(data: &mut Data) -> LemmyResult<()> { tracing::debug!("hello world"); // first receive at startup let rcv = data.stats_receiver.recv().await.unwrap(); tracing::debug!("received first stats"); assert_eq!(data.instance.id, rcv.state.instance_id); let sent = send_activity(data.person.ap_id.clone(), &data.context, true).await?; tracing::debug!("sent activity"); // receive for successfully sent activity let inbox_rcv = data.inbox_receiver.recv().await.unwrap(); let parsed_activity = serde_json::from_str::>(&inbox_rcv)?; assert_eq!(&sent.data, parsed_activity.inner()); tracing::debug!("received activity"); let rcv = data.stats_receiver.recv().await.unwrap(); assert_eq!(data.instance.id, rcv.state.instance_id); assert_eq!(Some(sent.id), rcv.state.last_successful_id); tracing::debug!("received second stats"); data.cleanup().await?; // it also sends state on shutdown let rcv = data.stats_receiver.try_recv(); assert!(rcv.is_ok()); // nothing further received let rcv = data.stats_receiver.try_recv(); assert_eq!(Some(TryRecvError::Disconnected), rcv.err()); let inbox_rcv = data.inbox_receiver.try_recv(); assert_eq!(Some(TryRecvError::Disconnected), inbox_rcv.err()); Ok(()) } #[test_context(Data)] #[tokio::test] #[traced_test] #[serial] async fn test_send_40(data: &mut Data) -> LemmyResult<()> { tracing::debug!("hello world"); // first receive at startup let rcv = data.stats_receiver.recv().await.unwrap(); tracing::debug!("received first stats"); assert_eq!(data.instance.id, rcv.state.instance_id); // assert_eq!(Some(ActivityId(0)), rcv.state.last_successful_id); // let last_id_before = rcv.state.last_successful_id.unwrap(); let mut sent = vec![]; for _ in 0..40 { sent.push(send_activity(data.person.ap_id.clone(), &data.context, false).await?); } sleep(2 * *WORK_FINISHED_RECHECK_DELAY).await; tracing::debug!("sent activity"); compare_sent_with_receive(data, sent).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[traced_test] #[serial] /// this test sends 15 activities, waits and checks they have all been received, then sends 50, /// etc async fn test_send_15_20_30(data: &mut Data) -> LemmyResult<()> { tracing::debug!("hello world"); // first receive at startup let rcv = data.stats_receiver.recv().await.unwrap(); tracing::debug!("received first stats"); assert_eq!(data.instance.id, rcv.state.instance_id); // assert_eq!(Some(ActivityId(0)), rcv.state.last_successful_id); // let last_id_before = rcv.state.last_successful_id.unwrap(); let counts = vec![15, 20, 35]; for count in counts { tracing::debug!("sending {} activities", count); let sent = try_join_all( (0..count).map(|_| send_activity(data.person.ap_id.clone(), &data.context, false)), ) .await?; sleep(2 * *WORK_FINISHED_RECHECK_DELAY).await; tracing::debug!("sent activity"); compare_sent_with_receive(data, sent).await?; } Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn test_update_instance(data: &mut Data) -> LemmyResult<()> { let form = InstanceForm::new(data.instance.domain.clone()); Instance::update(&mut data.context.pool(), data.instance.id, form).await?; send_activity(data.person.ap_id.clone(), &data.context, true).await?; data.inbox_receiver.recv().await.unwrap(); let instance = Instance::read_or_create(&mut data.context.pool(), &data.instance.domain).await?; assert!(instance.updated_at.is_some()); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn test_errors(data: &mut Data) -> LemmyResult<()> { let form = InstanceForm::new(data.instance.domain.clone()); Instance::update(&mut data.context.pool(), data.instance.id, form).await?; // check initial state let rcv = data.stats_receiver.recv().await.unwrap(); assert_eq!(0, rcv.state.fail_count); assert_eq!(data.instance.id, rcv.state.instance_id); // set receiver to return error for all inbox requests *data.respond_with_error.write().unwrap() = true; // send a few activities try_join_all((0..5).map(|_| send_activity(data.person.ap_id.clone(), &data.context, false))) .await?; // it immediately performs first retry giving us 2 failures wait_receive(2, &mut data.stats_receiver).await; // another automatic retry after short wait wait_receive(3, &mut data.stats_receiver).await; // now make sends successful *data.respond_with_error.write().unwrap() = false; // fail count goes back to 0 wait_receive(0, &mut data.stats_receiver).await; Ok(()) } async fn wait_receive( expected_fail_count: i32, rec: &mut UnboundedReceiver, ) { // loop until we get the latest event for _ in 0..5 { let rcv = rec.recv().await.unwrap(); if expected_fail_count == rcv.state.fail_count { return; } } panic!(); } fn listen_activities( inbox_sender: UnboundedSender, respond_with_error: Arc>, ) -> LemmyResult { let run = HttpServer::new(move || { App::new() .app_data(actix_web::web::Data::new(inbox_sender.clone())) .app_data(actix_web::web::Data::new(respond_with_error.clone())) .route( "/inbox", web::post().to( move |inbox_sender: actix_web::web::Data>, respond_with_error: actix_web::web::Data>>, body: String| async move { tracing::debug!("received activity: {:?}", body); inbox_sender.send(body.clone()).unwrap(); if *respond_with_error.read().unwrap() { HttpResponse::new(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) } else { HttpResponse::new(actix_web::http::StatusCode::OK) } }, ), ) }) .bind(("127.0.0.1", 8085))? .run(); let handle = run.handle(); tokio::spawn(async move { run.await.unwrap(); /*select! { _ = run => {}, _ = cancel.cancelled() => { } }*/ }); Ok(handle) } async fn send_activity( ap_id: DbUrl, context: &LemmyContext, wait: bool, ) -> LemmyResult { // create outgoing activity let id = format!( "http://ds9.lemmy.ml/activities/like/{}", uuid::Uuid::new_v4() ); let data = json!({ "actor": "http://ds9.lemmy.ml/u/lemmy_alpha", "object": "http://ds9.lemmy.ml/comment/1", "type": "Like", "id": id, }); let form = SentActivityForm { ap_id: Url::parse(&id)?.into(), data, sensitive: false, send_inboxes: vec![Some(Url::parse("http://localhost:8085/inbox")?.into())], send_all_instances: false, send_community_followers_of: None, actor_type: ActorType::Person, actor_apub_id: ap_id, }; let sent = SentActivity::create(&mut context.pool(), form).await?; if wait { sleep(*WORK_FINISHED_RECHECK_DELAY * 2).await; } Ok(sent) } async fn compare_sent_with_receive(data: &mut Data, mut sent: Vec) -> Result<()> { assert!(!sent.is_empty()); let check_order = !data.is_concurrent; // allow out-of order receiving when running parallel let mut received = Vec::new(); for _ in 0..sent.len() { let inbox_rcv = data.inbox_receiver.recv().await.unwrap(); let parsed_activity = serde_json::from_str::>(&inbox_rcv)?; received.push(parsed_activity); } if !check_order { // sort by id received.sort_by(|a, b| { a.inner()["id"] .as_str() .unwrap() .cmp(b.inner()["id"].as_str().unwrap()) }); sent.sort_by(|a, b| { a.data["id"] .as_str() .unwrap() .cmp(b.data["id"].as_str().unwrap()) }); } // receive for successfully sent activity for i in 0..sent.len() { let sent_activity = &sent[i]; let received_activity = received[i].inner(); assert_eq!(&sent_activity.data, received_activity); tracing::debug!("received activity"); } Ok(()) } } ================================================ FILE: crates/db_schema/Cargo.toml ================================================ [package] name = "lemmy_db_schema" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_db_schema" path = "src/lib.rs" doctest = false [lints] workspace = true [features] full = [ "lemmy_utils/full", "diesel", "diesel-derive-newtype", "bcrypt", "lemmy_utils", "serde_json", "diesel_ltree", "diesel-async", "diesel-uplete", "tokio", "i-love-jesus", "moka", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs"] [dependencies] chrono = { workspace = true } serde = { workspace = true } serde_with = { workspace = true } url = { workspace = true } strum = { workspace = true } serde_json = { workspace = true, optional = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } bcrypt = { workspace = true, optional = true } diesel = { workspace = true, optional = true } diesel-derive-newtype = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } diesel-uplete = { workspace = true, optional = true } diesel_ltree = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true } tokio = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } derive-new.workspace = true moka = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_schema/src/impls/activity.rs ================================================ use crate::{ diesel::OptionalExtension, newtypes::ActivityId, source::activity::{ReceivedActivity, SentActivity, SentActivityForm}, }; use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl SentActivity { pub async fn create(pool: &mut DbPool<'_>, form: SentActivityForm) -> LemmyResult { use lemmy_db_schema_file::schema::sent_activity::dsl::sent_activity; let conn = &mut get_conn(pool).await?; insert_into(sent_activity) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn read_from_apub_id(pool: &mut DbPool<'_>, object_id: &DbUrl) -> LemmyResult { use lemmy_db_schema_file::schema::sent_activity::dsl::{ap_id, sent_activity}; let conn = &mut get_conn(pool).await?; sent_activity .filter(ap_id.eq(object_id)) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read(pool: &mut DbPool<'_>, object_id: ActivityId) -> LemmyResult { use lemmy_db_schema_file::schema::sent_activity::dsl::sent_activity; let conn = &mut get_conn(pool).await?; sent_activity .find(object_id) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl ReceivedActivity { pub async fn create(pool: &mut DbPool<'_>, ap_id_: &DbUrl) -> LemmyResult<()> { use lemmy_db_schema_file::schema::received_activity::dsl::{ap_id, received_activity}; let conn = &mut get_conn(pool).await?; let rows_affected = insert_into(received_activity) .values(ap_id.eq(ap_id_)) .on_conflict_do_nothing() .execute(conn) .await .optional()?; if rows_affected == Some(1) { // new activity inserted successfully Ok(()) } else { Err(LemmyErrorType::CouldntCreate.into()) } } } #[cfg(test)] mod tests { use super::*; use lemmy_db_schema_file::enums::ActorType; use lemmy_diesel_utils::connection::build_db_pool_for_tests; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serde_json::json; use serial_test::serial; use url::Url; #[tokio::test] #[serial] async fn receive_activity_duplicate() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let ap_id: DbUrl = Url::parse("http://example.com/activity/531")?.into(); // inserting activity should only work once ReceivedActivity::create(pool, &ap_id).await?; let second = ReceivedActivity::create(pool, &ap_id).await; assert!(second.is_err()); Ok(()) } #[tokio::test] #[serial] async fn sent_activity_write_read() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let ap_id: DbUrl = Url::parse("http://example.com/activity/412")?.into(); let data = json!({ "key1": "0xF9BA143B95FF6D82", "key2": "42", }); let sensitive = false; let form = SentActivityForm { ap_id: ap_id.clone(), data: data.clone(), sensitive, actor_apub_id: Url::parse("http://example.com/u/exampleuser")?.into(), actor_type: ActorType::Person, send_all_instances: false, send_community_followers_of: None, send_inboxes: vec![], }; SentActivity::create(pool, form).await?; let res = SentActivity::read_from_apub_id(pool, &ap_id).await?; assert_eq!(res.ap_id, ap_id); assert_eq!(res.data, data); assert_eq!(res.sensitive, sensitive); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/actor_language.rs ================================================ use crate::{ diesel::JoinOnDsl, newtypes::{CommunityId, LanguageId, LocalUserId, SiteId}, source::{ actor_language::{ CommunityLanguage, CommunityLanguageForm, LocalUserLanguage, LocalUserLanguageForm, SiteLanguage, SiteLanguageForm, }, language::Language, site::Site, }, }; use diesel::{ ExpressionMethods, QueryDsl, delete, dsl::{count, exists}, insert_into, select, }; use diesel_async::{AsyncPgConnection, RunQueryDsl, scoped_futures::ScopedFutureExt}; use lemmy_db_schema_file::{ InstanceId, schema::{community_language, local_site, local_user_language, site, site_language}, }; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use tokio::sync::OnceCell; pub const UNDETERMINED_ID: LanguageId = LanguageId(0); impl LocalUserLanguage { pub async fn read( pool: &mut DbPool<'_>, for_local_user_id: LocalUserId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let langs = local_user_language::table .filter(local_user_language::local_user_id.eq(for_local_user_id)) .order(local_user_language::language_id) .select(local_user_language::language_id) .get_results(conn) .await?; convert_read_languages(conn, langs).await } /// Update the user's languages. /// /// If no language_id vector is given, it will show all languages pub async fn update( pool: &mut DbPool<'_>, language_ids: Vec, for_local_user_id: LocalUserId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let lang_ids = convert_update_languages(conn, language_ids).await?; // No need to update if languages are unchanged let current = LocalUserLanguage::read(&mut conn.into(), for_local_user_id).await?; if current == lang_ids { return Ok(0); } conn .run_transaction(|conn| { async move { // Delete old languages, not including new languages delete(local_user_language::table) .filter(local_user_language::local_user_id.eq(for_local_user_id)) .filter(local_user_language::language_id.ne_all(&lang_ids)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; let forms = lang_ids .iter() .map(|&l| LocalUserLanguageForm { local_user_id: for_local_user_id, language_id: l, }) .collect::>(); // Insert new languages insert_into(local_user_language::table) .values(forms) .on_conflict(( local_user_language::language_id, local_user_language::local_user_id, )) .do_nothing() .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } .scope_boxed() }) .await } } impl SiteLanguage { pub async fn read_local_raw(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; site::table .inner_join(local_site::table) .inner_join(site_language::table) .order(site_language::language_id) .select(site_language::language_id) .load(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read(pool: &mut DbPool<'_>, for_site_id: SiteId) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let langs = site_language::table .filter(site_language::site_id.eq(for_site_id)) .order(site_language::language_id) .select(site_language::language_id) .load(conn) .await?; convert_read_languages(conn, langs).await } pub async fn update( pool: &mut DbPool<'_>, language_ids: Vec, site: &Site, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let for_site_id = site.id; let instance_id = site.instance_id; let lang_ids = convert_update_languages(conn, language_ids).await?; // No need to update if languages are unchanged let current = SiteLanguage::read(&mut conn.into(), site.id).await?; if current == lang_ids { return Ok(()); } conn .run_transaction(|conn| { async move { // Delete old languages, not including new languages delete(site_language::table) .filter(site_language::site_id.eq(for_site_id)) .filter(site_language::language_id.ne_all(&lang_ids)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; let forms = lang_ids .iter() .map(|&l| SiteLanguageForm { site_id: for_site_id, language_id: l, }) .collect::>(); // Insert new languages insert_into(site_language::table) .values(forms) .on_conflict((site_language::site_id, site_language::language_id)) .do_nothing() .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; CommunityLanguage::limit_languages(conn, instance_id).await?; Ok(()) } .scope_boxed() }) .await } } impl CommunityLanguage { /// Returns true if the given language is one of configured languages for given community async fn is_allowed_community_language( pool: &mut DbPool<'_>, for_language_id: LanguageId, for_community_id: CommunityId, ) -> LemmyResult<()> { use lemmy_db_schema_file::schema::community_language::dsl::community_language; let conn = &mut get_conn(pool).await?; let is_allowed = select(exists( community_language.find((for_community_id, for_language_id)), )) .get_result(conn) .await?; if is_allowed { Ok(()) } else { Err(LemmyErrorType::LanguageNotAllowed.into()) } } /// When site languages are updated, delete all languages of local communities which are not /// also part of site languages. This is because post/comment language is only checked against /// community language, and it shouldnt be possible to post content in languages which are not /// allowed by local site. async fn limit_languages( conn: &mut AsyncPgConnection, for_instance_id: InstanceId, ) -> LemmyResult<()> { use lemmy_db_schema_file::schema::{ community::dsl as c, community_language::dsl as cl, site_language::dsl as sl, }; let community_languages: Vec = cl::community_language .left_outer_join(sl::site_language.on(cl::language_id.eq(sl::language_id))) .inner_join(c::community) .filter(c::instance_id.eq(for_instance_id)) .filter(sl::language_id.is_null()) .select(cl::language_id) .get_results(conn) .await?; for c in community_languages { delete(cl::community_language.filter(cl::language_id.eq(c))) .execute(conn) .await?; } Ok(()) } pub async fn read( pool: &mut DbPool<'_>, for_community_id: CommunityId, ) -> LemmyResult> { use lemmy_db_schema_file::schema::community_language::dsl::{ community_id, community_language, language_id, }; let conn = &mut get_conn(pool).await?; let langs = community_language .filter(community_id.eq(for_community_id)) .order(language_id) .select(language_id) .get_results(conn) .await?; convert_read_languages(conn, langs).await } pub async fn update( pool: &mut DbPool<'_>, mut language_ids: Vec, for_community_id: CommunityId, ) -> LemmyResult { if language_ids.is_empty() { language_ids = SiteLanguage::read_local_raw(pool).await?; } let conn = &mut get_conn(pool).await?; let lang_ids = convert_update_languages(conn, language_ids).await?; // No need to update if languages are unchanged let current = CommunityLanguage::read(&mut conn.into(), for_community_id).await?; if current == lang_ids { return Ok(0); } let form = lang_ids .iter() .map(|&language_id| CommunityLanguageForm { community_id: for_community_id, language_id, }) .collect::>(); conn .run_transaction(|conn| { async move { // Delete old languages, not including new languages delete(community_language::table) .filter(community_language::community_id.eq(for_community_id)) .filter(community_language::language_id.ne_all(&lang_ids)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; // Insert new languages insert_into(community_language::table) .values(form) .on_conflict(( community_language::community_id, community_language::language_id, )) .do_nothing() .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } .scope_boxed() }) .await } } pub async fn validate_post_language( pool: &mut DbPool<'_>, language_id: Option, community_id: CommunityId, ) -> LemmyResult<()> { if let Some(language_id) = language_id { CommunityLanguage::is_allowed_community_language(pool, language_id, community_id).await?; } Ok(()) } /// If no language is given, set all languages async fn convert_update_languages( conn: &mut AsyncPgConnection, language_ids: Vec, ) -> LemmyResult> { if language_ids.is_empty() { Ok( Language::read_all(&mut conn.into()) .await? .into_iter() .map(|l| l.id) .collect(), ) } else { Ok(language_ids) } } /// If all languages are returned, return empty vec instead #[expect(clippy::expect_used)] async fn convert_read_languages( conn: &mut AsyncPgConnection, language_ids: Vec, ) -> LemmyResult> { static ALL_LANGUAGES_COUNT: OnceCell = OnceCell::const_new(); let count: usize = (*ALL_LANGUAGES_COUNT .get_or_init(|| async { use lemmy_db_schema_file::schema::language::dsl::{id, language}; let count: i64 = language .select(count(id)) .first(conn) .await .expect("read number of languages"); count }) .await) .try_into()?; if language_ids.len() == count { Ok(vec![]) } else { Ok(language_ids) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use crate::{ source::{ community::{Community, CommunityInsertForm}, local_site::LocalSite, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, }, test_data::TestData, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use pretty_assertions::assert_eq; use serial_test::serial; async fn test_langs1(pool: &mut DbPool<'_>) -> LemmyResult> { Ok(vec![ Language::read_id_from_code(pool, "en").await?, Language::read_id_from_code(pool, "fr").await?, Language::read_id_from_code(pool, "ru").await?, ]) } async fn test_langs2(pool: &mut DbPool<'_>) -> LemmyResult> { Ok(vec![ Language::read_id_from_code(pool, "fi").await?, Language::read_id_from_code(pool, "se").await?, ]) } #[tokio::test] #[serial] async fn test_convert_update_languages() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); // call with empty vec, returns all languages let conn = &mut get_conn(pool).await?; let converted1 = convert_update_languages(conn, vec![]).await?; assert_eq!(184, converted1.len()); // call with nonempty vec, returns same vec let test_langs = test_langs1(&mut conn.into()).await?; let converted2 = convert_update_languages(conn, test_langs.clone()).await?; assert_eq!(test_langs, converted2); Ok(()) } #[tokio::test] #[serial] async fn test_convert_read_languages() -> LemmyResult<()> { use lemmy_db_schema_file::schema::language::dsl::{id, language}; let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); // call with all languages, returns empty vec let conn = &mut get_conn(pool).await?; let all_langs = language.select(id).get_results(conn).await?; let converted1: Vec = convert_read_languages(conn, all_langs).await?; assert_eq!(0, converted1.len()); // call with nonempty vec, returns same vec let test_langs = test_langs1(&mut conn.into()).await?; let converted2 = convert_read_languages(conn, test_langs.clone()).await?; assert_eq!(test_langs, converted2); Ok(()) } #[tokio::test] #[serial] async fn test_site_languages() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let site_languages1 = SiteLanguage::read_local_raw(pool).await?; // site is created with all languages assert_eq!(184, site_languages1.len()); let test_langs = test_langs1(pool).await?; SiteLanguage::update(pool, test_langs.clone(), &data.site).await?; let site_languages2 = SiteLanguage::read_local_raw(pool).await?; // after update, site only has new languages assert_eq!(test_langs, site_languages2); data.delete(pool).await?; Ok(()) } #[tokio::test] #[serial] async fn test_user_languages() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let person_form = PersonInsertForm::test_form(data.instance.id, "my test person"); let person = Person::create(pool, &person_form).await?; let local_user_form = LocalUserInsertForm::test_form(person.id); let local_user = LocalUser::create(pool, &local_user_form, vec![]).await?; let local_user_langs1 = LocalUserLanguage::read(pool, local_user.id).await?; // new user should be initialized with all languages assert_eq!(0, local_user_langs1.len()); // update user languages let test_langs2 = test_langs2(pool).await?; LocalUserLanguage::update(pool, test_langs2, local_user.id).await?; let local_user_langs2 = LocalUserLanguage::read(pool, local_user.id).await?; assert_eq!(2, local_user_langs2.len()); Person::delete(pool, person.id).await?; LocalUser::delete(pool, local_user.id).await?; LocalSite::delete(pool).await?; data.delete(pool).await?; Ok(()) } #[tokio::test] #[serial] async fn test_community_languages() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let test_langs = test_langs1(pool).await?; SiteLanguage::update(pool, test_langs.clone(), &data.site).await?; let read_site_langs = SiteLanguage::read(pool, data.site.id).await?; assert_eq!(test_langs, read_site_langs); // Test the local ones are the same let read_local_site_langs = SiteLanguage::read_local_raw(pool).await?; assert_eq!(test_langs, read_local_site_langs); let community_form = CommunityInsertForm::new( data.instance.id, "test community".to_string(), "test community".to_string(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let community_langs1 = CommunityLanguage::read(pool, community.id).await?; // community is initialized with site languages assert_eq!(test_langs, community_langs1); let allowed_lang1 = CommunityLanguage::is_allowed_community_language(pool, test_langs[0], community.id).await; assert!(allowed_lang1.is_ok()); let test_langs2 = test_langs2(pool).await?; let allowed_lang2 = CommunityLanguage::is_allowed_community_language(pool, test_langs2[0], community.id).await; assert!(allowed_lang2.is_err()); // limit site languages to en, fi. after this, community languages should be updated to // intersection of old languages (en, fr, ru) and (en, fi), which is only fi. SiteLanguage::update(pool, vec![test_langs[0], test_langs2[0]], &data.site).await?; let community_langs2 = CommunityLanguage::read(pool, community.id).await?; assert_eq!(vec![test_langs[0]], community_langs2); // update community languages to different ones CommunityLanguage::update(pool, test_langs2.clone(), community.id).await?; let community_langs3 = CommunityLanguage::read(pool, community.id).await?; assert_eq!(test_langs2, community_langs3); Community::delete(pool, community.id).await?; LocalSite::delete(pool).await?; data.delete(pool).await?; Ok(()) } #[tokio::test] #[serial] async fn test_validate_post_language() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let test_langs = test_langs1(pool).await?; let test_langs2 = test_langs2(pool).await?; let community_form = CommunityInsertForm::new( data.instance.id, "test community".to_string(), "test community".to_string(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; CommunityLanguage::update(pool, test_langs, community.id).await?; let person_form = PersonInsertForm::test_form(data.instance.id, "my test person"); let person = Person::create(pool, &person_form).await?; let local_user_form = LocalUserInsertForm::test_form(person.id); let local_user = LocalUser::create(pool, &local_user_form, vec![]).await?; LocalUserLanguage::update(pool, test_langs2, local_user.id).await?; let def1 = validate_post_language(pool, Some(LanguageId(2)), community.id).await; assert_eq!( Some(LemmyErrorType::LanguageNotAllowed), def1.err().map(|e| e.error_type) ); let ru = Language::read_id_from_code(pool, "ru").await?; let test_langs3 = vec![ ru, Language::read_id_from_code(pool, "fi").await?, Language::read_id_from_code(pool, "se").await?, UNDETERMINED_ID, ]; LocalUserLanguage::update(pool, test_langs3, local_user.id).await?; let def2 = validate_post_language(pool, None, community.id).await; assert!(def2.is_ok()); Person::delete(pool, person.id).await?; Community::delete(pool, community.id).await?; LocalUser::delete(pool, local_user.id).await?; LocalSite::delete(pool).await?; data.delete(pool).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/comment.rs ================================================ use crate::{ diesel::{DecoratableTarget, OptionalExtension}, newtypes::{CommentId, CommunityId, PostId}, source::comment::{ Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentSavedForm, CommentUpdateForm, }, traits::{Likeable, Saveable}, utils::DELETED_REPLACEMENT_TEXT, }; use chrono::{DateTime, Utc}; use diesel::{ ExpressionMethods, JoinOnDsl, QueryDsl, dsl::{insert_into, not}, expression::SelectableHelper, update, }; use diesel_async::RunQueryDsl; use diesel_ltree::{Ltree, dsl::LtreeExtensions}; use diesel_uplete::{UpleteCount, uplete}; use lemmy_db_schema_file::{ InstanceId, PersonId, schema::{comment, comment_actions, community, post}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::functions::{coalesce, hot_rank}, }; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError}, settings::structs::Settings, }; use url::Url; impl Comment { pub async fn permadelete_for_creator( pool: &mut DbPool<'_>, creator_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(comment::table.filter(comment::creator_id.eq(creator_id))) .set(( comment::content.eq(DELETED_REPLACEMENT_TEXT), comment::deleted.eq(true), comment::updated_at.eq(Utc::now()), )) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn update_removed_for_creator( pool: &mut DbPool<'_>, creator_id: PersonId, removed: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(comment::table.filter(comment::creator_id.eq(creator_id))) .set(( comment::removed.eq(removed), comment::updated_at.eq(Utc::now()), )) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } /// Diesel can't update from join unfortunately, so you'll need to loop over these async fn creator_comments_in_community( pool: &mut DbPool<'_>, creator_id: PersonId, community_id: CommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; comment::table .inner_join(post::table) .filter(comment::creator_id.eq(creator_id)) .filter(post::community_id.eq(community_id)) .select(Self::as_select()) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Diesel can't update from join unfortunately, so you'll need to loop over these async fn creator_comments_in_instance( pool: &mut DbPool<'_>, creator_id: PersonId, instance_id: InstanceId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let community_join = community::table.on(post::community_id.eq(community::id)); comment::table .inner_join(post::table) .inner_join(community_join) .filter(comment::creator_id.eq(creator_id)) .filter(community::instance_id.eq(instance_id)) .select(Self::as_select()) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update_removed_for_creator_and_community( pool: &mut DbPool<'_>, creator_id: PersonId, community_id: CommunityId, removed: bool, ) -> LemmyResult> { let comments = Self::creator_comments_in_community(pool, creator_id, community_id).await?; let comment_ids: Vec<_> = comments.iter().map(|c| c.id).collect(); let conn = &mut get_conn(pool).await?; update(comment::table) .filter(comment::id.eq_any(comment_ids)) .set(( comment::removed.eq(removed), comment::updated_at.eq(Utc::now()), )) .execute(conn) .await?; Ok(comments) } pub async fn update_removed_for_creator_and_instance( pool: &mut DbPool<'_>, creator_id: PersonId, instance_id: InstanceId, removed: bool, ) -> LemmyResult> { let comments = Self::creator_comments_in_instance(pool, creator_id, instance_id).await?; let comment_ids: Vec<_> = comments.iter().map(|c| c.id).collect(); let conn = &mut get_conn(pool).await?; update(comment::table) .filter(comment::id.eq_any(comment_ids)) .set(( comment::removed.eq(removed), comment::updated_at.eq(Utc::now()), )) .execute(conn) .await?; Ok(comments) } #[expect(clippy::same_name_method)] pub async fn create( pool: &mut DbPool<'_>, comment_form: &CommentInsertForm, parent_path: Option<&Ltree>, ) -> LemmyResult { Self::insert_apub(pool, None, comment_form, parent_path).await } pub async fn insert_apub( pool: &mut DbPool<'_>, timestamp: Option>, comment_form: &CommentInsertForm, parent_path: Option<&Ltree>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let comment_form = (comment_form, parent_path.map(|p| comment::path.eq(p))); if let Some(timestamp) = timestamp { insert_into(comment::table) .values(comment_form) .on_conflict(comment::ap_id) .filter_target(coalesce(comment::updated_at, comment::published_at).lt(timestamp)) .do_update() .set(comment_form) .get_result::(conn) .await } else { insert_into(comment::table) .values(comment_form) .get_result::(conn) .await } .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; comment::table .filter(comment::ap_id.eq(object_id)) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } pub fn parent_comment_id(&self) -> Option { let mut ltree_split: Vec<&str> = self.path.0.split('.').collect(); ltree_split.remove(0); // The first is always 0 if ltree_split.len() > 1 { let parent_comment_id = ltree_split.get(ltree_split.len() - 2); let p = parent_comment_id?; p.parse::().map(CommentId).ok() } else { None } } pub async fn update_hot_rank(pool: &mut DbPool<'_>, comment_id: CommentId) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(comment::table.find(comment_id)) .set(comment::hot_rank.eq(hot_rank(comment::score, comment::published_at))) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub fn local_url(&self, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/comment/{}", self.id))?) } /// The comment was created locally and sent back, indicating that the community accepted it pub async fn set_not_pending(&self, pool: &mut DbPool<'_>) -> LemmyResult<()> { if self.local && self.federation_pending { let form = CommentUpdateForm { federation_pending: Some(false), ..Default::default() }; Comment::update(pool, self.id, &form).await?; } Ok(()) } /// Updates the locked field for a comment and all its children. pub async fn update_locked_for_comment_and_children( pool: &mut DbPool<'_>, comment_path: &Ltree, locked: bool, ) -> LemmyResult> { let form = CommentUpdateForm { locked: Some(locked), ..Default::default() }; Self::update_comment_and_children(pool, comment_path, &form).await } /// Updates the removed field for a comment and all its children. pub async fn update_removed_for_comment_and_children( pool: &mut DbPool<'_>, comment_path: &Ltree, removed: bool, ) -> LemmyResult> { let form = CommentUpdateForm { removed: Some(removed), ..Default::default() }; Self::update_comment_and_children(pool, comment_path, &form).await } /// A helper function to update comment and all its children. /// /// Don't expose so as to make sure you aren't overwriting data. async fn update_comment_and_children( pool: &mut DbPool<'_>, comment_path: &Ltree, form: &CommentUpdateForm, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(comment::table) .filter(comment::path.contained_by(comment_path)) .set(form) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } /// Update the remove field for all the comments under a post. pub async fn update_removed_for_post( pool: &mut DbPool<'_>, post_id: PostId, removed: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(comment::table) .filter(comment::post_id.eq(post_id)) .set(( comment::removed.eq(removed), comment::updated_at.eq(Utc::now()), )) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn read_ap_ids_for_post( post_id: PostId, pool: &mut DbPool<'_>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; comment::table .filter(comment::post_id.eq(post_id)) .filter(not(comment::deleted)) .filter(not(comment::removed)) .filter(not(comment::federation_pending)) .order_by(comment::id) .select(comment::ap_id) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl Crud for Comment { type InsertForm = CommentInsertForm; type UpdateForm = CommentUpdateForm; type IdType = CommentId; /// Use [[Comment::create]] async fn create(_pool: &mut DbPool<'_>, _comment_form: &Self::InsertForm) -> LemmyResult { Err(UntranslatedError::Unreachable.into()) } async fn update( pool: &mut DbPool<'_>, comment_id: CommentId, comment_form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(comment::table.find(comment_id)) .set(comment_form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Likeable for CommentActions { type Form = CommentLikeForm; type IdType = CommentId; async fn like(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(comment_actions::table) .values(form) .on_conflict((comment_actions::comment_id, comment_actions::person_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn remove_all_likes( pool: &mut DbPool<'_>, creator_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(comment_actions::table.filter(comment_actions::person_id.eq(creator_id))) .set_null(comment_actions::vote_is_upvote) .set_null(comment_actions::voted_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn remove_likes_in_community( pool: &mut DbPool<'_>, creator_id: PersonId, community_id: CommunityId, ) -> LemmyResult { let comments = Comment::creator_comments_in_community(pool, creator_id, community_id).await?; let comment_ids: Vec<_> = comments.iter().map(|c| c.id).collect(); let conn = &mut get_conn(pool).await?; uplete(comment_actions::table.filter(comment_actions::comment_id.eq_any(comment_ids.clone()))) .set_null(comment_actions::vote_is_upvote) .set_null(comment_actions::voted_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Saveable for CommentActions { type Form = CommentSavedForm; async fn save(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(comment_actions::table) .values(form) .on_conflict((comment_actions::comment_id, comment_actions::person_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn unsave(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(comment_actions::table.find((form.person_id, form.comment_id))) .set_null(comment_actions::saved_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl CommentActions { pub async fn read( pool: &mut DbPool<'_>, comment_id: CommentId, person_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; comment_actions::table .find((person_id, comment_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } #[cfg(test)] mod tests { use super::*; use crate::{ newtypes::LanguageId, source::{ community::{Community, CommunityInsertForm}, instance::Instance, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, }, traits::{Likeable, Saveable}, utils::RANK_DEFAULT, }; use diesel_ltree::Ltree; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; use url::Url; #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "terry"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "test community".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; let expected_comment = Comment { id: inserted_comment.id, content: "A test comment".into(), creator_id: inserted_person.id, post_id: inserted_post.id, removed: false, deleted: false, path: Ltree(format!("0.{}", inserted_comment.id)), published_at: inserted_comment.published_at, updated_at: None, ap_id: Url::parse(&format!( "https://lemmy-alpha/comment/{}", inserted_comment.id ))? .into(), distinguished: false, local: true, language_id: LanguageId::default(), child_count: 1, controversy_rank: 0.0, downvotes: 0, upvotes: 1, score: 1, hot_rank: RANK_DEFAULT, report_count: 0, unresolved_report_count: 0, federation_pending: false, locked: false, }; let child_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A child comment".into(), ); let inserted_child_comment = Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?; // Comment Like let comment_like_form = CommentLikeForm::new(inserted_comment.id, inserted_person.id, Some(true)); let inserted_comment_like = CommentActions::like(pool, &comment_like_form).await?; assert_eq!(Some(true), inserted_comment_like.vote_is_upvote); // Comment Saved let comment_saved_form = CommentSavedForm::new(inserted_person.id, inserted_comment.id); let inserted_comment_saved = CommentActions::save(pool, &comment_saved_form).await?; assert!(inserted_comment_saved.saved_at.is_some()); let comment_update_form = CommentUpdateForm { content: Some("A test comment".into()), ..Default::default() }; let updated_comment = Comment::update(pool, inserted_comment.id, &comment_update_form).await?; let read_comment = Comment::read(pool, inserted_comment.id).await?; let form = CommentLikeForm::new(inserted_comment.id, inserted_person.id, None); CommentActions::like(pool, &form).await?; let saved_removed = CommentActions::unsave(pool, &comment_saved_form).await?; let num_deleted = Comment::delete(pool, inserted_comment.id).await?; Comment::delete(pool, inserted_child_comment.id).await?; Post::delete(pool, inserted_post.id).await?; Community::delete(pool, inserted_community.id).await?; Person::delete(pool, inserted_person.id).await?; Instance::delete(pool, inserted_instance.id).await?; assert_eq!(expected_comment, read_comment); assert_eq!(expected_comment, updated_comment); assert_eq!( format!("0.{}.{}", expected_comment.id, inserted_child_comment.id), inserted_child_comment.path.0, ); assert_eq!(UpleteCount::only_deleted(1), saved_removed); assert_eq!(1, num_deleted); Ok(()) } #[tokio::test] #[serial] async fn test_aggregates() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "thommy_comment_agg"); let inserted_person = Person::create(pool, &new_person).await?; let another_person = PersonInsertForm::test_form(inserted_instance.id, "jerry_comment_agg"); let another_inserted_person = Person::create(pool, &another_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "TIL_comment_agg".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; let child_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let _inserted_child_comment = Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?; let comment_like = CommentLikeForm::new(inserted_comment.id, inserted_person.id, Some(true)); CommentActions::like(pool, &comment_like).await?; let comment_aggs_before_delete = Comment::read(pool, inserted_comment.id).await?; assert_eq!(1, comment_aggs_before_delete.score); assert_eq!(1, comment_aggs_before_delete.upvotes); assert_eq!(0, comment_aggs_before_delete.downvotes); // Add a post dislike from the other person let comment_dislike = CommentLikeForm::new(inserted_comment.id, another_inserted_person.id, Some(false)); CommentActions::like(pool, &comment_dislike).await?; let comment_aggs_after_dislike = Comment::read(pool, inserted_comment.id).await?; assert_eq!(0, comment_aggs_after_dislike.score); assert_eq!(1, comment_aggs_after_dislike.upvotes); assert_eq!(1, comment_aggs_after_dislike.downvotes); // Remove the first comment like let form = CommentLikeForm::new(inserted_comment.id, inserted_person.id, None); CommentActions::like(pool, &form).await?; let after_like_remove = Comment::read(pool, inserted_comment.id).await?; assert_eq!(-1, after_like_remove.score); assert_eq!(0, after_like_remove.upvotes); assert_eq!(1, after_like_remove.downvotes); // Remove the parent post Post::delete(pool, inserted_post.id).await?; // Should be none found, since the post was deleted let after_delete = Comment::read(pool, inserted_comment.id).await; assert!(after_delete.is_err()); // This should delete all the associated rows, and fire triggers Person::delete(pool, another_inserted_person.id).await?; let person_num_deleted = Person::delete(pool, inserted_person.id).await?; assert_eq!(1, person_num_deleted); // Delete the community let community_num_deleted = Community::delete(pool, inserted_community.id).await?; assert_eq!(1, community_num_deleted); Instance::delete(pool, inserted_instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_update_children() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "mydomain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "john"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "test".into(), "test".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "Post Title".to_string(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let parent_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "Top level".to_string(), ); let inserted_parent_comment = Comment::create(pool, &parent_comment_form, None).await?; let child_comment_form = CommentInsertForm::new(inserted_person.id, inserted_post.id, "Child".to_string()); let inserted_child_comment = Comment::create( pool, &child_comment_form, Some(&inserted_parent_comment.path), ) .await?; let grandchild_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "Grandchild".to_string(), ); let _inserted_grandchild_comment = Comment::create( pool, &grandchild_comment_form, Some(&inserted_child_comment.path), ) .await?; let lock_form = CommentUpdateForm { locked: Some(true), ..Default::default() }; let updated_comments = Comment::update_comment_and_children(pool, &inserted_parent_comment.path, &lock_form).await?; let locked_comments_num = updated_comments.iter().filter(|c| c.locked).count(); assert_eq!(3, locked_comments_num); Ok(()) } #[tokio::test] #[serial] async fn test_remove_post_children() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "mydomain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "sharah"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "test".into(), "test".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "Post Title".to_string(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_toplevel1_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "Top level".to_string(), ); let inserted_comment_toplevel1 = Comment::create(pool, &comment_toplevel1_form, None).await?; let child_comment_form = CommentInsertForm::new(inserted_person.id, inserted_post.id, "Child".to_string()); let _inserted_child_comment = Comment::create( pool, &child_comment_form, Some(&inserted_comment_toplevel1.path), ) .await?; let comment_toplevel2_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "Top level 2".to_string(), ); let _inserted_comment_toplevel2 = Comment::create(pool, &comment_toplevel2_form, None).await?; let updated_comments = Comment::update_removed_for_post(pool, inserted_post.id, true).await?; let updated_comments_num = updated_comments.iter().filter(|c| c.removed).count(); assert_eq!(updated_comments_num, 3); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/comment_report.rs ================================================ use crate::{ newtypes::{CommentId, CommentReportId, PostId}, source::comment_report::{CommentReport, CommentReportForm}, traits::Reportable, }; use chrono::Utc; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, dsl::{insert_into, update}, }; use diesel_async::RunQueryDsl; use diesel_ltree::{Ltree, LtreeExtensions}; use lemmy_db_schema_file::{ PersonId, schema::{comment, comment_report}, }; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Reportable for CommentReport { type Form = CommentReportForm; type IdType = CommentReportId; type ObjectIdType = CommentId; /// creates a comment report and returns it /// /// * `conn` - the postgres connection /// * `comment_report_form` - the filled CommentReportForm to insert async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(comment_report::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } /// resolve a comment report /// /// * `conn` - the postgres connection /// * `report_id` - the id of the report to resolve /// * `by_resolver_id` - the id of the user resolving the report async fn update_resolved( pool: &mut DbPool<'_>, report_id_: Self::IdType, by_resolver_id: PersonId, is_resolved: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(comment_report::table.find(report_id_)) .set(( comment_report::resolved.eq(is_resolved), comment_report::resolver_id.eq(by_resolver_id), comment_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_apub( pool: &mut DbPool<'_>, object_id: Self::ObjectIdType, report_creator_id: PersonId, resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update( comment_report::table.filter( comment_report::comment_id .eq(object_id) .and(comment_report::creator_id.eq(report_creator_id)), ), ) .set(( comment_report::resolved.eq(true), comment_report::resolver_id.eq(resolver_id), comment_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_all_for_object( pool: &mut DbPool<'_>, comment_id_: CommentId, by_resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(comment_report::table.filter(comment_report::comment_id.eq(comment_id_))) .set(( comment_report::resolved.eq(true), comment_report::resolver_id.eq(by_resolver_id), comment_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl CommentReport { pub async fn resolve_all_for_thread( pool: &mut DbPool<'_>, comment_path: &Ltree, by_resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let report_alias = diesel::alias!(comment_report as cr); let report_subquery = report_alias .inner_join(comment::table.on(comment::id.eq(report_alias.field(comment_report::comment_id)))) .filter(comment::path.contained_by(comment_path)); update(comment_report::table.filter( comment_report::id.eq_any(report_subquery.select(report_alias.field(comment_report::id))), )) .set(( comment_report::resolved.eq(true), comment_report::resolver_id.eq(by_resolver_id), comment_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn resolve_all_for_post( pool: &mut DbPool<'_>, post_id: PostId, by_resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let report_alias = diesel::alias!(comment_report as cr); let report_subquery = report_alias .inner_join(comment::table.on(comment::id.eq(report_alias.field(comment_report::comment_id)))) .filter(comment::post_id.eq(post_id)); update(comment_report::table.filter( comment_report::id.eq_any(report_subquery.select(report_alias.field(comment_report::id))), )) .set(( comment_report::resolved.eq(true), comment_report::resolver_id.eq(by_resolver_id), comment_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } ================================================ FILE: crates/db_schema/src/impls/community.rs ================================================ use crate::{ diesel::{DecoratableTarget, JoinOnDsl, OptionalExtension}, newtypes::CommunityId, source::{ actor_language::CommunityLanguage, community::{ Community, CommunityActions, CommunityBlockForm, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, CommunityPersonBanForm, CommunityUpdateForm, }, post::Post, }, traits::{ApubActor, Bannable, Blockable, Followable}, utils::format_actor_url, }; use chrono::{DateTime, Utc}; use diesel::{ BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, QueryDsl, dsl::{exists, insert_into, not}, expression::SelectableHelper, select, update, }; use diesel_async::RunQueryDsl; use diesel_uplete::{UpleteCount, uplete}; use lemmy_db_schema_file::{ PersonId, enums::{CommunityFollowerState, CommunityNotificationsMode, CommunityVisibility, ListingType}, schema::{comment, community, community_actions, instance, local_user, post}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::functions::{coalesce, coalesce_2_nullable, lower, random_smallint}, }; use lemmy_utils::{ CACHE_DURATION_LARGEST_COMMUNITY, error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError}, settings::structs::Settings, }; use moka::future::Cache; use std::sync::{Arc, LazyLock}; use url::Url; impl Crud for Community { type InsertForm = CommunityInsertForm; type UpdateForm = CommunityUpdateForm; type IdType = CommunityId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; let community_ = insert_into(community::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate)?; // Initialize languages for new community CommunityLanguage::update(pool, vec![], community_.id).await?; Ok(community_) } async fn update( pool: &mut DbPool<'_>, community_id: CommunityId, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(community::table.find(community_id)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl CommunityActions { pub async fn join(pool: &mut DbPool<'_>, form: &CommunityModeratorForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(community_actions::table) .values(form) .on_conflict(( community_actions::person_id, community_actions::community_id, )) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } pub async fn leave( pool: &mut DbPool<'_>, form: &CommunityModeratorForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(community_actions::table.find((form.person_id, form.community_id))) .set_null(community_actions::became_moderator_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } } #[derive(Debug)] pub enum CollectionType { Moderators, Featured, } impl Community { pub async fn insert_apub( pool: &mut DbPool<'_>, timestamp: DateTime, form: &CommunityInsertForm, ) -> LemmyResult { let is_new_community = match &form.ap_id { Some(id) => Community::read_from_apub_id(pool, id).await?.is_none(), None => true, }; let conn = &mut get_conn(pool).await?; // Can't do separate insert/update commands because InsertForm/UpdateForm aren't convertible let community_ = insert_into(community::table) .values(form) .on_conflict(community::ap_id) .filter_target(coalesce(community::updated_at, community::published_at).lt(timestamp)) .do_update() .set(form) .get_result::(conn) .await?; // Initialize languages for new community if is_new_community { CommunityLanguage::update(pool, vec![], community_.id).await?; } Ok(community_) } /// Get the community which has a given moderators or featured url, also return the collection /// type pub async fn get_by_collection_url( pool: &mut DbPool<'_>, url: &DbUrl, ) -> LemmyResult<(Community, CollectionType)> { let conn = &mut get_conn(pool).await?; let res = community::table .filter(community::moderators_url.eq(url)) .first(conn) .await; if let Ok(c) = res { Ok((c, CollectionType::Moderators)) } else { let res = community::table .filter(community::featured_url.eq(url)) .first(conn) .await; if let Ok(c) = res { Ok((c, CollectionType::Featured)) } else { Err(LemmyErrorType::NotFound.into()) } } } pub async fn set_featured_posts( community_id: CommunityId, posts: Vec, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; for p in &posts { debug_assert!(p.community_id == community_id); } // Mark the given posts as featured and all other posts as not featured. let post_ids = posts.iter().map(|p| p.id); update(post::table) .filter(post::community_id.eq(community_id)) // This filter is just for performance .filter(post::featured_community.or(post::id.eq_any(post_ids.clone()))) .set(post::featured_community.eq(post::id.eq_any(post_ids))) .execute(conn) .await?; Ok(()) } pub async fn get_random_community_id( pool: &mut DbPool<'_>, type_: &Option, show_nsfw: Option, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; // This is based on the random page selection algorithm in MediaWiki. It assigns a random number // X to each item. To pick a random one, it generates a random number Y and gets the item with // the lowest X value where X >= Y. // // https://phabricator.wikimedia.org/source/mediawiki/browse/master/includes/specials/SpecialRandomPage.php;763c5f084101676ab1bc52862e1ffbd24585a365 // // The difference is we also regenerate the item's assigned number when the item is picked. // Without this, items would have permanent variations in the probability of being picked. // Additionally, in each group of multiple items that are assigned the same random number (a // more likely occurence with `smallint`), there would be only one item that ever gets // picked. let try_pick = || { let mut query = community::table .filter(not( community::deleted .or(community::removed) .or(community::visibility.eq(CommunityVisibility::Private)), )) .order(community::random_number.asc()) .select(community::id) .into_boxed(); if let Some(ListingType::Local) = type_ { query = query.filter(community::local); } if !show_nsfw.unwrap_or(false) { query = query.filter(not(community::nsfw)); } query }; diesel::update(community::table) .filter( community::id.nullable().eq(coalesce_2_nullable( try_pick() .filter(community::random_number.nullable().ge( // Without `select` and `single_value`, this would call `random_smallint` separately // for each row select(random_smallint()).single_value(), )) .single_value(), // Wrap to the beginning if the generated number is higher than all // `community::random_number` values, just like in the MediaWiki algorithm try_pick().single_value(), )), ) .set(community::random_number.eq(random_smallint())) .returning(community::id) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } #[diesel::dsl::auto_type(no_type_alias)] pub fn hide_removed_and_deleted() -> _ { community::removed .eq(false) .and(community::deleted.eq(false)) } pub async fn update_federated_followers( pool: &mut DbPool<'_>, for_community_id: CommunityId, new_subscribers: i32, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(community::table.find(for_community_id)) .set(community::dsl::subscribers.eq(new_subscribers)) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl CommunityActions { pub async fn read( pool: &mut DbPool<'_>, community_id: CommunityId, person_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; community_actions::table .find((person_id, community_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn delete_mods_for_community( pool: &mut DbPool<'_>, for_community_id: CommunityId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(community_actions::table.filter(community_actions::community_id.eq(for_community_id))) .set_null(community_actions::became_moderator_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn leave_mod_team_for_all_communities( pool: &mut DbPool<'_>, for_person_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(community_actions::table.filter(community_actions::person_id.eq(for_person_id))) .set_null(community_actions::became_moderator_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn get_person_moderated_communities( pool: &mut DbPool<'_>, for_person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; community_actions::table .filter(community_actions::became_moderator_at.is_not_null()) .filter(community_actions::person_id.eq(for_person_id)) .select(community_actions::community_id) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Check if we should accept activity in remote community. This requires either: /// - Local follower of the community /// - Local post or comment in the community /// /// Dont use this check for local communities. pub async fn check_accept_activity_in_community( pool: &mut DbPool<'_>, remote_community: &Community, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let remote_community_id = remote_community.id; let follow_action = community_actions::table .filter(community_actions::followed_at.is_not_null()) .filter(community_actions::community_id.eq(remote_community_id)); let local_post = post::table .filter(post::community_id.eq(remote_community_id)) .filter(post::local); let local_comment = comment::table .inner_join(post::table) .filter(post::community_id.eq(remote_community_id)) .filter(comment::local); select(exists(follow_action).or(exists(local_post).or(exists(local_comment)))) .get_result::(conn) .await? .then_some(()) .ok_or(UntranslatedError::CommunityHasNoFollowers(remote_community.ap_id.to_string()).into()) } pub async fn approve_private_community_follower( pool: &mut DbPool<'_>, community_id: CommunityId, follower_id: PersonId, approver_id: PersonId, state: CommunityFollowerState, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let find_action = community_actions::table .find((follower_id, community_id)) .filter(community_actions::followed_at.is_not_null()); diesel::update(find_action) .set(( community_actions::follow_state.eq(state), community_actions::follow_approver_id.eq(approver_id), )) .execute(conn) .await?; Ok(()) } pub async fn fetch_largest_subscribed_community( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult> { static CACHE: LazyLock>> = LazyLock::new(|| { Cache::builder() .max_capacity(1000) .time_to_live(CACHE_DURATION_LARGEST_COMMUNITY) .build() }); CACHE .try_get_with(person_id, async move { let conn = &mut get_conn(pool).await?; community_actions::table .filter(community_actions::followed_at.is_not_null()) .filter(community_actions::person_id.eq(person_id)) .inner_join(community::table.on(community::id.eq(community_actions::community_id))) .order_by(community::users_active_month.desc()) .select(community::id) .first::(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) }) .await .map_err(|_e: Arc| LemmyErrorType::NotFound.into()) } pub async fn update_notification_state( community_id: CommunityId, person_id: PersonId, new_state: CommunityNotificationsMode, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let form = ( community_actions::person_id.eq(person_id), community_actions::community_id.eq(community_id), community_actions::notifications.eq(new_state), ); insert_into(community_actions::table) .values(form.clone()) .on_conflict(( community_actions::person_id, community_actions::community_id, )) .do_update() .set(form) .execute(conn) .await?; Ok(()) } pub async fn list_subscribers( community_id: CommunityId, is_post: bool, pool: &mut DbPool<'_>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let mut query = community_actions::table .inner_join(local_user::table.on(community_actions::person_id.eq(local_user::person_id))) .filter(community_actions::community_id.eq(community_id)) .select(local_user::person_id) .into_boxed(); if is_post { query = query.filter( community_actions::notifications .eq(CommunityNotificationsMode::AllPosts) .or(community_actions::notifications.eq(CommunityNotificationsMode::AllPostsAndComments)), ); } else { query = query.filter( community_actions::notifications.eq(CommunityNotificationsMode::AllPostsAndComments), ); } query .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl Bannable for CommunityActions { type Form = CommunityPersonBanForm; async fn ban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(community_actions::table) .values(form) .on_conflict(( community_actions::community_id, community_actions::person_id, )) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn unban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(community_actions::table.find((form.person_id, form.community_id))) .set_null(community_actions::received_ban_at) .set_null(community_actions::ban_expires_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Followable for CommunityActions { type Form = CommunityFollowerForm; type IdType = CommunityId; async fn follow(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(community_actions::table) .values(form) .on_conflict(( community_actions::community_id, community_actions::person_id, )) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn follow_accepted( pool: &mut DbPool<'_>, community_id: CommunityId, person_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let find_action = community_actions::table .find((person_id, community_id)) .filter(community_actions::follow_state.is_not_null()); diesel::update(find_action) .set(community_actions::follow_state.eq(Some(CommunityFollowerState::Accepted))) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn unfollow( pool: &mut DbPool<'_>, person_id: PersonId, community_id: Self::IdType, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(community_actions::table.find((person_id, community_id))) .set_null(community_actions::followed_at) .set_null(community_actions::follow_state) .set_null(community_actions::follow_approver_id) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Blockable for CommunityActions { type Form = CommunityBlockForm; type ObjectIdType = CommunityId; type ObjectType = Community; async fn block(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(community_actions::table) .values(form) .on_conflict(( community_actions::person_id, community_actions::community_id, )) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn unblock( pool: &mut DbPool<'_>, community_block_form: &Self::Form, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(community_actions::table.find(( community_block_form.person_id, community_block_form.community_id, ))) .set_null(community_actions::blocked_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn read_block( pool: &mut DbPool<'_>, person_id: PersonId, community_id: Self::ObjectIdType, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let find_action = community_actions::table .find((person_id, community_id)) .filter(community_actions::blocked_at.is_not_null()); select(not(exists(find_action))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::CommunityIsBlocked.into()) } async fn read_blocks_for_person( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; community_actions::table .filter(community_actions::blocked_at.is_not_null()) .inner_join(community::table) .select(community::all_columns) .filter(community_actions::person_id.eq(person_id)) .filter(community::deleted.eq(false)) .filter(community::removed.eq(false)) .order_by(community_actions::blocked_at) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl ApubActor for Community { async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: &DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; community::table .filter(lower(community::ap_id).eq(object_id.to_lowercase())) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } async fn read_from_name( pool: &mut DbPool<'_>, community_name: &str, domain: Option<&str>, include_deleted: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let mut q = community::table .inner_join(instance::table) .into_boxed() .filter(lower(community::name).eq(community_name.to_lowercase())) .select(community::all_columns); if !include_deleted { q = q.filter(Self::hide_removed_and_deleted()) } if let Some(domain) = domain { q = q.filter(lower(instance::domain).eq(domain.to_lowercase())) } else { q = q.filter(community::local.eq(true)) } q.first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } fn actor_url(&self, settings: &Settings) -> LemmyResult { let domain = self .ap_id .inner() .domain() .ok_or(LemmyErrorType::NotFound)?; format_actor_url(&self.name, domain, 'c', settings) } fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/c/{name}"))?.into()) } } #[cfg(test)] mod tests { use super::*; use crate::{ source::{ comment::{Comment, CommentInsertForm}, community::{ Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, CommunityPersonBanForm, CommunityUpdateForm, }, instance::Instance, local_user::LocalUser, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, }, traits::{Bannable, Followable}, utils::RANK_DEFAULT, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let bobby_person = PersonInsertForm::test_form(inserted_instance.id, "bobby"); let inserted_bobby = Person::create(pool, &bobby_person).await?; let artemis_person = PersonInsertForm::test_form(inserted_instance.id, "artemis"); let inserted_artemis = Person::create(pool, &artemis_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "TIL".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let expected_community = Community { id: inserted_community.id, name: "TIL".into(), title: "nada".to_owned(), sidebar: None, summary: None, nsfw: false, removed: false, deleted: false, published_at: inserted_community.published_at, updated_at: None, ap_id: inserted_community.ap_id.clone(), local: true, private_key: None, public_key: "pubkey".to_owned(), last_refreshed_at: inserted_community.published_at, icon: None, banner: None, followers_url: inserted_community.followers_url.clone(), inbox_url: inserted_community.inbox_url.clone(), moderators_url: None, featured_url: None, posting_restricted_to_mods: false, instance_id: inserted_instance.id, visibility: CommunityVisibility::Public, random_number: inserted_community.random_number, subscribers: 1, posts: 0, comments: 0, users_active_day: 0, users_active_week: 0, users_active_month: 0, users_active_half_year: 0, hot_rank: RANK_DEFAULT, subscribers_local: 1, report_count: 0, unresolved_report_count: 0, interactions_month: 0, local_removed: false, }; let community_follower_form = CommunityFollowerForm::new( inserted_community.id, inserted_bobby.id, CommunityFollowerState::Accepted, ); let inserted_community_follower = CommunityActions::follow(pool, &community_follower_form).await?; assert_eq!( Some(CommunityFollowerState::Accepted), inserted_community_follower.follow_state ); let bobby_moderator_form = CommunityModeratorForm::new(inserted_community.id, inserted_bobby.id); let inserted_bobby_moderator = CommunityActions::join(pool, &bobby_moderator_form).await?; assert!(inserted_bobby_moderator.became_moderator_at.is_some()); let artemis_moderator_form = CommunityModeratorForm::new(inserted_community.id, inserted_artemis.id); let _inserted_artemis_moderator = CommunityActions::join(pool, &artemis_moderator_form).await?; let moderator_person_ids = vec![inserted_bobby.id, inserted_artemis.id]; // Make sure bobby is marked as a higher mod than artemis, and vice versa let bobby_higher_check_2 = LocalUser::is_higher_mod_or_admin_check( pool, inserted_community.id, inserted_bobby.id, moderator_person_ids.clone(), ) .await; assert!(bobby_higher_check_2.is_ok()); // This should throw an error, since artemis was added later let artemis_higher_check = LocalUser::is_higher_mod_or_admin_check( pool, inserted_community.id, inserted_artemis.id, moderator_person_ids, ) .await; assert!(artemis_higher_check.is_err()); let community_person_ban_form = CommunityPersonBanForm::new(inserted_community.id, inserted_bobby.id); let inserted_community_person_ban = CommunityActions::ban(pool, &community_person_ban_form).await?; assert!(inserted_community_person_ban.received_ban_at.is_some()); assert!(inserted_community_person_ban.ban_expires_at.is_none()); let read_community = Community::read(pool, inserted_community.id).await?; let update_community_form = CommunityUpdateForm { title: Some("nada".to_owned()), ..Default::default() }; let updated_community = Community::update(pool, inserted_community.id, &update_community_form).await?; let ignored_community = CommunityActions::unfollow( pool, community_follower_form.person_id, community_follower_form.community_id, ) .await?; let left_community = CommunityActions::leave(pool, &bobby_moderator_form).await?; let unban = CommunityActions::unban(pool, &community_person_ban_form).await?; let num_deleted = Community::delete(pool, inserted_community.id).await?; Person::delete(pool, inserted_bobby.id).await?; Person::delete(pool, inserted_artemis.id).await?; Instance::delete(pool, inserted_instance.id).await?; assert_eq!(expected_community, read_community); assert_eq!(expected_community, updated_community); assert_eq!(UpleteCount::only_updated(1), ignored_community); assert_eq!(UpleteCount::only_updated(1), left_community); assert_eq!(UpleteCount::only_deleted(1), unban); // assert_eq!(2, loaded_count); assert_eq!(1, num_deleted); Ok(()) } #[tokio::test] #[serial] async fn test_aggregates() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "thommy_community_agg"); let inserted_person = Person::create(pool, &new_person).await?; let another_person = PersonInsertForm::test_form(inserted_instance.id, "jerry_community_agg"); let another_inserted_person = Person::create(pool, &another_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "TIL_community_agg".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let another_community = CommunityInsertForm::new( inserted_instance.id, "TIL_community_agg_2".into(), "nada".to_owned(), "pubkey".to_string(), ); let another_inserted_community = Community::create(pool, &another_community).await?; let first_person_follow = CommunityFollowerForm::new( inserted_community.id, inserted_person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &first_person_follow).await?; let second_person_follow = CommunityFollowerForm::new( inserted_community.id, another_inserted_person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &second_person_follow).await?; let another_community_follow = CommunityFollowerForm::new( another_inserted_community.id, inserted_person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &another_community_follow).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; let child_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let _inserted_child_comment = Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?; let community_aggregates_before_delete = Community::read(pool, inserted_community.id).await?; assert_eq!(2, community_aggregates_before_delete.subscribers); assert_eq!(2, community_aggregates_before_delete.subscribers_local); assert_eq!(1, community_aggregates_before_delete.posts); assert_eq!(2, community_aggregates_before_delete.comments); // Test the other community let another_community_aggs = Community::read(pool, another_inserted_community.id).await?; assert_eq!(1, another_community_aggs.subscribers); assert_eq!(1, another_community_aggs.subscribers_local); assert_eq!(0, another_community_aggs.posts); assert_eq!(0, another_community_aggs.comments); // Unfollow test CommunityActions::unfollow( pool, second_person_follow.person_id, second_person_follow.community_id, ) .await?; let after_unfollow = Community::read(pool, inserted_community.id).await?; assert_eq!(1, after_unfollow.subscribers); assert_eq!(1, after_unfollow.subscribers_local); // Follow again just for the later tests CommunityActions::follow(pool, &second_person_follow).await?; let after_follow_again = Community::read(pool, inserted_community.id).await?; assert_eq!(2, after_follow_again.subscribers); assert_eq!(2, after_follow_again.subscribers_local); // Remove a parent post (the comment count should also be 0) Post::delete(pool, inserted_post.id).await?; let after_parent_post_delete = Community::read(pool, inserted_community.id).await?; assert_eq!(0, after_parent_post_delete.posts); assert_eq!(0, after_parent_post_delete.comments); // Remove the 2nd person Person::delete(pool, another_inserted_person.id).await?; let after_person_delete = Community::read(pool, inserted_community.id).await?; assert_eq!(1, after_person_delete.subscribers); assert_eq!(1, after_person_delete.subscribers_local); // This should delete all the associated rows, and fire triggers let person_num_deleted = Person::delete(pool, inserted_person.id).await?; assert_eq!(1, person_num_deleted); // Delete the community let community_num_deleted = Community::delete(pool, inserted_community.id).await?; assert_eq!(1, community_num_deleted); let another_community_num_deleted = Community::delete(pool, another_inserted_community.id).await?; assert_eq!(1, another_community_num_deleted); // Should be none found, since the creator was deleted let after_delete = Community::read(pool, inserted_community.id).await; assert!(after_delete.is_err()); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/community_community_follow.rs ================================================ use crate::{ diesel::{ExpressionMethods, QueryDsl}, newtypes::CommunityId, source::community_community_follow::CommunityCommunityFollow, }; use diesel::{delete, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::community_community_follow; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::LemmyResult; impl CommunityCommunityFollow { pub async fn follow( pool: &mut DbPool<'_>, target_id: CommunityId, community_id: CommunityId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; insert_into(community_community_follow::table) .values(( community_community_follow::target_id.eq(target_id), community_community_follow::community_id.eq(community_id), )) .execute(conn) .await?; Ok(()) } pub async fn unfollow( pool: &mut DbPool<'_>, target_id: CommunityId, community_id: CommunityId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; delete( community_community_follow::table .filter(community_community_follow::target_id.eq(target_id)) .filter(community_community_follow::community_id.eq(community_id)), ) .execute(conn) .await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/community_report.rs ================================================ use crate::{ newtypes::{CommunityId, CommunityReportId}, source::community_report::{CommunityReport, CommunityReportForm}, traits::Reportable, }; use chrono::Utc; use diesel::{ BoolExpressionMethods, ExpressionMethods, QueryDsl, dsl::{insert_into, update}, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{PersonId, schema::community_report}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Reportable for CommunityReport { type Form = CommunityReportForm; type IdType = CommunityReportId; type ObjectIdType = CommunityId; /// creates a community report and returns it /// /// * `conn` - the postgres connection /// * `community_report_form` - the filled CommunityReportForm to insert async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(community_report::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } /// resolve a community report /// /// * `conn` - the postgres connection /// * `report_id` - the id of the report to resolve /// * `by_resolver_id` - the id of the user resolving the report async fn update_resolved( pool: &mut DbPool<'_>, report_id_: Self::IdType, by_resolver_id: PersonId, is_resolved: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(community_report::table.find(report_id_)) .set(( community_report::resolved.eq(is_resolved), community_report::resolver_id.eq(by_resolver_id), community_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_apub( pool: &mut DbPool<'_>, object_id: Self::ObjectIdType, report_creator_id: PersonId, resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update( community_report::table.filter( community_report::community_id .eq(object_id) .and(community_report::creator_id.eq(report_creator_id)), ), ) .set(( community_report::resolved.eq(true), community_report::resolver_id.eq(resolver_id), community_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_all_for_object( pool: &mut DbPool<'_>, community_id_: Self::ObjectIdType, by_resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(community_report::table.filter(community_report::community_id.eq(community_id_))) .set(( community_report::resolved.eq(true), community_report::resolver_id.eq(by_resolver_id), community_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } ================================================ FILE: crates/db_schema/src/impls/community_tag.rs ================================================ use crate::{ diesel::SelectableHelper, newtypes::{CommunityId, CommunityTagId, PostId}, source::{ community_tag::{ CommunityTag, CommunityTagInsertForm, CommunityTagUpdateForm, CommunityTagsView, PostCommunityTag, PostCommunityTagForm, }, post::Post, }, }; use diesel::{ ExpressionMethods, QueryDsl, delete, deserialize::FromSql, insert_into, pg::{Pg, PgValue}, serialize::ToSql, sql_types::{Json, Nullable}, upsert::excluded, }; use diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt}; use lemmy_db_schema_file::schema::{community_tag, post_community_tag}; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use std::collections::HashSet; impl Crud for CommunityTag { type InsertForm = CommunityTagInsertForm; type UpdateForm = CommunityTagUpdateForm; type IdType = CommunityTagId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(community_tag::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, pid: CommunityTagId, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(community_tag::table.find(pid)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl CommunityTag { pub async fn read_for_community( pool: &mut DbPool<'_>, community_id: CommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; community_tag::table .filter(community_tag::community_id.eq(community_id)) .filter(community_tag::deleted.eq(false)) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update_many( pool: &mut DbPool<'_>, mut forms: Vec, existing_tags: Vec, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let new_tag_ids = forms .iter() .map(|tag| tag.ap_id.clone()) .collect::>(); let delete_forms = existing_tags .into_iter() .filter(|tag| !new_tag_ids.contains(&tag.ap_id)) .map(|t| CommunityTagInsertForm { ap_id: t.ap_id, name: t.name, display_name: None, community_id: t.community_id, deleted: Some(true), summary: None, color: Some(t.color), }); forms.extend(delete_forms); conn .run_transaction(|conn| { async move { insert_into(community_tag::table) .values(&forms) .on_conflict(community_tag::ap_id) .do_update() .set(( community_tag::display_name.eq(excluded(community_tag::display_name)), community_tag::summary.eq(excluded(community_tag::summary)), community_tag::deleted.eq(excluded(community_tag::deleted)), )) .execute(conn) .await?; Ok(()) } .scope_boxed() }) .await?; Ok(()) } pub async fn read_for_post( pool: &mut DbPool<'_>, post_id: PostId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; post_community_tag::table .inner_join(community_tag::table) .filter(post_community_tag::post_id.eq(post_id)) .filter(community_tag::deleted.eq(false)) .select(community_tag::all_columns) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read_apub(pool: &mut DbPool<'_>, ap_id: &DbUrl) -> LemmyResult { let conn = &mut get_conn(pool).await?; community_tag::table .filter(community_tag::ap_id.eq(ap_id)) .filter(community_tag::deleted.eq(false)) .select(community_tag::all_columns) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl FromSql, Pg> for CommunityTagsView { fn from_sql(bytes: PgValue) -> diesel::deserialize::Result { let value = >::from_sql(bytes)?; 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(vec![])), } } } impl ToSql, Pg> for CommunityTagsView { 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()) } } impl PostCommunityTag { pub async fn update( pool: &mut DbPool<'_>, post: &Post, community_tag_ids: &[CommunityTagId], ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; conn .run_transaction(|conn| { async move { delete(post_community_tag::table.filter(post_community_tag::post_id.eq(post.id))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted)?; let forms = community_tag_ids .iter() .map(|tag_id| PostCommunityTagForm { post_id: post.id, community_tag_id: *tag_id, }) .collect::>(); insert_into(post_community_tag::table) .values(forms) .returning(Self::as_select()) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } .scope_boxed() }) .await } } ================================================ FILE: crates/db_schema/src/impls/custom_emoji.rs ================================================ use crate::{ newtypes::CustomEmojiId, source::{ custom_emoji::{CustomEmoji, CustomEmojiInsertForm, CustomEmojiUpdateForm}, custom_emoji_keyword::{CustomEmojiKeyword, CustomEmojiKeywordInsertForm}, }, }; use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::{ custom_emoji::dsl::custom_emoji, custom_emoji_keyword::dsl::{custom_emoji_id, custom_emoji_keyword}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, traits::Crud, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Crud for CustomEmoji { type InsertForm = CustomEmojiInsertForm; type UpdateForm = CustomEmojiUpdateForm; type IdType = CustomEmojiId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(custom_emoji) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, emoji_id: Self::IdType, new_custom_emoji: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(custom_emoji.find(emoji_id)) .set(new_custom_emoji) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl CustomEmojiKeyword { pub async fn create_from_keywords( pool: &mut DbPool<'_>, for_custom_emoji_id: CustomEmojiId, keywords: &[String], ) -> LemmyResult> { let forms = keywords .iter() .map(|k| CustomEmojiKeywordInsertForm { custom_emoji_id: for_custom_emoji_id, keyword: k.to_lowercase().trim().to_string(), }) .collect(); Self::create(pool, &forms).await } pub async fn create( pool: &mut DbPool<'_>, form: &Vec, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; insert_into(custom_emoji_keyword) .values(form) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn delete(pool: &mut DbPool<'_>, emoji_id: CustomEmojiId) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(custom_emoji_keyword.filter(custom_emoji_id.eq(emoji_id))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } ================================================ FILE: crates/db_schema/src/impls/email_verification.rs ================================================ use crate::{ newtypes::LocalUserId, source::email_verification::{EmailVerification, EmailVerificationForm}, }; use diesel::{ExpressionMethods, QueryDsl, dsl::IntervalDsl, insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::email_verification; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, utils::now, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl EmailVerification { pub async fn create(pool: &mut DbPool<'_>, form: &EmailVerificationForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(email_verification::table) .values(form) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn read_for_token(pool: &mut DbPool<'_>, token: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; email_verification::table .filter(email_verification::verification_token.eq(token)) .filter(email_verification::published_at.gt(now() - 7.days())) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn delete_old_tokens_for_local_user( pool: &mut DbPool<'_>, local_user_id_: LocalUserId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete( email_verification::table.filter(email_verification::local_user_id.eq(local_user_id_)), ) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } ================================================ FILE: crates/db_schema/src/impls/federation_allowlist.rs ================================================ use crate::source::federation_allowlist::{FederationAllowList, FederationAllowListForm}; use diesel::{ExpressionMethods, QueryDsl, delete, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{InstanceId, schema::federation_allowlist}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl FederationAllowList { pub async fn allow(pool: &mut DbPool<'_>, form: &FederationAllowListForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(federation_allowlist::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn unallow(pool: &mut DbPool<'_>, instance_id_: InstanceId) -> LemmyResult { let conn = &mut get_conn(pool).await?; delete(federation_allowlist::table.filter(federation_allowlist::instance_id.eq(instance_id_))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } #[cfg(test)] mod tests { use super::*; use crate::source::instance::Instance; use lemmy_diesel_utils::connection::build_db_pool_for_tests; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_allowlist_insert_and_clear() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let instances = vec![ Instance::read_or_create(pool, "tld1.xyz").await?, Instance::read_or_create(pool, "tld2.xyz").await?, Instance::read_or_create(pool, "tld3.xyz").await?, ]; let forms: Vec<_> = instances .iter() .map(|i| FederationAllowListForm::new(i.id)) .collect(); for f in &forms { FederationAllowList::allow(pool, f).await?; } let allows = Instance::allowlist(pool).await?; assert_eq!(3, allows.len()); assert_eq!(instances, allows); // Now test clearing them for f in forms { FederationAllowList::unallow(pool, f.instance_id).await?; } let allows = Instance::allowlist(pool).await?; assert_eq!(0, allows.len()); Instance::delete_all(pool).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/federation_blocklist.rs ================================================ use crate::source::federation_blocklist::{FederationBlockList, FederationBlockListForm}; use diesel::{ExpressionMethods, QueryDsl, delete, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{InstanceId, schema::federation_blocklist}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl FederationBlockList { pub async fn block(pool: &mut DbPool<'_>, form: &FederationBlockListForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(federation_blocklist::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn unblock(pool: &mut DbPool<'_>, instance_id_: InstanceId) -> LemmyResult { let conn = &mut get_conn(pool).await?; delete(federation_blocklist::table.filter(federation_blocklist::instance_id.eq(instance_id_))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } ================================================ FILE: crates/db_schema/src/impls/federation_queue_state.rs ================================================ use crate::source::federation_queue_state::FederationQueueState; use diesel::{ExpressionMethods, Insertable, OptionalExtension, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{InstanceId, schema::federation_queue_state}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl FederationQueueState { /// load state or return a default empty value pub async fn load(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult { let conn = &mut get_conn(pool).await?; Ok( federation_queue_state::table .filter(federation_queue_state::instance_id.eq(instance_id)) .select(FederationQueueState::as_select()) .get_result(conn) .await .optional()? .unwrap_or(FederationQueueState { instance_id, fail_count: 0, last_retry_at: None, last_successful_id: None, // this value is set to the most current id for new instances last_successful_published_time_at: None, }), ) } pub async fn upsert(pool: &mut DbPool<'_>, state: &FederationQueueState) -> LemmyResult { let conn = &mut get_conn(pool).await?; state .insert_into(federation_queue_state::table) .on_conflict(federation_queue_state::instance_id) .do_update() .set(state) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } ================================================ FILE: crates/db_schema/src/impls/images.rs ================================================ use crate::source::images::{ ImageDetails, ImageDetailsInsertForm, LocalImage, LocalImageForm, RemoteImage, }; use diesel::{ BoolExpressionMethods, ExpressionMethods, QueryDsl, dsl::exists, insert_into, select, }; use diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt}; use lemmy_db_schema_file::{ PersonId, schema::{image_details, local_image, remote_image}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use url::Url; impl LocalImage { pub async fn create( pool: &mut DbPool<'_>, form: &LocalImageForm, image_details_form: &ImageDetailsInsertForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; conn .run_transaction(|conn| { async move { let local_insert = insert_into(local_image::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate); ImageDetails::create(&mut conn.into(), image_details_form).await?; local_insert } .scope_boxed() }) .await } pub async fn validate_by_alias_and_user( pool: &mut DbPool<'_>, alias: &str, person_id: PersonId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( local_image::table.filter( local_image::pictrs_alias .eq(alias) .and(local_image::person_id.eq(person_id)), ), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotFound.into()) } pub async fn delete_by_alias(pool: &mut DbPool<'_>, alias: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq(alias))) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } /// Delete many aliases. Should be used with a pictrs purge. pub async fn delete_by_aliases(pool: &mut DbPool<'_>, aliases: &[String]) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(local_image::table.filter(local_image::pictrs_alias.eq_any(aliases))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } impl RemoteImage { pub async fn create(pool: &mut DbPool<'_>, links: Vec) -> LemmyResult { let conn = &mut get_conn(pool).await?; let forms = links .into_iter() .map(|url| remote_image::dsl::link.eq::(url.into())) .collect::>(); insert_into(remote_image::table) .values(forms) .on_conflict_do_nothing() .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn validate(pool: &mut DbPool<'_>, link_: DbUrl) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( remote_image::table.filter(remote_image::link.eq(link_)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotFound.into()) } } impl ImageDetails { pub async fn create(pool: &mut DbPool<'_>, form: &ImageDetailsInsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(image_details::table) .values(form) .on_conflict_do_nothing() .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } } ================================================ FILE: crates/db_schema/src/impls/instance.rs ================================================ use crate::{ diesel::dsl::IntervalDsl, source::instance::{ Instance, InstanceActions, InstanceBanForm, InstanceCommunitiesBlockForm, InstanceForm, InstancePersonsBlockForm, }, traits::Bannable, }; use chrono::Utc; use diesel::{ ExpressionMethods, NullableExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper, dsl::{count_star, exists, insert_into, not, select}, }; use diesel_async::RunQueryDsl; use diesel_uplete::{UpleteCount, uplete}; use lemmy_db_schema_file::{ InstanceId, PersonId, schema::{ federation_allowlist, federation_blocklist, federation_queue_state, instance, instance_actions, }, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, utils::{ functions::{coalesce, lower}, now, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Instance { /// Attempt to read Instance column for the given domain. If it doesn't exist, insert a new one. /// There is no need for update as the domain of an existing instance cant change. pub async fn read_or_create(pool: &mut DbPool<'_>, domain_: &str) -> LemmyResult { use lemmy_db_schema_file::schema::instance::domain; let conn = &mut get_conn(pool).await?; // First try to read the instance row and return directly if found let instance = instance::table .filter(lower(domain).eq(&domain_.to_lowercase())) .first(conn) .await .optional()?; // TODO could convert this to unwrap_or_else once async closures are stable match instance { Some(i) => Ok(i), None => { // Instance not in database yet, insert it let form = InstanceForm { updated_at: Some(Utc::now()), ..InstanceForm::new(domain_.to_string()) }; insert_into(instance::table) .values(&form) // Necessary because this method may be called concurrently for the same domain. This // could be handled with a transaction, but nested transactions arent allowed .on_conflict(instance::domain) .do_update() .set(&form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } } } pub async fn read(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult { let conn = &mut get_conn(pool).await?; instance::table .find(instance_id) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update( pool: &mut DbPool<'_>, instance_id: InstanceId, form: InstanceForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(instance::table.find(instance_id)) .set(form) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn delete(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(instance::table.find(instance_id)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } pub async fn read_all(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; instance::table .select(Self::as_select()) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Only for use in tests pub async fn delete_all(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(federation_queue_state::table) .execute(conn) .await?; diesel::delete(instance::table) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } pub async fn allowlist(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; instance::table .inner_join(federation_allowlist::table) .select(Self::as_select()) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn blocklist(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; instance::table .inner_join(federation_blocklist::table) .select(Self::as_select()) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// returns a list of all instances, each with a flag of whether the instance is allowed or not /// and dead or not ordered by id pub async fn read_federated_with_blocked_and_dead( pool: &mut DbPool<'_>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let is_dead_expr = coalesce(instance::updated_at, instance::published_at).lt(now() - 3.days()); // this needs to be done in two steps because the meaning of the "blocked" column depends on the // existence of any value at all in the allowlist. (so a normal join wouldn't work) let use_allowlist = federation_allowlist::table .select(count_star().gt(0)) .get_result::(conn) .await?; if use_allowlist { instance::table .left_join(federation_allowlist::table) .select(( Self::as_select(), federation_allowlist::instance_id.nullable().is_not_null(), is_dead_expr, )) .order_by(instance::id) .get_results::<(Self, bool, bool)>(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } else { instance::table .left_join(federation_blocklist::table) .select(( Self::as_select(), federation_blocklist::instance_id.nullable().is_null(), is_dead_expr, )) .order_by(instance::id) .get_results::<(Self, bool, bool)>(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } } impl InstanceActions { pub async fn block_communities( pool: &mut DbPool<'_>, form: &InstanceCommunitiesBlockForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(instance_actions::table) .values(form) .on_conflict((instance_actions::person_id, instance_actions::instance_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } pub async fn unblock_communities( pool: &mut DbPool<'_>, form: &InstanceCommunitiesBlockForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(instance_actions::table.find((form.person_id, form.instance_id))) .set_null(instance_actions::blocked_communities_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } /// Checks to see if there's a block for the instances communities pub async fn read_communities_block( pool: &mut DbPool<'_>, person_id: PersonId, instance_id: InstanceId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let find_action = instance_actions::table .find((person_id, instance_id)) .filter(instance_actions::blocked_communities_at.is_not_null()); select(not(exists(find_action))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::InstanceIsBlocked.into()) } pub async fn read_communities_block_for_person( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; instance_actions::table .filter(instance_actions::blocked_communities_at.is_not_null()) .inner_join(instance::table) .select(instance::all_columns) .filter(instance_actions::person_id.eq(person_id)) .order_by(instance_actions::blocked_communities_at) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn block_persons( pool: &mut DbPool<'_>, form: &InstancePersonsBlockForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(instance_actions::table) .values(form) .on_conflict((instance_actions::person_id, instance_actions::instance_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } pub async fn unblock_persons( pool: &mut DbPool<'_>, form: &InstancePersonsBlockForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(instance_actions::table.find((form.person_id, form.instance_id))) .set_null(instance_actions::blocked_persons_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } /// Checks to see if there's a block either from the instance person. pub async fn read_persons_block( pool: &mut DbPool<'_>, person_id: PersonId, instance_id: InstanceId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let find_action = instance_actions::table .find((person_id, instance_id)) .filter(instance_actions::blocked_persons_at.is_not_null()); select(not(exists(find_action))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::InstanceIsBlocked.into()) } pub async fn read_persons_block_for_person( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; instance_actions::table .filter(instance_actions::blocked_persons_at.is_not_null()) .inner_join(instance::table) .select(instance::all_columns) .filter(instance_actions::person_id.eq(person_id)) .order_by(instance_actions::blocked_persons_at) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn check_ban( pool: &mut DbPool<'_>, person_id: PersonId, instance_id: InstanceId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let ban_exists = select(exists( instance_actions::table .filter(instance_actions::person_id.eq(person_id)) .filter(instance_actions::instance_id.eq(instance_id)) .filter(instance_actions::received_ban_at.is_not_null()), )) .get_result::(conn) .await?; if ban_exists { return Err(LemmyErrorType::SiteBan.into()); } Ok(()) } } impl Bannable for InstanceActions { type Form = InstanceBanForm; async fn ban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; Ok( insert_into(instance_actions::table) .values(form) .on_conflict((instance_actions::person_id, instance_actions::instance_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await?, ) } async fn unban(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; Ok( uplete(instance_actions::table.find((form.person_id, form.instance_id))) .set_null(instance_actions::received_ban_at) .set_null(instance_actions::ban_expires_at) .get_result(conn) .await?, ) } } ================================================ FILE: crates/db_schema/src/impls/keyword_block.rs ================================================ use crate::{ newtypes::LocalUserId, source::keyword_block::{LocalUserKeywordBlock, LocalUserKeywordBlockForm}, }; use diesel::{ExpressionMethods, QueryDsl, delete, insert_into}; use diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt}; use lemmy_db_schema_file::schema::local_user_keyword_block; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LocalUserKeywordBlock { pub async fn read( pool: &mut DbPool<'_>, for_local_user_id: LocalUserId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; local_user_keyword_block::table .filter(local_user_keyword_block::local_user_id.eq(for_local_user_id)) .select(local_user_keyword_block::keyword) .load(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update( pool: &mut DbPool<'_>, blocking_keywords: Vec, for_local_user_id: LocalUserId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; // No need to update if keywords unchanged conn .run_transaction(|conn| { async move { delete(local_user_keyword_block::table) .filter(local_user_keyword_block::local_user_id.eq(for_local_user_id)) .filter(local_user_keyword_block::keyword.ne_all(&blocking_keywords)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate)?; let forms = blocking_keywords .into_iter() .map(|k| LocalUserKeywordBlockForm { local_user_id: for_local_user_id, keyword: k, }) .collect::>(); insert_into(local_user_keyword_block::table) .values(forms) .on_conflict_do_nothing() .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } .scope_boxed() }) .await } } ================================================ FILE: crates/db_schema/src/impls/language.rs ================================================ use super::actor_language::UNDETERMINED_ID; use crate::{diesel::ExpressionMethods, newtypes::LanguageId, source::language::Language}; use diesel::{QueryDsl, dsl::count}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::{language, post}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::{ CacheLock, build_cache, error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, }; use std::sync::LazyLock; impl Language { /// Returns list of all available languages, with most used languages first pub async fn read_all(pool: &mut DbPool<'_>) -> LemmyResult> { static CACHE: CacheLock> = LazyLock::new(build_cache); CACHE .try_get_with((), async move { let conn = &mut get_conn(pool).await?; language::table .left_join(post::table) .group_by(language::id) .order_by(count(post::id).desc()) .select(language::all_columns) .load(conn) .await }) .await .map_err(|_e| LemmyErrorType::NotFound.into()) } pub async fn read_from_id(pool: &mut DbPool<'_>, id_: LanguageId) -> LemmyResult { let conn = &mut get_conn(pool).await?; language::table .find(id_) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Attempts to find the given language code and return its ID. pub async fn read_id_from_code(pool: &mut DbPool<'_>, code_: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = language::table .filter(language::code.eq(code_)) .first::(conn) .await .map(|l| l.id); // Return undetermined by default Ok(res.unwrap_or(UNDETERMINED_ID)) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::source::language::Language; use lemmy_diesel_utils::connection::build_db_pool_for_tests; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_languages() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let mut all = Language::read_all(pool).await?; // Languages are returned in order of popularity, so to make this test work we need to // manually sort them by id. all.sort_by(|a, b| a.id.0.cmp(&b.id.0)); assert_eq!(184, all.len()); assert_eq!("ak", all[5].code); assert_eq!("lv", all[99].code); assert_eq!("yi", all[179].code); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/local_site.rs ================================================ use crate::source::local_site::{LocalSite, LocalSiteInsertForm, LocalSiteUpdateForm}; use diesel::dsl::insert_into; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::local_site; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LocalSite { pub async fn create(pool: &mut DbPool<'_>, form: &LocalSiteInsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(local_site::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn update(pool: &mut DbPool<'_>, form: &LocalSiteUpdateForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(local_site::table) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn delete(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(local_site::table) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } #[cfg(test)] mod tests { use super::*; use crate::{ source::{ comment::{Comment, CommentInsertForm}, community::{Community, CommunityInsertForm, CommunityUpdateForm}, instance::Instance, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, site::Site, }, test_data::TestData, }; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; async fn read_local_site(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; local_site::table .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } async fn prepare_site_with_community( pool: &mut DbPool<'_>, ) -> LemmyResult<(TestData, Person, Community)> { let data = TestData::create(pool).await?; let new_person = PersonInsertForm::test_form(data.instance.id, "thommy_site_agg"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( data.instance.id, "TIL_site_agg".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; Ok((data, inserted_person, inserted_community)) } #[tokio::test] #[serial] async fn test_aggregates() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let (data, inserted_person, inserted_community) = prepare_site_with_community(pool).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); // Insert two of those posts let inserted_post = Post::create(pool, &new_post).await?; let _inserted_post_again = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); // Insert two of those comments let inserted_comment = Comment::create(pool, &comment_form, None).await?; let child_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let _inserted_child_comment = Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?; let site_aggregates_before_delete = read_local_site(pool).await?; // TODO: this is unstable, sometimes it returns 0 users, sometimes 1 //assert_eq!(0, site_aggregates_before_delete.users); assert_eq!(1, site_aggregates_before_delete.communities); assert_eq!(2, site_aggregates_before_delete.posts); assert_eq!(2, site_aggregates_before_delete.comments); // Try a post delete Post::delete(pool, inserted_post.id).await?; let site_aggregates_after_post_delete = read_local_site(pool).await?; assert_eq!(1, site_aggregates_after_post_delete.posts); assert_eq!(0, site_aggregates_after_post_delete.comments); // This shouuld delete all the associated rows, and fire triggers let person_num_deleted = Person::delete(pool, inserted_person.id).await?; assert_eq!(1, person_num_deleted); // Delete the community let community_num_deleted = Community::delete(pool, inserted_community.id).await?; assert_eq!(1, community_num_deleted); // Site should still exist, it can without a site creator. let after_delete_creator = read_local_site(pool).await; assert!(after_delete_creator.is_ok()); Site::delete(pool, data.site.id).await?; let after_delete_site = read_local_site(pool).await; assert!(after_delete_site.is_err()); Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_soft_delete() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let (data, inserted_person, inserted_community) = prepare_site_with_community(pool).await?; let site_aggregates_before = read_local_site(pool).await?; assert_eq!(1, site_aggregates_before.communities); Community::update( pool, inserted_community.id, &CommunityUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; let site_aggregates_after_delete = read_local_site(pool).await?; assert_eq!(0, site_aggregates_after_delete.communities); Community::update( pool, inserted_community.id, &CommunityUpdateForm { deleted: Some(false), ..Default::default() }, ) .await?; Community::update( pool, inserted_community.id, &CommunityUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; let site_aggregates_after_remove = read_local_site(pool).await?; assert_eq!(0, site_aggregates_after_remove.communities); Community::update( pool, inserted_community.id, &CommunityUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; let site_aggregates_after_remove_delete = read_local_site(pool).await?; assert_eq!(0, site_aggregates_after_remove_delete.communities); Community::delete(pool, inserted_community.id).await?; Person::delete(pool, inserted_person.id).await?; data.delete(pool).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/local_site_rate_limit.rs ================================================ use crate::{ diesel::OptionalExtension, source::local_site_rate_limit::{ LocalSiteRateLimit, LocalSiteRateLimitInsertForm, LocalSiteRateLimitUpdateForm, }, }; use diesel::dsl::insert_into; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::local_site_rate_limit; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LocalSiteRateLimit { pub async fn read(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; local_site_rate_limit::table .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn create( pool: &mut DbPool<'_>, form: &LocalSiteRateLimitInsertForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(local_site_rate_limit::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn update( pool: &mut DbPool<'_>, form: &LocalSiteRateLimitUpdateForm, ) -> LemmyResult<()> { // avoid error "There are no changes to save. This query cannot be built" if form.is_empty() { return Ok(()); } let conn = &mut get_conn(pool).await?; diesel::update(local_site_rate_limit::table) .set(form) .get_result::(conn) .await?; Ok(()) } } impl LocalSiteRateLimitUpdateForm { fn is_empty(&self) -> bool { self.message_max_requests.is_none() && self.message_interval_seconds.is_none() && self.post_max_requests.is_none() && self.post_interval_seconds.is_none() && self.register_max_requests.is_none() && self.register_interval_seconds.is_none() && self.image_max_requests.is_none() && self.image_interval_seconds.is_none() && self.comment_max_requests.is_none() && self.comment_interval_seconds.is_none() && self.search_max_requests.is_none() && self.search_interval_seconds.is_none() && self.updated_at.is_none() } } ================================================ FILE: crates/db_schema/src/impls/local_site_url_blocklist.rs ================================================ use crate::source::local_site_url_blocklist::{LocalSiteUrlBlocklist, LocalSiteUrlBlocklistForm}; use diesel::dsl::insert_into; use diesel_async::{AsyncPgConnection, RunQueryDsl, scoped_futures::ScopedFutureExt}; use lemmy_db_schema_file::schema::local_site_url_blocklist; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LocalSiteUrlBlocklist { pub async fn replace(pool: &mut DbPool<'_>, url_blocklist: Vec) -> LemmyResult { let conn = &mut get_conn(pool).await?; conn .run_transaction(|conn| { async move { Self::clear(conn).await?; let forms = url_blocklist .into_iter() .map(|url| LocalSiteUrlBlocklistForm { url, updated_at: None, }) .collect::>(); insert_into(local_site_url_blocklist::table) .values(forms) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } .scope_boxed() }) .await } async fn clear(conn: &mut AsyncPgConnection) -> LemmyResult { diesel::delete(local_site_url_blocklist::table) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } pub async fn get_all(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; local_site_url_blocklist::table .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } ================================================ FILE: crates/db_schema/src/impls/local_user.rs ================================================ use crate::{ newtypes::{CommunityId, LanguageId, LocalUserId}, source::{ actor_language::LocalUserLanguage, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, site::Site, }, }; use bcrypt::{DEFAULT_COST, hash}; use diesel::{ CombineDsl, ExpressionMethods, JoinOnDsl, QueryDsl, dsl::{IntervalDsl, insert_into, not}, result::Error, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{ PersonId, enums::CommunityVisibility, schema::{community, community_actions, local_user, person, registration_application}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, utils::{ functions::{coalesce, lower}, now, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LocalUser { pub async fn create( pool: &mut DbPool<'_>, form: &LocalUserInsertForm, languages: Vec, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut form_with_encrypted_password = form.clone(); if let Some(password_encrypted) = &form.password_encrypted { let password_hash = hash(password_encrypted, DEFAULT_COST)?; form_with_encrypted_password.password_encrypted = Some(password_hash); } let local_user_ = insert_into(local_user::table) .values(form_with_encrypted_password) .get_result::(conn) .await?; LocalUserLanguage::update(pool, languages, local_user_.id).await?; Ok(local_user_) } pub async fn update( pool: &mut DbPool<'_>, local_user_id: LocalUserId, form: &LocalUserUpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = diesel::update(local_user::table.find(local_user_id)) .set(form) .execute(conn) .await; // Diesel will throw an error if the query is all Nones (not updating anything), ignore this. match res { Err(Error::QueryBuilderError(_)) => Ok(0), other => other, } .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn delete(pool: &mut DbPool<'_>, id: LocalUserId) -> LemmyResult { let conn = &mut *get_conn(pool).await?; diesel::delete(local_user::table.find(id)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } pub async fn update_password( pool: &mut DbPool<'_>, local_user_id: LocalUserId, new_password: &str, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let password_hash = hash(new_password, DEFAULT_COST)?; diesel::update(local_user::table.find(local_user_id)) .set((local_user::password_encrypted.eq(password_hash),)) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn set_all_users_email_verified(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(local_user::table) .set(local_user::email_verified.eq(true)) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn set_all_users_registration_applications_accepted( pool: &mut DbPool<'_>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(local_user::table) .set(local_user::accepted_application.eq(true)) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn delete_old_denied_local_users(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; // Make sure: // - An admin has interacted with the application // - The app is older than a week // - The accepted_application is false let old_denied_registrations = registration_application::table .filter(registration_application::admin_id.is_not_null()) .filter(registration_application::published_at.lt(now() - 1.week())) .select(registration_application::local_user_id); // Delete based on join logic is here: // https://stackoverflow.com/questions/60836040/how-do-i-perform-a-delete-with-sub-query-in-diesel-against-a-postgres-database let local_users = local_user::table .filter(local_user::id.eq_any(old_denied_registrations)) .filter(not(local_user::accepted_application)) .select(local_user::person_id); // Delete the person rows, which should automatically clear the local_user ones let persons = person::table.filter(person::id.eq_any(local_users)); diesel::delete(persons) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } pub async fn check_is_email_taken(pool: &mut DbPool<'_>, email: &str) -> LemmyResult<()> { use diesel::dsl::{exists, select}; let conn = &mut get_conn(pool).await?; select(not(exists(local_user::table.filter( lower(coalesce(local_user::email, "")).eq(email.to_lowercase()), )))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::EmailAlreadyTaken.into()) } // TODO: maybe move this and pass in LocalUserView pub async fn export_backup( pool: &mut DbPool<'_>, person_id_: PersonId, ) -> LemmyResult { use lemmy_db_schema_file::schema::{ comment, comment_actions, community, community_actions, instance, instance_actions, person_actions, post, post_actions, }; let conn = &mut get_conn(pool).await?; let followed_communities = community_actions::table .filter(community_actions::followed_at.is_not_null()) .filter(community_actions::person_id.eq(person_id_)) .inner_join(community::table) .select(community::ap_id) .get_results(conn) .await?; let saved_posts = post_actions::table .filter(post_actions::saved_at.is_not_null()) .filter(post_actions::person_id.eq(person_id_)) .inner_join(post::table) .select(post::ap_id) .get_results(conn) .await?; let saved_comments = comment_actions::table .filter(comment_actions::saved_at.is_not_null()) .filter(comment_actions::person_id.eq(person_id_)) .inner_join(comment::table) .select(comment::ap_id) .get_results(conn) .await?; let blocked_communities = community_actions::table .filter(community_actions::blocked_at.is_not_null()) .filter(community_actions::person_id.eq(person_id_)) .inner_join(community::table) .select(community::ap_id) .get_results(conn) .await?; let blocked_users = person_actions::table .filter(person_actions::blocked_at.is_not_null()) .filter(person_actions::person_id.eq(person_id_)) .inner_join(person::table.on(person_actions::target_id.eq(person::id))) .select(person::ap_id) .get_results(conn) .await?; let blocked_instances_communities = instance_actions::table .filter(instance_actions::blocked_communities_at.is_not_null()) .filter(instance_actions::person_id.eq(person_id_)) .inner_join(instance::table) .select(instance::domain) .get_results(conn) .await?; let blocked_instances_persons = instance_actions::table .filter(instance_actions::blocked_persons_at.is_not_null()) .filter(instance_actions::person_id.eq(person_id_)) .inner_join(instance::table) .select(instance::domain) .get_results(conn) .await?; // TODO: use join for parallel queries? Ok(UserBackupLists { followed_communities, saved_posts, saved_comments, blocked_communities, blocked_users, blocked_instances_communities, blocked_instances_persons, }) } /// Checks to make sure the acting admin is higher than the target admin pub async fn is_higher_admin_check( pool: &mut DbPool<'_>, admin_person_id: PersonId, target_person_ids: Vec, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; // Build the list of persons let mut persons = target_person_ids; persons.push(admin_person_id); persons.dedup(); let res = local_user::table .filter(local_user::admin.eq(true)) .filter(local_user::person_id.eq_any(persons)) .order_by(local_user::id) // This does a limit 1 select first .first::(conn) .await?; // If the first result sorted by published is the acting admin if res.person_id == admin_person_id { Ok(()) } else { Err(LemmyErrorType::NotHigherAdmin.into()) } } /// Checks to make sure the acting moderator is higher than the target moderator pub async fn is_higher_mod_or_admin_check( pool: &mut DbPool<'_>, for_community_id: CommunityId, admin_person_id: PersonId, target_person_ids: Vec, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; // Build the list of persons let mut persons = target_person_ids; persons.push(admin_person_id); persons.dedup(); let admins = local_user::table .filter(local_user::admin.eq(true)) .filter(local_user::person_id.eq_any(&persons)) .order_by(local_user::id) .select(local_user::person_id); let mods = community_actions::table .filter(community_actions::became_moderator_at.is_not_null()) .filter(community_actions::community_id.eq(for_community_id)) .filter(community_actions::person_id.eq_any(&persons)) .order_by(community_actions::became_moderator_at) .select(community_actions::person_id); let res = admins.union_all(mods).get_results::(conn).await?; let first_person = res.as_slice().first().ok_or(LemmyErrorType::NotHigherMod)?; // If the first result sorted by published is the acting mod if *first_person == admin_person_id { Ok(()) } else { Err(LemmyErrorType::NotHigherMod.into()) } } } /// Adds some helper functions for an optional LocalUser pub trait LocalUserOptionHelper { fn person_id(&self) -> Option; fn local_user_id(&self) -> Option; fn show_bot_accounts(&self) -> bool; fn show_read_posts(&self) -> bool; fn is_admin(&self) -> bool; fn show_nsfw(&self, site: &Site) -> bool; fn hide_media(&self) -> bool; fn visible_communities_only(&self, query: Q) -> Q where Q: diesel::query_dsl::methods::FilterDsl< diesel::dsl::Eq, Output = Q, >; } impl LocalUserOptionHelper for Option<&LocalUser> { fn person_id(&self) -> Option { self.map(|l| l.person_id) } fn local_user_id(&self) -> Option { self.map(|l| l.id) } fn show_bot_accounts(&self) -> bool { self.map(|l| l.show_bot_accounts).unwrap_or(true) } fn show_read_posts(&self) -> bool { self.map(|l| l.show_read_posts).unwrap_or(true) } fn is_admin(&self) -> bool { self.map(|l| l.admin).unwrap_or(false) } fn show_nsfw(&self, site: &Site) -> bool { self .map(|l| l.show_nsfw) .unwrap_or(site.content_warning.is_some()) } fn hide_media(&self) -> bool { self.map(|l| l.hide_media).unwrap_or(false) } // TODO: use this function for private community checks, but the generics get extremely confusing fn visible_communities_only(&self, query: Q) -> Q where Q: diesel::query_dsl::methods::FilterDsl< diesel::dsl::Eq, Output = Q, >, { if self.is_none() { query.filter(community::visibility.eq(CommunityVisibility::Public)) } else { query } } } impl LocalUserInsertForm { pub fn test_form(person_id: PersonId) -> Self { Self::new(person_id, Some(String::new())) } pub fn test_form_admin(person_id: PersonId) -> Self { LocalUserInsertForm { admin: Some(true), ..Self::test_form(person_id) } } } pub struct UserBackupLists { pub followed_communities: Vec, pub saved_posts: Vec, pub saved_comments: Vec, pub blocked_communities: Vec, pub blocked_users: Vec, pub blocked_instances_communities: Vec, pub blocked_instances_persons: Vec, } #[cfg(test)] mod tests { use crate::source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use serial_test::serial; #[tokio::test] #[serial] async fn test_admin_higher_check() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let fiona_person = PersonInsertForm::test_form(inserted_instance.id, "fiona"); let inserted_fiona_person = Person::create(pool, &fiona_person).await?; let fiona_local_user_form = LocalUserInsertForm::test_form_admin(inserted_fiona_person.id); let _inserted_fiona_local_user = LocalUser::create(pool, &fiona_local_user_form, vec![]).await?; let delores_person = PersonInsertForm::test_form(inserted_instance.id, "delores"); let inserted_delores_person = Person::create(pool, &delores_person).await?; let delores_local_user_form = LocalUserInsertForm::test_form_admin(inserted_delores_person.id); let _inserted_delores_local_user = LocalUser::create(pool, &delores_local_user_form, vec![]).await?; let admin_person_ids = vec![inserted_fiona_person.id, inserted_delores_person.id]; // Make sure fiona is marked as a higher admin than delores, and vice versa let fiona_higher_check = LocalUser::is_higher_admin_check(pool, inserted_fiona_person.id, admin_person_ids.clone()) .await; assert!(fiona_higher_check.is_ok()); // This should throw an error, since delores was added later let delores_higher_check = LocalUser::is_higher_admin_check(pool, inserted_delores_person.id, admin_person_ids).await; assert!(delores_higher_check.is_err()); Instance::delete(pool, inserted_instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_email_taken() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let darwin_email = "charles.darwin@gmail.com"; let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let darwin_person = PersonInsertForm::test_form(inserted_instance.id, "darwin"); let inserted_darwin_person = Person::create(pool, &darwin_person).await?; let mut darwin_local_user_form = LocalUserInsertForm::test_form_admin(inserted_darwin_person.id); darwin_local_user_form.email = Some(darwin_email.into()); let _inserted_darwin_local_user = LocalUser::create(pool, &darwin_local_user_form, vec![]).await?; let check = LocalUser::check_is_email_taken(pool, darwin_email).await; assert!(check.is_err()); let passed_check = LocalUser::check_is_email_taken(pool, "not_charles@gmail.com").await; assert!(passed_check.is_ok()); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/login_token.rs ================================================ use crate::{ diesel::{ExpressionMethods, QueryDsl}, newtypes::LocalUserId, source::login_token::{LoginToken, LoginTokenCreateForm}, }; use diesel::{delete, dsl::exists, insert_into, select}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::login_token::{dsl::login_token, user_id}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LoginToken { pub async fn create(pool: &mut DbPool<'_>, form: LoginTokenCreateForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(login_token) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } /// Check if the given token is valid for user. pub async fn validate( pool: &mut DbPool<'_>, user_id_: LocalUserId, token_: &str, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( login_token.find(token_).filter(user_id.eq(user_id_)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotLoggedIn.into()) } pub async fn list(pool: &mut DbPool<'_>, user_id_: LocalUserId) -> LemmyResult> { let conn = &mut get_conn(pool).await?; login_token .filter(user_id.eq(user_id_)) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Invalidate specific token on user logout. pub async fn invalidate(pool: &mut DbPool<'_>, token_: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; delete(login_token.find(token_)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } /// Invalidate all logins of given user on password reset/change, or account deletion. pub async fn invalidate_all(pool: &mut DbPool<'_>, user_id_: LocalUserId) -> LemmyResult { let conn = &mut get_conn(pool).await?; delete(login_token.filter(user_id.eq(user_id_))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } ================================================ FILE: crates/db_schema/src/impls/mod.rs ================================================ pub mod activity; pub mod actor_language; pub mod comment; pub mod comment_report; pub mod community; pub mod community_community_follow; pub mod community_report; pub mod community_tag; pub mod custom_emoji; pub mod email_verification; pub mod federation_allowlist; pub mod federation_blocklist; pub mod federation_queue_state; pub mod images; pub mod instance; pub mod keyword_block; pub mod language; pub mod local_site; pub mod local_site_rate_limit; pub mod local_site_url_blocklist; pub mod local_user; pub mod login_token; pub mod modlog; pub mod multi_community; pub mod notification; pub mod oauth_account; pub mod oauth_provider; pub mod password_reset_request; pub mod person; pub mod post; pub mod post_report; pub mod private_message; pub mod private_message_report; pub mod registration_application; pub mod secret; pub mod site; pub mod tagline; ================================================ FILE: crates/db_schema/src/impls/modlog.rs ================================================ use crate::{ newtypes::{CommunityId, ModlogId}, source::{ comment::Comment, modlog::{Modlog, ModlogInsertForm}, person::Person, post::Post, }, }; use chrono::{DateTime, Utc}; use diesel::dsl::insert_into; use diesel_async::RunQueryDsl; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::modlog; use lemmy_db_schema_file::{InstanceId, PersonId, enums::ModlogKind}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Modlog { pub async fn create<'a>( pool: &mut DbPool<'_>, form: &[ModlogInsertForm<'a>], ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; insert_into(modlog::table) .values(form) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } } impl<'a> ModlogInsertForm<'a> { pub fn admin_ban( mod_person: &Person, target_person_id: PersonId, banned: bool, expires_at: Option>, reason: &'a str, ) -> Self { Self { reason: Some(reason), expires_at, target_person_id: Some(target_person_id), target_instance_id: Some(mod_person.instance_id), ..ModlogInsertForm::new(ModlogKind::AdminBan, !banned, mod_person.id) } } pub fn admin_add(mod_person: &Person, target_person_id: PersonId, added: bool) -> Self { Self { target_person_id: Some(target_person_id), target_instance_id: Some(mod_person.instance_id), ..ModlogInsertForm::new(ModlogKind::AdminAdd, !added, mod_person.id) } } pub fn mod_remove_post( mod_person_id: PersonId, post: &Post, removed: bool, reason: &'a str, bulk_action_parent_id: Option, ) -> Self { Self { reason: Some(reason), target_post_id: Some(post.id), target_community_id: Some(post.community_id), target_person_id: Some(post.creator_id), bulk_action_parent_id, ..ModlogInsertForm::new(ModlogKind::ModRemovePost, !removed, mod_person_id) } } pub fn mod_remove_comment( mod_person_id: PersonId, comment: &Comment, community_id: CommunityId, removed: bool, reason: &'a str, bulk_action_parent_id: Option, ) -> Self { Self { reason: Some(reason), target_comment_id: Some(comment.id), target_post_id: Some(comment.post_id), target_community_id: Some(community_id), target_person_id: Some(comment.creator_id), bulk_action_parent_id, ..ModlogInsertForm::new(ModlogKind::ModRemoveComment, !removed, mod_person_id) } } pub fn mod_lock_comment( mod_person_id: PersonId, comment: &Comment, community_id: CommunityId, removed: bool, reason: &'a str, ) -> Self { Self { reason: Some(reason), target_comment_id: Some(comment.id), target_post_id: Some(comment.post_id), target_community_id: Some(community_id), target_person_id: Some(comment.creator_id), ..ModlogInsertForm::new(ModlogKind::ModLockComment, !removed, mod_person_id) } } pub fn mod_lock_post( mod_person_id: PersonId, post: &Post, locked: bool, reason: &'a str, ) -> Self { Self { reason: Some(reason), target_post_id: Some(post.id), target_community_id: Some(post.community_id), target_person_id: Some(post.creator_id), ..ModlogInsertForm::new(ModlogKind::ModLockPost, !locked, mod_person_id) } } pub fn mod_create_comment_warning( mod_person_id: PersonId, comment: &Comment, community_id: CommunityId, reason: &'a str, ) -> Self { Self { reason: Some(reason), target_comment_id: Some(comment.id), target_post_id: Some(comment.post_id), target_community_id: Some(community_id), target_person_id: Some(comment.creator_id), ..ModlogInsertForm::new(ModlogKind::ModWarnComment, false, mod_person_id) } } pub fn mod_create_post_warning(mod_person_id: PersonId, post: &Post, reason: &'a str) -> Self { Self { reason: Some(reason), target_post_id: Some(post.id), target_community_id: Some(post.community_id), target_person_id: Some(post.creator_id), ..ModlogInsertForm::new(ModlogKind::ModWarnPost, false, mod_person_id) } } pub fn admin_remove_community( mod_person: &Person, community_id: CommunityId, community_owner_id: Option, removed: bool, reason: &'a str, ) -> Self { Self { reason: Some(reason), target_community_id: Some(community_id), target_person_id: community_owner_id, target_instance_id: Some(mod_person.instance_id), ..ModlogInsertForm::new(ModlogKind::AdminRemoveCommunity, !removed, mod_person.id) } } pub fn mod_change_community_visibility( mod_person_id: PersonId, community_id: CommunityId, ) -> Self { Self { target_community_id: Some(community_id), ..ModlogInsertForm::new( ModlogKind::ModChangeCommunityVisibility, false, mod_person_id, ) } } pub fn mod_ban_from_community( mod_person_id: PersonId, community_id: CommunityId, target_person_id: PersonId, removed: bool, expires_at: Option>, reason: &'a str, ) -> Self { Self { reason: Some(reason), expires_at, target_community_id: Some(community_id), target_person_id: Some(target_person_id), ..ModlogInsertForm::new(ModlogKind::ModBanFromCommunity, !removed, mod_person_id) } } pub fn mod_add_to_community( mod_person_id: PersonId, community_id: CommunityId, target_person_id: PersonId, added: bool, ) -> Self { Self { target_community_id: Some(community_id), target_person_id: Some(target_person_id), ..ModlogInsertForm::new(ModlogKind::ModAddToCommunity, !added, mod_person_id) } } pub fn mod_transfer_community( mod_person_id: PersonId, community_id: CommunityId, target_person_id: PersonId, ) -> Self { Self { target_community_id: Some(community_id), target_person_id: Some(target_person_id), ..ModlogInsertForm::new(ModlogKind::ModTransferCommunity, false, mod_person_id) } } pub fn admin_allow_instance( mod_person_id: PersonId, instance_id: InstanceId, allow: bool, reason: &'a str, ) -> Self { Self { reason: Some(reason), target_instance_id: Some(instance_id), ..ModlogInsertForm::new(ModlogKind::AdminAllowInstance, !allow, mod_person_id) } } pub fn admin_block_instance( mod_person_id: PersonId, instance_id: InstanceId, block: bool, reason: &'a str, ) -> Self { Self { reason: Some(reason), target_instance_id: Some(instance_id), ..ModlogInsertForm::new(ModlogKind::AdminBlockInstance, !block, mod_person_id) } } pub fn admin_purge_comment( mod_person_id: PersonId, comment: &Comment, community_id: CommunityId, reason: &'a str, ) -> Self { Self { target_post_id: Some(comment.post_id), target_person_id: Some(comment.creator_id), target_community_id: Some(community_id), reason: Some(reason), ..ModlogInsertForm::new(ModlogKind::AdminPurgeComment, false, mod_person_id) } } pub fn admin_purge_post( mod_person_id: PersonId, community_id: CommunityId, reason: &'a str, ) -> Self { Self { target_community_id: Some(community_id), reason: Some(reason), ..ModlogInsertForm::new(ModlogKind::AdminPurgePost, false, mod_person_id) } } pub fn admin_purge_community(mod_person_id: PersonId, reason: &'a str) -> Self { Self { reason: Some(reason), ..ModlogInsertForm::new(ModlogKind::AdminPurgeCommunity, false, mod_person_id) } } pub fn admin_purge_person(mod_person_id: PersonId, reason: &'a str) -> Self { Self { reason: Some(reason), ..ModlogInsertForm::new(ModlogKind::AdminPurgePerson, false, mod_person_id) } } pub fn mod_feature_post_community(mod_person_id: PersonId, post: &Post, featured: bool) -> Self { Self { target_post_id: Some(post.id), target_community_id: Some(post.community_id), ..ModlogInsertForm::new( ModlogKind::ModFeaturePostCommunity, !featured, mod_person_id, ) } } pub fn admin_feature_post_site(mod_person: &Person, post: &Post, featured: bool) -> Self { Self { target_post_id: Some(post.id), target_community_id: Some(post.community_id), target_instance_id: Some(mod_person.instance_id), ..ModlogInsertForm::new(ModlogKind::AdminFeaturePostSite, !featured, mod_person.id) } } } ================================================ FILE: crates/db_schema/src/impls/multi_community.rs ================================================ use crate::{ diesel::{BoolExpressionMethods, OptionalExtension, PgExpressionMethods, SelectableHelper}, newtypes::{CommunityId, MultiCommunityId}, source::{ community::Community, multi_community::{ MultiCommunity, MultiCommunityEntry, MultiCommunityEntryForm, MultiCommunityFollow, MultiCommunityFollowForm, MultiCommunityInsertForm, MultiCommunityUpdateForm, }, }, traits::ApubActor, utils::format_actor_url, }; use diesel::{ ExpressionMethods, QueryDsl, dsl::{delete, exists, insert_into, not}, select, update, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{ PersonId, schema::{ community, instance, multi_community, multi_community_entry, multi_community_follow, person, }, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::functions::lower, }; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use url::Url; const MULTI_COMMUNITY_ENTRY_LIMIT: i8 = 50; impl Crud for MultiCommunity { type InsertForm = MultiCommunityInsertForm; type UpdateForm = MultiCommunityUpdateForm; type IdType = MultiCommunityId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(multi_community::table) .values(form) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, id: MultiCommunityId, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(multi_community::table.find(id)) .set(form) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl MultiCommunity { pub async fn upsert(pool: &mut DbPool<'_>, form: &MultiCommunityInsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(multi_community::table) .values(form) .on_conflict(multi_community::ap_id) .do_update() .set(form) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn follow( pool: &mut DbPool<'_>, form: &MultiCommunityFollowForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(multi_community_follow::table) .values(form) .on_conflict(( multi_community_follow::multi_community_id, multi_community_follow::person_id, )) .do_update() .set(form) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn unfollow( pool: &mut DbPool<'_>, person_id: PersonId, multi_community_id: MultiCommunityId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; delete( multi_community_follow::table .filter(multi_community_follow::multi_community_id.eq(multi_community_id)) .filter(multi_community_follow::person_id.eq(person_id)), ) .execute(conn) .await?; Ok(()) } pub async fn follower_inboxes( pool: &mut DbPool<'_>, multi_community_id: MultiCommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; multi_community_follow::table .inner_join(person::table) .filter(multi_community_follow::multi_community_id.eq(multi_community_id)) .select(person::inbox_url) .distinct() .get_results(conn) .await .optional()? .ok_or(LemmyErrorType::NotFound.into()) } /// Should be called in a transaction together with update() or upsert() pub async fn update_entries( pool: &mut DbPool<'_>, id: MultiCommunityId, new_communities: &Vec, ) -> LemmyResult<(Vec, Vec, bool)> { let conn = &mut get_conn(pool).await?; if new_communities.len() >= usize::try_from(MULTI_COMMUNITY_ENTRY_LIMIT)? { return Err(LemmyErrorType::MultiCommunityEntryLimitReached.into()); } let removed: Vec = delete( multi_community_entry::table .filter(multi_community_entry::multi_community_id.eq(id)) .filter(multi_community_entry::community_id.ne_all(new_communities)), ) .returning(multi_community_entry::community_id) .get_results::(conn) .await?; let removed: Vec = community::table .filter(community::id.eq_any(removed)) .filter(not(community::local)) .get_results(conn) .await?; let forms = new_communities .iter() .map(|community_id| MultiCommunityEntryForm { multi_community_id: id, community_id: *community_id, }) .collect::>(); let added: Vec<_> = insert_into(multi_community_entry::table) .values(forms) .on_conflict_do_nothing() .returning(multi_community_entry::community_id) .get_results::(conn) .await?; let added: Vec = community::table .filter(community::id.eq_any(added)) .filter(not(community::local)) .get_results(conn) .await?; // check if any local user follows the multi-comm let has_local_followers: bool = select(exists( multi_community_follow::table .inner_join(person::table) .inner_join(multi_community::table) .filter(person::local), )) .get_result(conn) .await?; Ok((added, removed, has_local_followers)) } pub async fn read_community_ap_ids( pool: &mut DbPool<'_>, multi_name: &str, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; multi_community::table .inner_join(multi_community_entry::table.inner_join(community::table)) .filter( community::removed .or(community::deleted) .is_distinct_from(true), ) .filter(multi_community::name.eq(multi_name)) .select(community::ap_id) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl ApubActor for MultiCommunity { async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: &DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; multi_community::table .filter(lower(multi_community::ap_id).eq(object_id.to_lowercase())) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } async fn read_from_name( pool: &mut DbPool<'_>, name: &str, domain: Option<&str>, include_deleted: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let mut q = multi_community::table .inner_join(instance::table) .filter(lower(multi_community::name).eq(name.to_lowercase())) .select(MultiCommunity::as_select()) .into_boxed(); if !include_deleted { q = q.filter(multi_community::deleted.eq(false)) } if let Some(domain) = domain { q = q.filter(lower(instance::domain).eq(domain.to_lowercase())) } else { q = q.filter(multi_community::local.eq(true)) } q.first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } fn actor_url(&self, settings: &Settings) -> LemmyResult { let domain = self .ap_id .inner() .domain() .ok_or(LemmyErrorType::NotFound)?; format_actor_url(&self.name, domain, 'm', settings) } fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/m/{name}"))?.into()) } } impl MultiCommunityEntry { pub async fn create(pool: &mut DbPool<'_>, form: &MultiCommunityEntryForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(multi_community_entry::table) .values(form) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn delete(pool: &mut DbPool<'_>, form: &MultiCommunityEntryForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; delete( multi_community_entry::table .filter(multi_community_entry::multi_community_id.eq(form.multi_community_id)) .filter(multi_community_entry::community_id.eq(form.community_id)), ) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } /// Make sure you aren't trying to insert more communities than the entry limit allows. pub async fn check_entry_limit( pool: &mut DbPool<'_>, multi_community_id: MultiCommunityId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let count: i64 = multi_community_entry::table .filter(multi_community_entry::multi_community_id.eq(multi_community_id)) .count() .get_result(conn) .await?; if count >= MULTI_COMMUNITY_ENTRY_LIMIT.into() { Err(LemmyErrorType::MultiCommunityEntryLimitReached.into()) } else { Ok(()) } } pub async fn community_used_in_multiple( pool: &mut DbPool<'_>, form: &MultiCommunityEntryForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; select(exists( multi_community_entry::table .filter(multi_community_entry::multi_community_id.ne(form.multi_community_id)) .filter(multi_community_entry::community_id.eq(form.community_id)), )) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } #[cfg(test)] mod tests { use super::*; use crate::source::{ community::{Community, CommunityInsertForm}, instance::Instance, multi_community::{MultiCommunity, MultiCommunityInsertForm}, person::{Person, PersonInsertForm}, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { multi: MultiCommunity, instance: Instance, community: Community, person: Person, } async fn setup(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let form = PersonInsertForm::test_form(instance.id, "bobby"); let person = Person::create(pool, &form).await?; let form = CommunityInsertForm::new( instance.id, "TIL".into(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &form).await?; let form = MultiCommunityInsertForm::new(person.id, instance.id, "multi".to_string(), String::new()); let multi = MultiCommunity::create(pool, &form).await?; assert_eq!(form.creator_id, multi.creator_id); assert_eq!(form.name, multi.name); Ok(Data { multi, instance, community, person, }) } #[tokio::test] #[serial] async fn test_counts() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = setup(pool).await?; // Make sure there are no counts in the current multi. assert_eq!(0, data.multi.subscribers); assert_eq!(0, data.multi.subscribers_local); assert_eq!(0, data.multi.communities); // Insert a community entry let entry_form = MultiCommunityEntryForm { multi_community_id: data.multi.id, community_id: data.community.id, }; MultiCommunityEntry::create(pool, &entry_form).await?; let after_entry_insert = MultiCommunity::read(pool, data.multi.id).await?; assert_eq!(1, after_entry_insert.communities); MultiCommunityEntry::delete(pool, &entry_form).await?; let after_entry_delete = MultiCommunity::read(pool, data.multi.id).await?; assert_eq!(0, after_entry_delete.communities); let pending_follow_form = MultiCommunityFollowForm { multi_community_id: data.multi.id, person_id: data.person.id, follow_state: CommunityFollowerState::Pending, }; MultiCommunity::follow(pool, &pending_follow_form).await?; let after_pending_follow = MultiCommunity::read(pool, data.multi.id).await?; // Should be 0, since its a pending follow, not approved assert_eq!(0, after_pending_follow.subscribers); assert_eq!(0, after_pending_follow.subscribers_local); // Unfollow (deletes the row), the count should not decrement MultiCommunity::unfollow(pool, data.person.id, data.multi.id).await?; let after_unfollow = MultiCommunity::read(pool, data.multi.id).await?; assert_eq!(0, after_unfollow.subscribers); assert_eq!(0, after_unfollow.subscribers_local); let accepted_follow_form = MultiCommunityFollowForm { multi_community_id: data.multi.id, person_id: data.person.id, follow_state: CommunityFollowerState::Accepted, }; MultiCommunity::follow(pool, &accepted_follow_form).await?; let after_accepted_follow = MultiCommunity::read(pool, data.multi.id).await?; assert_eq!(1, after_accepted_follow.subscribers); assert_eq!(1, after_accepted_follow.subscribers_local); Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_multi_community_apub() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = setup(pool).await?; let multi_read_apub_empty = MultiCommunity::read_community_ap_ids(pool, &data.multi.name).await?; assert!(multi_read_apub_empty.is_empty()); let multi_entries = vec![data.community.id]; MultiCommunity::update_entries(pool, data.multi.id, &multi_entries).await?; let multi_read_apub = MultiCommunity::read_community_ap_ids(pool, &data.multi.name).await?; assert_eq!(vec![data.community.ap_id], multi_read_apub); Instance::delete(pool, data.instance.id).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/notification.rs ================================================ use crate::{ newtypes::{CommentId, NotificationId, PostId}, source::notification::{Notification, NotificationInsertForm}, }; use diesel::{ ExpressionMethods, QueryDsl, delete, dsl::{insert_into, update}, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{PersonId, schema::notification}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Notification { pub async fn create( pool: &mut DbPool<'_>, form: &[NotificationInsertForm], ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; insert_into(notification::table) .values(form) .on_conflict_do_nothing() .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn mark_read_by_comment_and_recipient( pool: &mut DbPool<'_>, comment_id: CommentId, recipient_id: PersonId, read: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update( notification::table .filter(notification::comment_id.eq(comment_id)) .filter(notification::recipient_id.eq(recipient_id)), ) .set(notification::read.eq(read)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn mark_read_by_post_and_recipient( pool: &mut DbPool<'_>, post_id: PostId, recipient_id: PersonId, read: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update( notification::table .filter(notification::post_id.eq(post_id)) .filter(notification::recipient_id.eq(recipient_id)), ) .set(notification::read.eq(read)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn mark_all_as_read( pool: &mut DbPool<'_>, for_recipient_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update( notification::table .filter(notification::recipient_id.eq(for_recipient_id)) .filter(notification::read.eq(false)), ) .set(notification::read.eq(true)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn mark_read_by_id_and_person( pool: &mut DbPool<'_>, notification_id: NotificationId, recipient_id: PersonId, read: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update( notification::table .filter(notification::id.eq(notification_id)) .filter(notification::recipient_id.eq(recipient_id)), ) .set(notification::read.eq(read)) .execute(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Only for tests pub async fn delete(pool: &mut DbPool<'_>, id: NotificationId) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; delete(notification::table.filter(notification::id.eq(id))) .execute(conn) .await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/oauth_account.rs ================================================ use crate::{ newtypes::LocalUserId, source::oauth_account::{OAuthAccount, OAuthAccountInsertForm}, }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::{oauth_account, oauth_account::dsl::local_user_id}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl OAuthAccount { pub async fn create(pool: &mut DbPool<'_>, form: &OAuthAccountInsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(oauth_account::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn delete_user_accounts( pool: &mut DbPool<'_>, for_local_user_id: LocalUserId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::delete(oauth_account::table.filter(local_user_id.eq(for_local_user_id))) .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } ================================================ FILE: crates/db_schema/src/impls/oauth_provider.rs ================================================ use crate::{ newtypes::OAuthProviderId, source::oauth_provider::{ AdminOAuthProvider, OAuthProviderInsertForm, OAuthProviderUpdateForm, PublicOAuthProvider, }, }; use diesel::{QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::oauth_provider; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, traits::Crud, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Crud for AdminOAuthProvider { type InsertForm = OAuthProviderInsertForm; type UpdateForm = OAuthProviderUpdateForm; type IdType = OAuthProviderId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(oauth_provider::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, oauth_provider_id: OAuthProviderId, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(oauth_provider::table.find(oauth_provider_id)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl AdminOAuthProvider { pub async fn get_all(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; oauth_provider::table .order(oauth_provider::id) .select(oauth_provider::all_columns) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub fn convert_providers_to_public( oauth_providers: Vec, ) -> Vec { oauth_providers .into_iter() .filter(|x| x.enabled) .map(|p| PublicOAuthProvider { id: p.id, display_name: p.display_name, authorization_endpoint: p.authorization_endpoint, client_id: p.client_id, scopes: p.scopes, use_pkce: p.use_pkce, }) .collect() } pub async fn get_all_public(pool: &mut DbPool<'_>) -> LemmyResult> { AdminOAuthProvider::get_all(pool) .await .map(Self::convert_providers_to_public) } } ================================================ FILE: crates/db_schema/src/impls/password_reset_request.rs ================================================ use crate::{ newtypes::LocalUserId, source::password_reset_request::{PasswordResetRequest, PasswordResetRequestForm}, }; use diesel::{ ExpressionMethods, IntoSql, delete, dsl::{IntervalDsl, insert_into, now}, sql_types::Timestamptz, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::password_reset_request; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl PasswordResetRequest { pub async fn create( pool: &mut DbPool<'_>, local_user_id: LocalUserId, token_: String, ) -> LemmyResult { let form = PasswordResetRequestForm { local_user_id, token: token_.into(), }; let conn = &mut get_conn(pool).await?; insert_into(password_reset_request::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn read_and_delete(pool: &mut DbPool<'_>, token_: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; delete(password_reset_request::table) .filter(password_reset_request::token.eq(token_)) .filter(password_reset_request::published_at.gt(now.into_sql::() - 1.days())) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } #[cfg(test)] mod tests { use crate::source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, password_reset_request::PasswordResetRequest, person::{Person, PersonInsertForm}, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_password_reset() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); // Setup let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "thommy prw"); let inserted_person = Person::create(pool, &new_person).await?; let new_local_user = LocalUserInsertForm::test_form(inserted_person.id); let inserted_local_user = LocalUser::create(pool, &new_local_user, vec![]).await?; // Create password reset token let token = "nope"; let inserted_password_reset_request = PasswordResetRequest::create(pool, inserted_local_user.id, token.to_string()).await?; // Read it and verify let read_password_reset_request = PasswordResetRequest::read_and_delete(pool, token).await?; assert_eq!( inserted_password_reset_request.id, read_password_reset_request.id ); assert_eq!( inserted_password_reset_request.local_user_id, read_password_reset_request.local_user_id ); assert_eq!( inserted_password_reset_request.token, read_password_reset_request.token ); assert_eq!( inserted_password_reset_request.published_at, read_password_reset_request.published_at ); // Cannot reuse same token again let read_password_reset_request = PasswordResetRequest::read_and_delete(pool, token).await; assert!(read_password_reset_request.is_err()); // Cleanup let num_deleted = Person::delete(pool, inserted_person.id).await?; Instance::delete(pool, inserted_instance.id).await?; assert_eq!(1, num_deleted); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/person.rs ================================================ use crate::{ diesel::{BoolExpressionMethods, NullableExpressionMethods, OptionalExtension}, newtypes::{CommunityId, LocalUserId}, source::person::{ Person, PersonActions, PersonBlockForm, PersonFollowerForm, PersonInsertForm, PersonNoteForm, PersonUpdateForm, }, traits::{ApubActor, Blockable, Followable}, utils::format_actor_url, }; use chrono::Utc; use diesel::{ ExpressionMethods, JoinOnDsl, QueryDsl, dsl::{exists, insert_into, not, select}, expression::SelectableHelper, }; use diesel_async::RunQueryDsl; use diesel_uplete::{UpleteCount, uplete}; use lemmy_db_schema_file::{ InstanceId, PersonId, schema::{instance, instance_actions, local_user, person, person_actions}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::functions::lower, }; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use url::Url; impl Crud for Person { type InsertForm = PersonInsertForm; type UpdateForm = PersonUpdateForm; type IdType = PersonId; // Override this, so that you don't get back deleted async fn read(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult { let conn = &mut get_conn(pool).await?; person::table .filter(person::deleted.eq(false)) .find(person_id) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } async fn create(pool: &mut DbPool<'_>, form: &PersonInsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(person::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, person_id: PersonId, form: &PersonUpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(person::table.find(person_id)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Person { /// Update or insert the person. /// /// This is necessary for federation, because Activitypub doesn't distinguish between these /// actions. pub async fn upsert(pool: &mut DbPool<'_>, form: &PersonInsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(person::table) .values(form) .on_conflict(person::ap_id) .do_update() .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn delete_account( pool: &mut DbPool<'_>, person_id: PersonId, local_instance_id: InstanceId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; // Set the local user email to none, only if they aren't banned locally. let instance_actions_join = instance_actions::table.on( instance_actions::person_id .eq(person_id) .and(instance_actions::instance_id.eq(local_instance_id)), ); let not_banned_local_user_id = local_user::table .left_join(instance_actions_join) .filter(local_user::person_id.eq(person_id)) .filter(instance_actions::received_ban_at.nullable().is_null()) .select(local_user::id) .first::(conn) .await .optional()?; if let Some(local_user_id) = not_banned_local_user_id { diesel::update(local_user::table.find(local_user_id)) .set(local_user::email.eq::>(None)) .execute(conn) .await?; }; diesel::update(person::table.find(person_id)) .set(( person::display_name.eq::>(None), person::avatar.eq::>(None), person::banner.eq::>(None), person::bio.eq::>(None), person::matrix_user_id.eq::>(None), person::deleted.eq(true), person::updated_at.eq(Utc::now()), )) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn check_username_taken(pool: &mut DbPool<'_>, username: &str) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(not(exists( person::table .filter(lower(person::name).eq(username.to_lowercase())) .filter(person::local.eq(true)), ))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::UsernameAlreadyTaken.into()) } } impl PersonInsertForm { pub fn test_form(instance_id: InstanceId, name: &str) -> Self { Self::new(name.to_owned(), "pubkey".to_string(), instance_id) } } impl ApubActor for Person { async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: &DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; person::table .filter(lower(person::ap_id).eq(object_id.to_lowercase())) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } async fn read_from_name( pool: &mut DbPool<'_>, from_name: &str, domain: Option<&str>, include_deleted: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let mut q = person::table .inner_join(instance::table) .into_boxed() .filter(lower(person::name).eq(from_name.to_lowercase())) .select(person::all_columns); if !include_deleted { q = q.filter(person::deleted.eq(false)) } if let Some(domain) = domain { q = q.filter(lower(instance::domain).eq(domain.to_lowercase())) } else { q = q.filter(person::local.eq(true)) } q.first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } fn actor_url(&self, settings: &Settings) -> LemmyResult { let domain = self .ap_id .inner() .domain() .ok_or(LemmyErrorType::NotFound)?; format_actor_url(&self.name, domain, 'u', settings) } fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/u/{name}"))?.into()) } } impl Followable for PersonActions { type Form = PersonFollowerForm; type IdType = PersonId; async fn follow(pool: &mut DbPool<'_>, form: &PersonFollowerForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(person_actions::table) .values(form) .on_conflict((person_actions::person_id, person_actions::target_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } /// Currently no user following async fn follow_accepted(_: &mut DbPool<'_>, _: CommunityId, _: PersonId) -> LemmyResult { Err(LemmyErrorType::NotFound.into()) } async fn unfollow( pool: &mut DbPool<'_>, person_id: PersonId, target_id: Self::IdType, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(person_actions::table.find((person_id, target_id))) .set_null(person_actions::followed_at) .set_null(person_actions::follow_pending) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } } impl Blockable for PersonActions { type Form = PersonBlockForm; type ObjectIdType = PersonId; type ObjectType = Person; async fn block(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(person_actions::table) .values(form) .on_conflict((person_actions::person_id, person_actions::target_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } async fn unblock(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(person_actions::table.find((form.person_id, form.target_id))) .set_null(person_actions::blocked_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::AlreadyExists) } async fn read_block( pool: &mut DbPool<'_>, person_id: PersonId, recipient_id: Self::ObjectIdType, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let find_action = person_actions::table .find((person_id, recipient_id)) .filter(person_actions::blocked_at.is_not_null()); select(not(exists(find_action))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::PersonIsBlocked.into()) } async fn read_blocks_for_person( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let target_person_alias = diesel::alias!(person as person1); person_actions::table .filter(person_actions::blocked_at.is_not_null()) .inner_join(person::table.on(person_actions::person_id.eq(person::id))) .inner_join( target_person_alias.on(person_actions::target_id.eq(target_person_alias.field(person::id))), ) .select(target_person_alias.fields(person::all_columns)) .filter(person_actions::person_id.eq(person_id)) .filter(target_person_alias.field(person::deleted).eq(false)) .order_by(person_actions::blocked_at) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl PersonActions { pub async fn follower_inboxes( pool: &mut DbPool<'_>, for_person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; person_actions::table .filter(person_actions::followed_at.is_not_null()) .inner_join(person::table.on(person_actions::person_id.eq(person::id))) .filter(person_actions::target_id.eq(for_person_id)) .select(person::inbox_url) .distinct() .load(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn note(pool: &mut DbPool<'_>, form: &PersonNoteForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(person_actions::table) .values(form) .on_conflict((person_actions::person_id, person_actions::target_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn delete_note( pool: &mut DbPool<'_>, person_id: PersonId, target_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(person_actions::table.find((person_id, target_id))) .set_null(person_actions::note) .set_null(person_actions::noted_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn like( pool: &mut DbPool<'_>, person_id: PersonId, target_id: PersonId, previous_vote_is_upvote: Option, current_vote_is_upvote: Option, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; // here let (upvotes_inc, downvotes_inc) = match (previous_vote_is_upvote, current_vote_is_upvote) { (None, Some(true)) => (1, 0), (None, Some(false)) => (0, 1), (Some(true), Some(false)) => (-1, 1), (Some(false), Some(true)) => (1, -1), (Some(true), None) => (-1, 0), (Some(false), None) => (0, -1), _ => (0, 0), }; let voted_at = Utc::now(); insert_into(person_actions::table) .values(( person_actions::person_id.eq(person_id), person_actions::target_id.eq(target_id), person_actions::voted_at.eq(voted_at), person_actions::upvotes.eq(upvotes_inc), person_actions::downvotes.eq(downvotes_inc), )) .on_conflict((person_actions::person_id, person_actions::target_id)) .do_update() .set(( person_actions::person_id.eq(person_id), person_actions::target_id.eq(target_id), person_actions::voted_at.eq(voted_at), person_actions::upvotes.eq(person_actions::upvotes + upvotes_inc), person_actions::downvotes.eq(person_actions::downvotes + downvotes_inc), )) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } #[cfg(test)] mod tests { use crate::{ source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentUpdateForm}, community::{Community, CommunityInsertForm}, person::{Person, PersonActions, PersonFollowerForm, PersonInsertForm, PersonUpdateForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm}, }, test_data::TestData, traits::{Followable, Likeable}, }; use diesel_uplete::UpleteCount; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let expected_person = Person { id: data.person.id, name: "holly".into(), display_name: None, avatar: None, banner: None, deleted: false, published_at: data.person.published_at, updated_at: None, ap_id: data.person.ap_id.clone(), bio: None, local: true, bot_account: false, private_key: None, public_key: "pubkey".to_owned(), last_refreshed_at: data.person.published_at, inbox_url: data.person.inbox_url.clone(), matrix_user_id: None, instance_id: data.instance.id, post_count: 0, post_score: 0, comment_count: 0, comment_score: 0, }; let read_person = Person::read(pool, data.person.id).await?; let update_person_form = PersonUpdateForm { ap_id: Some(data.person.ap_id.clone()), ..Default::default() }; let updated_person = Person::update(pool, data.person.id, &update_person_form).await?; assert_eq!(expected_person, read_person); assert_eq!(expected_person, data.person); assert_eq!(expected_person, updated_person); let num_deleted = Person::delete(pool, data.person.id).await?; assert_eq!(1, num_deleted); data.delete(pool).await?; Ok(()) } #[tokio::test] #[serial] async fn follow() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let person_form_2 = PersonInsertForm::test_form(data.instance.id, "michele"); let person_2 = Person::create(pool, &person_form_2).await?; let follow_form = PersonFollowerForm::new(data.person.id, person_2.id, false); let person_follower = PersonActions::follow(pool, &follow_form).await?; assert_eq!(data.person.id, person_follower.target_id); assert_eq!(person_2.id, person_follower.person_id); assert!(person_follower.follow_pending.is_some_and(|x| !x)); let followers = PersonActions::follower_inboxes(pool, data.person.id).await?; assert_eq!(vec![person_2.inbox_url], followers); let unfollow = PersonActions::unfollow(pool, follow_form.person_id, follow_form.target_id).await?; assert_eq!(UpleteCount::only_deleted(1), unfollow); data.delete(pool).await?; Ok(()) } #[tokio::test] #[serial] async fn test_aggregates() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let another_person = PersonInsertForm::test_form(data.instance.id, "jerry_user_agg"); let another_inserted_person = Person::create(pool, &another_person).await?; let new_community = CommunityInsertForm::new( data.instance.id, "TIL_site_agg".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new("A test post".into(), data.person.id, inserted_community.id); let inserted_post = Post::create(pool, &new_post).await?; let post_like = PostLikeForm::new(inserted_post.id, data.person.id, Some(true)); let _inserted_post_like = PostActions::like(pool, &post_like).await?; let comment_form = CommentInsertForm::new(data.person.id, inserted_post.id, "A test comment".into()); let inserted_comment = Comment::create(pool, &comment_form, None).await?; let comment_like = CommentLikeForm::new(inserted_comment.id, data.person.id, Some(true)); CommentActions::like(pool, &comment_like).await?; let child_comment_form = CommentInsertForm::new(data.person.id, inserted_post.id, "A test comment".into()); let inserted_child_comment = Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?; let child_comment_like = CommentLikeForm::new( inserted_child_comment.id, another_inserted_person.id, Some(true), ); CommentActions::like(pool, &child_comment_like).await?; let person_aggregates_before_delete = Person::read(pool, data.person.id).await?; assert_eq!(1, person_aggregates_before_delete.post_count); assert_eq!(1, person_aggregates_before_delete.post_score); assert_eq!(2, person_aggregates_before_delete.comment_count); assert_eq!(2, person_aggregates_before_delete.comment_score); // Remove a post like let form = PostLikeForm::new(inserted_post.id, data.person.id, None); PostActions::like(pool, &form).await?; let after_post_like_remove = Person::read(pool, data.person.id).await?; assert_eq!(0, after_post_like_remove.post_score); Comment::update( pool, inserted_comment.id, &CommentUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; Comment::update( pool, inserted_child_comment.id, &CommentUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; let after_parent_comment_removed = Person::read(pool, data.person.id).await?; assert_eq!(0, after_parent_comment_removed.comment_count); // TODO: fix person aggregate comment score calculation // assert_eq!(0, after_parent_comment_removed.comment_score); // Remove a parent comment (the scores should also be removed) Comment::delete(pool, inserted_comment.id).await?; Comment::delete(pool, inserted_child_comment.id).await?; let after_parent_comment_delete = Person::read(pool, data.person.id).await?; assert_eq!(0, after_parent_comment_delete.comment_count); // TODO: fix person aggregate comment score calculation // assert_eq!(0, after_parent_comment_delete.comment_score); // Add in the two comments again, then delete the post. let new_parent_comment = Comment::create(pool, &comment_form, None).await?; let _new_child_comment = Comment::create(pool, &child_comment_form, Some(&new_parent_comment.path)).await?; let comment_like = CommentLikeForm::new(new_parent_comment.id, data.person.id, Some(true)); CommentActions::like(pool, &comment_like).await?; let after_comment_add = Person::read(pool, data.person.id).await?; assert_eq!(2, after_comment_add.comment_count); // TODO: fix person aggregate comment score calculation // assert_eq!(1, after_comment_add.comment_score); Post::delete(pool, inserted_post.id).await?; let after_post_delete = Person::read(pool, data.person.id).await?; // TODO: fix person aggregate comment score calculation // assert_eq!(0, after_post_delete.comment_score); assert_eq!(0, after_post_delete.comment_count); assert_eq!(0, after_post_delete.post_score); assert_eq!(0, after_post_delete.post_count); // This should delete all the associated rows, and fire triggers let person_num_deleted = Person::delete(pool, data.person.id).await?; assert_eq!(1, person_num_deleted); Person::delete(pool, another_inserted_person.id).await?; // Delete the community let community_num_deleted = Community::delete(pool, inserted_community.id).await?; assert_eq!(1, community_num_deleted); // Should be none found let after_delete = Person::read(pool, data.person.id).await; assert!(after_delete.is_err()); data.delete(pool).await?; Ok(()) } #[tokio::test] #[serial] async fn person_vote_counts() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = TestData::create(pool).await?; let person_form = PersonInsertForm::test_form(data.instance.id, "jerry_user_agg"); let other_person = Person::create(pool, &person_form).await?; // initial upvote let res = PersonActions::like(pool, data.person.id, other_person.id, None, Some(true)).await?; assert_eq!(Some(1), res.upvotes); assert_eq!(Some(0), res.downvotes); // change upvote to downvote let res = PersonActions::like( pool, data.person.id, other_person.id, Some(true), Some(false), ) .await?; assert_eq!(Some(0), res.upvotes); assert_eq!(Some(1), res.downvotes); // downvote a different item let res = PersonActions::like(pool, data.person.id, other_person.id, None, Some(false)).await?; assert_eq!(Some(0), res.upvotes); assert_eq!(Some(2), res.downvotes); // remove the downvote let res = PersonActions::like(pool, data.person.id, other_person.id, Some(false), None).await?; assert_eq!(Some(0), res.upvotes); assert_eq!(Some(1), res.downvotes); data.delete(pool).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/post.rs ================================================ use crate::{ newtypes::{CommunityId, PostId}, source::post::{ Post, PostActions, PostHideForm, PostInsertForm, PostLikeForm, PostReadCommentsForm, PostReadForm, PostSavedForm, PostUpdateForm, }, traits::{Likeable, Saveable}, utils::{DELETED_REPLACEMENT_TEXT, FETCH_LIMIT_MAX, SITEMAP_DAYS, SITEMAP_LIMIT}, }; use chrono::{DateTime, Utc}; use diesel::{ BoolExpressionMethods, DecoratableTarget, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, OptionalExtension, QueryDsl, dsl::{count, insert_into, not, update}, expression::SelectableHelper, }; use diesel_async::RunQueryDsl; use diesel_uplete::{UpleteCount, uplete}; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::PostNotificationsMode, schema::{community, local_user, person, post, post_actions}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::{ functions::{coalesce, hot_rank, scaled_rank}, now, }, }; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use url::Url; impl Crud for Post { type InsertForm = PostInsertForm; type UpdateForm = PostUpdateForm; type IdType = PostId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, post_id: PostId, new_post: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(post::table.find(post_id)) .set(new_post) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn read(pool: &mut DbPool<'_>, id: PostId) -> LemmyResult { let conn = &mut get_conn(pool).await?; post::table .find(id) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl Post { pub async fn insert_apub( pool: &mut DbPool<'_>, timestamp: DateTime, form: &PostInsertForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post::table) .values(form) .on_conflict(post::ap_id) .filter_target(coalesce(post::updated_at, post::published_at).lt(timestamp)) .do_update() .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn list_featured_for_community( pool: &mut DbPool<'_>, the_community_id: CommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; post::table .filter(post::community_id.eq(the_community_id)) .filter(post::deleted.eq(false)) .filter(post::removed.eq(false)) .filter(post::featured_community.eq(true)) .then_order_by(post::published_at.desc()) .limit(FETCH_LIMIT_MAX.try_into()?) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn list_for_sitemap( pool: &mut DbPool<'_>, ) -> LemmyResult)>> { let conn = &mut get_conn(pool).await?; post::table .select((post::ap_id, coalesce(post::updated_at, post::published_at))) .filter(post::local.eq(true)) .filter(post::deleted.eq(false)) .filter(post::removed.eq(false)) .filter(post::published_at.ge(Utc::now().naive_utc() - SITEMAP_DAYS)) .order(post::published_at.desc()) .limit(SITEMAP_LIMIT) .load::<(DbUrl, chrono::DateTime)>(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn permadelete_for_creator( pool: &mut DbPool<'_>, for_creator_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(post::table.filter(post::creator_id.eq(for_creator_id))) .set(( post::name.eq(DELETED_REPLACEMENT_TEXT), post::url.eq(Option::<&str>::None), post::body.eq(DELETED_REPLACEMENT_TEXT), post::deleted.eq(true), post::updated_at.eq(Utc::now()), )) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn creator_post_ids_in_community( pool: &mut DbPool<'_>, creator_id: PersonId, community_id: CommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; post::table .filter(post::creator_id.eq(creator_id)) .filter(post::community_id.eq(community_id)) .select(post::id) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Diesel can't update from join unfortunately, so you sometimes need to fetch a list of post_ids /// for a creator. async fn creator_post_ids_in_instance( pool: &mut DbPool<'_>, creator_id: PersonId, instance_id: InstanceId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; post::table .inner_join(community::table) .filter(post::creator_id.eq(creator_id)) .filter(community::instance_id.eq(instance_id)) .select(post::id) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update_removed_for_creator_and_community( pool: &mut DbPool<'_>, creator_id: PersonId, community_id: CommunityId, removed: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; update(post::table) .filter(post::creator_id.eq(creator_id)) .filter(post::community_id.eq(community_id)) .set((post::removed.eq(removed), post::updated_at.eq(Utc::now()))) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn update_removed_for_creator_and_instance( pool: &mut DbPool<'_>, creator_id: PersonId, instance_id: InstanceId, removed: bool, ) -> LemmyResult> { let post_ids = Self::creator_post_ids_in_instance(pool, creator_id, instance_id).await?; let conn = &mut get_conn(pool).await?; update(post::table) .filter(post::id.eq_any(post_ids.clone())) .set((post::removed.eq(removed), post::updated_at.eq(Utc::now()))) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn update_removed_for_creator( pool: &mut DbPool<'_>, creator_id: PersonId, removed: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; update(post::table) .filter(post::creator_id.eq(creator_id)) .set((post::removed.eq(removed), post::updated_at.eq(Utc::now()))) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub fn is_post_creator(person_id: PersonId, post_creator_id: PersonId) -> bool { person_id == post_creator_id } pub async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; post::table .filter(post::ap_id.eq(object_id)) .filter(post::scheduled_publish_time_at.is_null()) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn delete_from_apub_id( pool: &mut DbPool<'_>, object_id: Url, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let object_id: DbUrl = object_id.into(); diesel::update(post::table.filter(post::ap_id.eq(object_id))) .set(post::deleted.eq(true)) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn user_scheduled_post_count( person_id: PersonId, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; post::table .inner_join(person::table) .inner_join(community::table) // find all posts which have scheduled_publish_time that is in the future .filter(post::scheduled_publish_time_at.is_not_null()) .filter(coalesce(post::scheduled_publish_time_at, now()).gt(now())) // make sure the post and community are still around .filter(not(post::deleted.or(post::removed))) .filter(not(community::removed.or(community::deleted))) // only posts by specified user .filter(post::creator_id.eq(person_id)) .select(count(post::id)) .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update_ranks(pool: &mut DbPool<'_>, post_id: PostId) -> LemmyResult { let conn = &mut get_conn(pool).await?; // Diesel can't update based on a join, which is necessary for the scaled_rank // https://github.com/diesel-rs/diesel/issues/1478 // Just select the metrics we need manually, for now, since its a single post anyway let interactions_month = community::table .select(community::interactions_month) .inner_join(post::table.on(community::id.eq(post::community_id))) .filter(post::id.eq(post_id)) .first::(conn) .await?; diesel::update(post::table.find(post_id)) .set(( post::hot_rank.eq(hot_rank(post::score, post::published_at)), post::hot_rank_active.eq(hot_rank( post::score, coalesce(post::newest_comment_time_necro_at, post::published_at), )), post::scaled_rank.eq(scaled_rank( post::score, post::published_at, interactions_month, )), )) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub fn local_url(&self, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/post/{}", self.id))?) } /// The comment was created locally and sent back, indicating that the community accepted it pub async fn set_not_pending(&self, pool: &mut DbPool<'_>) -> LemmyResult<()> { if self.local && self.federation_pending { let form = PostUpdateForm { federation_pending: Some(false), ..Default::default() }; Post::update(pool, self.id, &form).await?; } Ok(()) } } impl Likeable for PostActions { type Form = PostLikeForm; type IdType = PostId; async fn like(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post_actions::table) .values(form) .on_conflict((post_actions::post_id, post_actions::person_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn remove_all_likes( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(post_actions::table.filter(post_actions::person_id.eq(person_id))) .set_null(post_actions::vote_is_upvote) .set_null(post_actions::voted_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn remove_likes_in_community( pool: &mut DbPool<'_>, person_id: PersonId, community_id: CommunityId, ) -> LemmyResult { let post_ids = Post::creator_post_ids_in_community(pool, person_id, community_id).await?; let conn = &mut get_conn(pool).await?; uplete(post_actions::table.filter(post_actions::post_id.eq_any(post_ids.clone()))) .set_null(post_actions::vote_is_upvote) .set_null(post_actions::voted_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Saveable for PostActions { type Form = PostSavedForm; async fn save(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post_actions::table) .values(form) .on_conflict((post_actions::post_id, post_actions::person_id)) .do_update() .set(form) .returning(Self::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn unsave(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete(post_actions::table.find((form.person_id, form.post_id))) .set_null(post_actions::saved_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl PostActions { pub async fn mark_as_unread( pool: &mut DbPool<'_>, person_id: PersonId, post_ids: &[PostId], ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let post_ids: Vec<_> = post_ids.to_vec(); uplete( post_actions::table .filter(post_actions::post_id.eq_any(post_ids)) .filter(post_actions::person_id.eq(person_id)), ) .set_null(post_actions::read_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn mark_as_read( pool: &mut DbPool<'_>, person_id: PersonId, post_ids: &[PostId], ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let forms: Vec<_> = post_ids .iter() .map(|post_id| PostReadForm::new(*post_id, person_id)) .collect(); insert_into(post_actions::table) .values(forms) .on_conflict((post_actions::person_id, post_actions::post_id)) .do_update() .set(post_actions::read_at.eq(now().nullable())) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl PostActions { pub async fn hide(pool: &mut DbPool<'_>, form: &PostHideForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post_actions::table) .values(form) .on_conflict((post_actions::person_id, post_actions::post_id)) .do_update() .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } pub async fn unhide(pool: &mut DbPool<'_>, form: &PostHideForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; uplete( post_actions::table .filter(post_actions::post_id.eq(form.post_id)) .filter(post_actions::person_id.eq(form.person_id)), ) .set_null(post_actions::hidden_at) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl PostActions { pub async fn update_read_comments( pool: &mut DbPool<'_>, form: &PostReadCommentsForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post_actions::table) .values(form) .on_conflict((post_actions::person_id, post_actions::post_id)) .do_update() .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl PostActions { pub async fn read( pool: &mut DbPool<'_>, post_id: PostId, person_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; post_actions::table .find((person_id, post_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn update_notification_state( post_id: PostId, person_id: PersonId, new_state: PostNotificationsMode, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let form = ( post_actions::person_id.eq(person_id), post_actions::post_id.eq(post_id), post_actions::notifications.eq(new_state), ); insert_into(post_actions::table) .values(form.clone()) .on_conflict((post_actions::person_id, post_actions::post_id)) .do_update() .set(form) .execute(conn) .await?; Ok(()) } pub async fn list_subscribers( post_id: PostId, pool: &mut DbPool<'_>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; post_actions::table .inner_join(local_user::table.on(post_actions::person_id.eq(local_user::person_id))) .filter(post_actions::post_id.eq(post_id)) .filter(post_actions::notifications.eq(PostNotificationsMode::AllComments)) .select(local_user::person_id) .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } #[cfg(test)] mod tests { use crate::{ source::{ comment::{Comment, CommentInsertForm, CommentUpdateForm}, community::{Community, CommunityInsertForm}, instance::Instance, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm, PostSavedForm, PostUpdateForm}, }, traits::{Likeable, Saveable}, utils::RANK_DEFAULT, }; use chrono::DateTime; use diesel_uplete::UpleteCount; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; use url::Url; #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "jim"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "test community_3".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let new_post2 = PostInsertForm::new( "A test post 2".into(), inserted_person.id, inserted_community.id, ); let inserted_post2 = Post::create(pool, &new_post2).await?; let new_scheduled_post = PostInsertForm { scheduled_publish_time_at: Some(DateTime::from_timestamp_nanos(i64::MAX)), ..PostInsertForm::new("beans".into(), inserted_person.id, inserted_community.id) }; let inserted_scheduled_post = Post::create(pool, &new_scheduled_post).await?; let expected_post = Post { id: inserted_post.id, name: "A test post".into(), url: None, body: None, alt_text: None, creator_id: inserted_person.id, community_id: inserted_community.id, published_at: inserted_post.published_at, removed: false, locked: false, nsfw: false, deleted: false, updated_at: None, embed_title: None, embed_description: None, embed_video_url: None, embed_video_width: None, embed_video_height: None, thumbnail_url: None, ap_id: Url::parse(&format!("https://lemmy-alpha/post/{}", inserted_post.id))?.into(), local: true, language_id: Default::default(), featured_community: false, featured_local: false, url_content_type: None, scheduled_publish_time_at: None, comments: 0, controversy_rank: 0.0, downvotes: 0, upvotes: 1, score: 1, hot_rank: RANK_DEFAULT, hot_rank_active: RANK_DEFAULT, newest_comment_time_at: None, newest_comment_time_necro_at: None, report_count: 0, scaled_rank: RANK_DEFAULT, unresolved_report_count: 0, federation_pending: false, }; // Post Like let post_like_form = PostLikeForm::new(inserted_post.id, inserted_person.id, Some(true)); let inserted_post_like = PostActions::like(pool, &post_like_form).await?; assert_eq!(Some(true), inserted_post_like.vote_is_upvote); // Post Save let post_saved_form = PostSavedForm::new(inserted_post.id, inserted_person.id); let inserted_post_saved = PostActions::save(pool, &post_saved_form).await?; assert!(inserted_post_saved.saved_at.is_some()); // Mark 2 posts as read PostActions::mark_as_read(pool, inserted_person.id, &[inserted_post.id]).await?; PostActions::mark_as_read(pool, inserted_person.id, &[inserted_post2.id]).await?; let read_post = Post::read(pool, inserted_post.id).await?; let new_post_update = PostUpdateForm { name: Some("A test post".into()), ..Default::default() }; let updated_post = Post::update(pool, inserted_post.id, &new_post_update).await?; // Scheduled post count let scheduled_post_count = Post::user_scheduled_post_count(inserted_person.id, pool).await?; assert_eq!(1, scheduled_post_count); let form = PostLikeForm::new(inserted_post.id, inserted_person.id, None); PostActions::like(pool, &form).await?; let saved_removed = PostActions::unsave(pool, &post_saved_form).await?; assert_eq!(UpleteCount::only_updated(1), saved_removed); let read_removed_1 = PostActions::mark_as_unread(pool, inserted_person.id, &[inserted_post.id]).await?; assert_eq!(UpleteCount::only_deleted(1), read_removed_1); let read_removed_2 = PostActions::mark_as_unread(pool, inserted_person.id, &[inserted_post2.id]).await?; assert_eq!(UpleteCount::only_deleted(1), read_removed_2); let num_deleted = Post::delete(pool, inserted_post.id).await? + Post::delete(pool, inserted_post2.id).await? + Post::delete(pool, inserted_scheduled_post.id).await?; assert_eq!(3, num_deleted); Community::delete(pool, inserted_community.id).await?; Person::delete(pool, inserted_person.id).await?; Instance::delete(pool, inserted_instance.id).await?; assert_eq!(expected_post, read_post); assert_eq!(expected_post, updated_post); Ok(()) } #[tokio::test] #[serial] async fn test_aggregates() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "thommy_community_agg"); let inserted_person = Person::create(pool, &new_person).await?; let another_person = PersonInsertForm::test_form(inserted_instance.id, "jerry_community_agg"); let another_inserted_person = Person::create(pool, &another_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "TIL_community_agg".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; let child_comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let inserted_child_comment = Comment::create(pool, &child_comment_form, Some(&inserted_comment.path)).await?; let post_like = PostLikeForm::new(inserted_post.id, inserted_person.id, Some(true)); PostActions::like(pool, &post_like).await?; let post_aggs_before_delete = Post::read(pool, inserted_post.id).await?; assert_eq!(2, post_aggs_before_delete.comments); assert_eq!(1, post_aggs_before_delete.score); assert_eq!(1, post_aggs_before_delete.upvotes); assert_eq!(0, post_aggs_before_delete.downvotes); // Add a post dislike from the other person let post_dislike = PostLikeForm::new(inserted_post.id, another_inserted_person.id, Some(false)); PostActions::like(pool, &post_dislike).await?; let post_aggs_after_dislike = Post::read(pool, inserted_post.id).await?; assert_eq!(2, post_aggs_after_dislike.comments); assert_eq!(0, post_aggs_after_dislike.score); assert_eq!(1, post_aggs_after_dislike.upvotes); assert_eq!(1, post_aggs_after_dislike.downvotes); // Remove the comments Comment::delete(pool, inserted_comment.id).await?; Comment::delete(pool, inserted_child_comment.id).await?; let after_comment_delete = Post::read(pool, inserted_post.id).await?; assert_eq!(0, after_comment_delete.comments); assert_eq!(0, after_comment_delete.score); assert_eq!(1, after_comment_delete.upvotes); assert_eq!(1, after_comment_delete.downvotes); // Remove the first post like let form = PostLikeForm::new(inserted_post.id, inserted_person.id, None); PostActions::like(pool, &form).await?; let after_like_remove = Post::read(pool, inserted_post.id).await?; assert_eq!(0, after_like_remove.comments); assert_eq!(-1, after_like_remove.score); assert_eq!(0, after_like_remove.upvotes); assert_eq!(1, after_like_remove.downvotes); // This should delete all the associated rows, and fire triggers Person::delete(pool, another_inserted_person.id).await?; let person_num_deleted = Person::delete(pool, inserted_person.id).await?; assert_eq!(1, person_num_deleted); // Delete the community let community_num_deleted = Community::delete(pool, inserted_community.id).await?; assert_eq!(1, community_num_deleted); // Should be none found, since the creator was deleted let after_delete = Post::read(pool, inserted_post.id).await; assert!(after_delete.is_err()); Instance::delete(pool, inserted_instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_aggregates_soft_delete() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "thommy_community_agg"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "TIL_community_agg".into(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post".into(), inserted_person.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_person.id, inserted_post.id, "A test comment".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; let post_aggregates_before = Post::read(pool, inserted_post.id).await?; assert_eq!(1, post_aggregates_before.comments); Comment::update( pool, inserted_comment.id, &CommentUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; let post_aggregates_after_remove = Post::read(pool, inserted_post.id).await?; assert_eq!(0, post_aggregates_after_remove.comments); Comment::update( pool, inserted_comment.id, &CommentUpdateForm { removed: Some(false), ..Default::default() }, ) .await?; Comment::update( pool, inserted_comment.id, &CommentUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; let post_aggregates_after_delete = Post::read(pool, inserted_post.id).await?; assert_eq!(0, post_aggregates_after_delete.comments); Comment::update( pool, inserted_comment.id, &CommentUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; let post_aggregates_after_delete_remove = Post::read(pool, inserted_post.id).await?; assert_eq!(0, post_aggregates_after_delete_remove.comments); Comment::delete(pool, inserted_comment.id).await?; Post::delete(pool, inserted_post.id).await?; Person::delete(pool, inserted_person.id).await?; Community::delete(pool, inserted_community.id).await?; Instance::delete(pool, inserted_instance.id).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/post_report.rs ================================================ use crate::{ newtypes::{PostId, PostReportId}, source::post_report::{PostReport, PostReportForm}, traits::Reportable, }; use chrono::Utc; use diesel::{ BoolExpressionMethods, ExpressionMethods, QueryDsl, dsl::{insert_into, update}, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{PersonId, schema::post_report}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Reportable for PostReport { type Form = PostReportForm; type IdType = PostReportId; type ObjectIdType = PostId; async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(post_report::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update_resolved( pool: &mut DbPool<'_>, report_id: Self::IdType, by_resolver_id: PersonId, is_resolved: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(post_report::table.find(report_id)) .set(( post_report::resolved.eq(is_resolved), post_report::resolver_id.eq(by_resolver_id), post_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_apub( pool: &mut DbPool<'_>, object_id: Self::ObjectIdType, report_creator_id: PersonId, resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update( post_report::table.filter( post_report::post_id .eq(object_id) .and(post_report::creator_id.eq(report_creator_id)), ), ) .set(( post_report::resolved.eq(true), post_report::resolver_id.eq(resolver_id), post_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_all_for_object( pool: &mut DbPool<'_>, post_id_: PostId, by_resolver_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(post_report::table.filter(post_report::post_id.eq(post_id_))) .set(( post_report::resolved.eq(true), post_report::resolver_id.eq(by_resolver_id), post_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } #[cfg(test)] mod tests { use super::*; use crate::source::{ community::{Community, CommunityInsertForm}, instance::Instance, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use serial_test::serial; async fn init(pool: &mut DbPool<'_>) -> LemmyResult<(Person, PostReport)> { let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let person_form = PersonInsertForm::test_form(inserted_instance.id, "jim"); let person = Person::create(pool, &person_form).await?; let community_form = CommunityInsertForm::new( inserted_instance.id, "test community_4".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let form = PostInsertForm::new("A test post".into(), person.id, community.id); let post = Post::create(pool, &form).await?; let report_form = PostReportForm { post_id: post.id, creator_id: person.id, reason: "my reason".to_string(), ..Default::default() }; let report = PostReport::report(pool, &report_form).await?; Ok((person, report)) } #[tokio::test] #[serial] async fn test_resolve_post_report() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let (person, report) = init(pool).await?; let resolved_count = PostReport::update_resolved(pool, report.id, person.id, true).await?; assert_eq!(resolved_count, 1); let unresolved_count = PostReport::update_resolved(pool, report.id, person.id, false).await?; assert_eq!(unresolved_count, 1); Person::delete(pool, person.id).await?; Post::delete(pool, report.post_id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_resolve_all_post_reports() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let (person, report) = init(pool).await?; let resolved_count = PostReport::resolve_all_for_object(pool, report.post_id, person.id).await?; assert_eq!(resolved_count, 1); Person::delete(pool, person.id).await?; Post::delete(pool, report.post_id).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/private_message.rs ================================================ use crate::{ diesel::{DecoratableTarget, OptionalExtension}, newtypes::PrivateMessageId, source::{ person::Person, private_message::{PrivateMessage, PrivateMessageInsertForm, PrivateMessageUpdateForm}, }, }; use chrono::{DateTime, Utc}; use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{PersonId, schema::private_message}; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::functions::coalesce, }; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use url::Url; impl Crud for PrivateMessage { type InsertForm = PrivateMessageInsertForm; type UpdateForm = PrivateMessageUpdateForm; type IdType = PrivateMessageId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(private_message::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, private_message_id: PrivateMessageId, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(private_message::table.find(private_message_id)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl PrivateMessage { pub async fn insert_apub( pool: &mut DbPool<'_>, timestamp: DateTime, form: &PrivateMessageInsertForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(private_message::table) .values(form) .on_conflict(private_message::ap_id) .filter_target( coalesce(private_message::updated_at, private_message::published_at).lt(timestamp), ) .do_update() .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } pub async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; private_message::table .filter(private_message::ap_id.eq(object_id)) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } pub fn local_url(&self, settings: &Settings) -> LemmyResult { let domain = settings.get_protocol_and_hostname(); Ok(Url::parse(&format!("{domain}/private_message/{}", self.id))?.into()) } pub async fn update_removed_for_creator( pool: &mut DbPool<'_>, for_creator_id: PersonId, removed: bool, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; diesel::update(private_message::table.filter(private_message::creator_id.eq(for_creator_id))) .set(( private_message::removed.eq(removed), private_message::updated_at.eq(Utc::now()), )) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } /// Dont let creator know that recipient deleted the message pub fn clear_deleted_by_recipient(&mut self, my_person: Option<&Person>) { if Some(self.creator_id) == my_person.map(|p| p.id) { self.deleted_by_recipient = false; } } } #[cfg(test)] mod tests { use crate::source::{ instance::Instance, person::{Person, PersonInsertForm}, private_message::{PrivateMessage, PrivateMessageInsertForm, PrivateMessageUpdateForm}, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; use url::Url; #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let creator_form = PersonInsertForm::test_form(inserted_instance.id, "creator_pm"); let inserted_creator = Person::create(pool, &creator_form).await?; let recipient_form = PersonInsertForm::test_form(inserted_instance.id, "recipient_pm"); let inserted_recipient = Person::create(pool, &recipient_form).await?; let private_message_form = PrivateMessageInsertForm::new( inserted_creator.id, inserted_recipient.id, "A test private message".into(), ); let inserted_private_message = PrivateMessage::create(pool, &private_message_form).await?; let expected_private_message = PrivateMessage { id: inserted_private_message.id, content: "A test private message".into(), creator_id: inserted_creator.id, recipient_id: inserted_recipient.id, deleted: false, updated_at: None, published_at: inserted_private_message.published_at, ap_id: Url::parse(&format!( "https://lemmy-alpha/private_message/{}", inserted_private_message.id ))? .into(), local: true, removed: false, deleted_by_recipient: false, }; let read_private_message = PrivateMessage::read(pool, inserted_private_message.id).await?; let private_message_update_form = PrivateMessageUpdateForm { content: Some("A test private message".into()), ..Default::default() }; let updated_private_message = PrivateMessage::update( pool, inserted_private_message.id, &private_message_update_form, ) .await?; let deleted_private_message = PrivateMessage::update( pool, inserted_private_message.id, &PrivateMessageUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; Person::delete(pool, inserted_creator.id).await?; Person::delete(pool, inserted_recipient.id).await?; Instance::delete(pool, inserted_instance.id).await?; assert_eq!(expected_private_message, read_private_message); assert_eq!(expected_private_message, updated_private_message); assert_eq!(expected_private_message, inserted_private_message); assert!(deleted_private_message.deleted); Ok(()) } } ================================================ FILE: crates/db_schema/src/impls/private_message_report.rs ================================================ use crate::{ newtypes::{PrivateMessageId, PrivateMessageReportId}, source::private_message_report::{PrivateMessageReport, PrivateMessageReportForm}, traits::Reportable, }; use chrono::Utc; use diesel::{ ExpressionMethods, QueryDsl, dsl::{insert_into, update}, }; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{PersonId, schema::private_message_report}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, UntranslatedError}; impl Reportable for PrivateMessageReport { type Form = PrivateMessageReportForm; type IdType = PrivateMessageReportId; type ObjectIdType = PrivateMessageId; async fn report(pool: &mut DbPool<'_>, form: &Self::Form) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(private_message_report::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update_resolved( pool: &mut DbPool<'_>, report_id: Self::IdType, by_resolver_id: PersonId, is_resolved: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; update(private_message_report::table.find(report_id)) .set(( private_message_report::resolved.eq(is_resolved), private_message_report::resolver_id.eq(by_resolver_id), private_message_report::updated_at.eq(Utc::now()), )) .execute(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } async fn resolve_apub( _pool: &mut DbPool<'_>, _object_id: Self::ObjectIdType, _report_creator_id: PersonId, _resolver_id: PersonId, ) -> LemmyResult { Err(UntranslatedError::Unreachable.into()) } // This is unused because private message doesn't have remove handler async fn resolve_all_for_object( _pool: &mut DbPool<'_>, _pm_id_: PrivateMessageId, _by_resolver_id: PersonId, ) -> LemmyResult { Err(LemmyErrorType::NotFound.into()) } } ================================================ FILE: crates/db_schema/src/impls/registration_application.rs ================================================ use crate::{ newtypes::{LocalUserId, RegistrationApplicationId}, source::registration_application::{ RegistrationApplication, RegistrationApplicationInsertForm, RegistrationApplicationUpdateForm, }, }; use diesel::{ExpressionMethods, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::registration_application; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, traits::Crud, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Crud for RegistrationApplication { type InsertForm = RegistrationApplicationInsertForm; type UpdateForm = RegistrationApplicationUpdateForm; type IdType = RegistrationApplicationId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(registration_application::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, id_: Self::IdType, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(registration_application::table.find(id_)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl RegistrationApplication { pub async fn find_by_local_user_id( pool: &mut DbPool<'_>, local_user_id_: LocalUserId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; registration_application::table .filter(registration_application::local_user_id.eq(local_user_id_)) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Fetches the most recent updated application. pub async fn last_updated(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; registration_application::table .filter(registration_application::updated_at.is_not_null()) .order_by(registration_application::updated_at.desc()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// The duration between the last application creation, and its approval / denial time. /// /// Useful for estimating when your application will be approved. pub fn updated_published_duration(&self) -> Option { self .updated_at .map(|updated| (updated - self.published_at).num_seconds()) } /// A missing admin id, means the application is unread #[diesel::dsl::auto_type(no_type_alias)] pub fn is_unread() -> _ { registration_application::admin_id.is_null() } } ================================================ FILE: crates/db_schema/src/impls/secret.rs ================================================ use crate::source::secret::Secret; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::schema::secret::dsl::secret; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Secret { /// Initialize the Secrets from the DB. /// Warning: You should only call this once. pub async fn init(pool: &mut DbPool<'_>) -> LemmyResult { Self::read_secrets(pool).await } async fn read_secrets(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; secret .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } ================================================ FILE: crates/db_schema/src/impls/site.rs ================================================ use crate::{ newtypes::SiteId, source::{ actor_language::SiteLanguage, site::{Site, SiteInsertForm, SiteUpdateForm}, }, }; use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use lemmy_db_schema_file::{InstanceId, schema::site}; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, traits::Crud, utils::functions::lower, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use url::Url; impl Crud for Site { type InsertForm = SiteInsertForm; type UpdateForm = SiteUpdateForm; type IdType = SiteId; /// Use SiteView::read_local, or Site::read_from_apub_id instead async fn read(_pool: &mut DbPool<'_>, _site_id: SiteId) -> LemmyResult { Err(LemmyErrorType::NotFound.into()) } async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let is_new_site = match &form.ap_id { Some(id) => Site::read_from_apub_id(pool, id).await?.is_none(), None => true, }; let conn = &mut get_conn(pool).await?; // Can't do separate insert/update commands because InsertForm/UpdateForm aren't convertible let site = insert_into(site::table) .values(form) .on_conflict(site::ap_id) .do_update() .set(form) .get_result::(conn) .await?; // initialize languages if site is newly created if is_new_site { // initialize with all languages SiteLanguage::update(pool, vec![], &site).await?; } Ok(site) } async fn update( pool: &mut DbPool<'_>, site_id: SiteId, new_site: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(site::table.find(site_id)) .set(new_site) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl Site { pub async fn read_from_instance_id( pool: &mut DbPool<'_>, instance_id: InstanceId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; site::table .filter(site::instance_id.eq(instance_id)) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: &DbUrl, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; site::table .filter(lower(site::ap_id).eq(object_id.to_lowercase())) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read_remote_sites(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; site::table .order_by(site::id) .offset(1) .get_results::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Instance actor is at the root path, so we simply need to clear the path and other unnecessary /// parts of the url. pub fn instance_ap_id_from_url(mut url: Url) -> Url { url.set_fragment(None); url.set_path(""); url.set_query(None); url } } ================================================ FILE: crates/db_schema/src/impls/tagline.rs ================================================ use crate::{ newtypes::TaglineId, source::tagline::{Tagline, TaglineInsertForm, TaglineUpdateForm, tagline_keys as key}, utils::limit_fetch, }; use diesel::{QueryDsl, insert_into}; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema_file::schema::tagline; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, traits::Crud, utils::functions::random, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl Crud for Tagline { type InsertForm = TaglineInsertForm; type UpdateForm = TaglineUpdateForm; type IdType = TaglineId; async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { let conn = &mut get_conn(pool).await?; insert_into(tagline::table) .values(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntCreate) } async fn update( pool: &mut DbPool<'_>, tagline_id: TaglineId, form: &Self::UpdateForm, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; diesel::update(tagline::table.find(tagline_id)) .set(form) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::CouldntUpdate) } } impl PaginationCursorConversion for Tagline { type PaginatedType = Tagline; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Tagline::read(pool, TaglineId(cursor.id()?)).await } } impl Tagline { pub async fn list( pool: &mut DbPool<'_>, page_cursor: Option, limit: Option, ) -> LemmyResult> { let limit = limit_fetch(limit, None)?; let query = tagline::table.limit(limit).into_boxed(); let paginated_query = Self::paginate(query, &page_cursor, SortDirection::Desc, pool, None) .await? .then_order_by(key::published_at) .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, page_cursor) } pub async fn get_random(pool: &mut DbPool<'_>) -> LemmyResult { let conn = &mut get_conn(pool).await?; tagline::table .order(random()) .limit(1) .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } ================================================ FILE: crates/db_schema/src/lib.rs ================================================ #[cfg(feature = "full")] #[macro_use] extern crate diesel; #[cfg(feature = "full")] #[macro_use] extern crate diesel_derive_newtype; #[cfg(feature = "full")] pub mod impls; pub mod newtypes; pub mod source; #[cfg(feature = "full")] pub mod test_data; #[cfg(feature = "full")] pub mod traits; #[cfg(feature = "full")] pub mod utils; use lemmy_db_schema_file::enums::{ModlogKind, NotificationType}; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; #[cfg(feature = "full")] use { diesel::query_source::AliasedField, lemmy_db_schema_file::{ aliases, schema::{instance_actions, person}, }, }; #[derive( EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash, )] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The search sort types. pub enum SearchSortType { #[default] New, Top, Old, } /// The community sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] pub enum CommunitySortType { ActiveSixMonths, #[default] ActiveMonthly, ActiveWeekly, ActiveDaily, Hot, New, Old, NameAsc, NameDesc, Comments, Posts, Subscribers, SubscribersLocal, } /// The local user sort type. #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] pub enum LocalUserSortType { #[default] New, Old, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] pub enum MultiCommunitySortType { New, Old, NameAsc, NameDesc, Communities, #[default] Subscribers, SubscribersLocal, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A listing type for multi-community fetches. pub enum MultiCommunityListingType { /// Content from your own site, as well as all connected / federated sites. All, /// Content from your site only. #[default] Local, /// Content only from communities you've subscribed to. Subscribed, } #[derive( EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash, )] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The type of content returned from a search. pub enum SearchType { #[default] All, Comments, Posts, Communities, Users, MultiCommunities, } #[derive( EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash, )] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A list of possible types for the inbox. pub enum NotificationTypeFilter { #[default] All, #[serde(untagged)] Other(NotificationType), } #[derive( EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash, )] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A list of possible types for the various modlog actions. pub enum ModlogKindFilter { #[default] All, #[serde(untagged)] Other(ModlogKind), } #[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A list of possible types for a person's content. pub enum PersonContentType { All, Comments, Posts, } #[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A list of possible types for reports. pub enum ReportType { All, Posts, Comments, PrivateMessages, Communities, } #[derive( EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash, )] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The feature type for a post. pub enum PostFeatureType { #[default] /// Features to the top of your site. Local, /// Features to the top of the community. Community, } #[derive( EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash, )] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The like_type for a persons liked content. pub enum LikeType { #[default] All, LikedOnly, DislikedOnly, } /// Wrapper for assert_eq! macro. Checks that vec matches the given length, and prints the /// vec on failure. #[macro_export] macro_rules! assert_length { ($len:expr, $vec:expr) => {{ assert_eq!($len, $vec.len(), "Vec has wrong length: {:?}", $vec) }}; } #[cfg(feature = "full")] /// A helper tuple for person 1 alias columns pub type Person1AliasAllColumnsTuple = ( AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, ); #[cfg(feature = "full")] /// A helper tuple for person 2 alias columns pub type Person2AliasAllColumnsTuple = ( AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, ); #[cfg(feature = "full")] /// A helper tuple for more my instance persons actions pub type MyInstancePersonsActionsAllColumnsTuple = ( AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, AliasedField, ); ================================================ FILE: crates/db_schema/src/newtypes.rs ================================================ #[cfg(feature = "full")] use diesel_ltree::Ltree; use serde::{Deserialize, Serialize}; use std::fmt; #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The post id. pub struct PostId(pub i32); impl fmt::Display for PostId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The comment id. pub struct CommentId(pub i32); impl fmt::Display for CommentId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } pub enum PostOrCommentId { Post(PostId), Comment(CommentId), } #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The community id. pub struct CommunityId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The local user id. pub struct LocalUserId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The private message id. pub struct PrivateMessageId(pub i32); impl fmt::Display for PrivateMessageId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct NotificationId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The comment report id. pub struct CommentReportId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The community report id. pub struct CommunityReportId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The post report id. pub struct PostReportId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The private message report id. pub struct PrivateMessageReportId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The site id. pub struct SiteId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The language id. pub struct LanguageId(pub i32); #[derive( Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default, PartialOrd, Ord, )] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ActivityId(pub i64); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The local site id. pub struct LocalSiteId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The custom emoji id. pub struct CustomEmojiId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The tagline id. pub struct TaglineId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The registration application id. pub struct RegistrationApplicationId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The oauth provider id. pub struct OAuthProviderId(pub i32); #[cfg(feature = "full")] #[derive(Serialize, Deserialize)] #[serde(remote = "Ltree")] /// Do remote derivation for the Ltree struct pub struct LtreeDef(pub String); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] /// The report combined id pub struct ReportCombinedId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] /// The person content combined id pub struct PersonContentCombinedId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] /// The person saved combined id pub struct PersonSavedCombinedId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] /// The person liked combined id pub struct PersonLikedCombinedId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] /// The search combined id pub struct SearchCombinedId(i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ModlogId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct MultiCommunityId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The community tag id pub struct CommunityTagId(pub i32); ================================================ FILE: crates/db_schema/src/source/activity.rs ================================================ use crate::newtypes::{ActivityId, CommunityId}; use chrono::{DateTime, Utc}; use diesel::Queryable; use lemmy_db_schema_file::{ enums::ActorType, schema::{received_activity, sent_activity}, }; use lemmy_diesel_utils::dburl::DbUrl; use serde_json::Value; use std::{collections::HashSet, fmt::Debug}; use url::Url; #[derive(Debug, Default, Clone)] /// describes where an activity should be sent pub struct ActivitySendTargets { /// send to these inboxes explicitly pub inboxes: HashSet, /// send to all followers of these local communities pub community_followers_of: Option, /// send to all remote instances pub all_instances: bool, } // todo: in different file? impl ActivitySendTargets { pub fn empty() -> ActivitySendTargets { ActivitySendTargets::default() } pub fn to_inbox(url: Url) -> ActivitySendTargets { let mut a = ActivitySendTargets::empty(); a.inboxes.insert(url); a } pub fn to_local_community_followers(id: CommunityId) -> ActivitySendTargets { let mut a = ActivitySendTargets::empty(); a.community_followers_of = Some(id); a } pub fn to_all_instances() -> ActivitySendTargets { let mut a = ActivitySendTargets::empty(); a.all_instances = true; a } pub fn set_all_instances(&mut self) { self.all_instances = true; } pub fn add_inbox(&mut self, inbox: Url) { self.inboxes.insert(inbox); } pub fn add_inboxes(&mut self, inboxes: Vec) { self.inboxes.extend(inboxes.into_iter().map(Into::into)); } } #[derive(PartialEq, Eq, Debug)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", diesel(table_name = sent_activity))] pub struct SentActivity { pub id: ActivityId, pub ap_id: DbUrl, pub data: Value, pub sensitive: bool, pub published_at: DateTime, pub send_inboxes: Vec>, pub send_community_followers_of: Option, pub send_all_instances: bool, pub actor_type: ActorType, pub actor_apub_id: Option, } #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = sent_activity))] pub struct SentActivityForm { pub ap_id: DbUrl, pub data: Value, pub sensitive: bool, pub send_inboxes: Vec>, pub send_community_followers_of: Option, pub send_all_instances: bool, pub actor_type: ActorType, pub actor_apub_id: DbUrl, } #[derive(PartialEq, Eq, Debug)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(primary_key(ap_id)))] #[cfg_attr(feature = "full", diesel(table_name = received_activity))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct ReceivedActivity { pub ap_id: DbUrl, pub published_at: DateTime, } ================================================ FILE: crates/db_schema/src/source/actor_language.rs ================================================ use crate::newtypes::{CommunityId, LanguageId, LocalUserId, SiteId}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::local_user_language; use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = local_user_language))] #[cfg_attr(feature = "full", diesel(primary_key(local_user_id, language_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct LocalUserLanguage { pub local_user_id: LocalUserId, pub language_id: LanguageId, } #[derive(Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_user_language))] pub struct LocalUserLanguageForm { pub local_user_id: LocalUserId, pub language_id: LanguageId, } #[cfg(feature = "full")] use lemmy_db_schema_file::schema::community_language; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = community_language))] #[cfg_attr(feature = "full", diesel(primary_key(community_id, language_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct CommunityLanguage { pub community_id: CommunityId, pub language_id: LanguageId, } #[derive(Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_language))] pub struct CommunityLanguageForm { pub community_id: CommunityId, pub language_id: LanguageId, } #[cfg(feature = "full")] use lemmy_db_schema_file::schema::site_language; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = site_language))] #[cfg_attr(feature = "full", diesel(primary_key(site_id, language_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct SiteLanguage { pub site_id: SiteId, pub language_id: LanguageId, } #[derive(Clone, Debug)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = site_language))] pub struct SiteLanguageForm { pub site_id: SiteId, pub language_id: LanguageId, } ================================================ FILE: crates/db_schema/src/source/combined/mod.rs ================================================ pub mod person_content; pub mod person_liked; pub mod person_saved; pub mod report; pub mod search; ================================================ FILE: crates/db_schema/src/source/combined/person_content.rs ================================================ use crate::newtypes::{CommentId, PersonContentCombinedId, PostId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::person_content_combined; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = person_content_combined))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = person_content_combined_keys))] /// A combined table for a persons contents (posts and comments) pub struct PersonContentCombined { pub published_at: DateTime, pub creator_id: PersonId, pub post_id: Option, pub comment_id: Option, pub id: PersonContentCombinedId, } ================================================ FILE: crates/db_schema/src/source/combined/person_liked.rs ================================================ use crate::newtypes::{CommentId, PersonLikedCombinedId, PostId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::person_liked_combined; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = person_liked_combined))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = person_liked_combined_keys))] /// A combined person_liked table. pub struct PersonLikedCombined { pub voted_at: DateTime, pub id: PersonLikedCombinedId, pub person_id: PersonId, pub creator_id: PersonId, pub post_id: Option, pub comment_id: Option, pub vote_is_upvote: bool, } ================================================ FILE: crates/db_schema/src/source/combined/person_saved.rs ================================================ use crate::newtypes::{CommentId, PersonSavedCombinedId, PostId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::person_saved_combined; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = person_saved_combined))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = person_saved_combined_keys))] /// A combined person_saved table. pub struct PersonSavedCombined { pub saved_at: DateTime, pub person_id: PersonId, pub creator_id: PersonId, pub post_id: Option, pub comment_id: Option, pub id: PersonSavedCombinedId, } ================================================ FILE: crates/db_schema/src/source/combined/report.rs ================================================ use crate::newtypes::{ CommentReportId, CommunityReportId, PostReportId, PrivateMessageReportId, ReportCombinedId, }; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::report_combined; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = report_combined))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = report_combined_keys))] /// A combined reports table. pub struct ReportCombined { pub id: ReportCombinedId, pub published_at: DateTime, pub post_report_id: Option, pub comment_report_id: Option, pub private_message_report_id: Option, pub community_report_id: Option, pub resolved: bool, } ================================================ FILE: crates/db_schema/src/source/combined/search.rs ================================================ use crate::newtypes::{CommentId, CommunityId, MultiCommunityId, PostId, SearchCombinedId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::search_combined; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = search_combined))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = search_combined_keys))] /// A combined table for a search (posts, comments, communities, persons) pub struct SearchCombined { pub published_at: DateTime, pub score: i32, pub post_id: Option, pub comment_id: Option, pub community_id: Option, pub person_id: Option, pub id: SearchCombinedId, pub multi_community_id: Option, } ================================================ FILE: crates/db_schema/src/source/comment.rs ================================================ use crate::newtypes::{CommentId, LanguageId, PostId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { crate::newtypes::LtreeDef, diesel_ltree::Ltree, i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::{comment, comment_actions}, }; #[skip_serializing_none] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Associations, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] #[cfg_attr(feature = "full", diesel(table_name = comment))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = comment_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A comment. pub struct Comment { pub id: CommentId, pub creator_id: PersonId, pub post_id: PostId, pub content: String, /// Whether the comment has been removed. pub removed: bool, pub published_at: DateTime, pub updated_at: Option>, /// Whether the comment has been deleted by its creator. pub deleted: bool, /// The federated activity id / ap_id. pub ap_id: DbUrl, /// Whether the comment is local. pub local: bool, #[cfg(feature = "full")] #[cfg_attr(feature = "full", serde(with = "LtreeDef"))] #[cfg_attr(feature = "ts-rs", ts(type = "string"))] /// The path / tree location of a comment, separated by dots, ending with the comment's id. Ex: /// 0.24.27 pub path: Ltree, #[cfg(not(feature = "full"))] pub path: String, /// Whether the comment has been distinguished(speaking officially) by a mod. pub distinguished: bool, pub language_id: LanguageId, pub score: i32, pub upvotes: i32, pub downvotes: i32, /// The total number of children in this comment branch. pub child_count: i32, #[serde(skip)] pub hot_rank: f32, #[serde(skip)] pub controversy_rank: f32, pub report_count: i16, pub unresolved_report_count: i16, /// If a local user comments in a remote community, the comment is hidden until it is confirmed /// accepted by the community (by receiving it back via federation). pub federation_pending: bool, /// Whether the comment is locked. pub locked: bool, } #[derive(Debug, Clone, derive_new::new, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset,))] #[cfg_attr(feature = "full", diesel(table_name = comment))] pub struct CommentInsertForm { pub creator_id: PersonId, pub post_id: PostId, pub content: String, #[new(default)] pub removed: Option, #[new(default)] pub published_at: Option>, #[new(default)] pub updated_at: Option>, #[new(default)] pub deleted: Option, #[new(default)] pub ap_id: Option, #[new(default)] pub local: Option, #[new(default)] pub distinguished: Option, #[new(default)] pub language_id: Option, #[new(default)] pub federation_pending: Option, #[new(default)] pub locked: Option, } #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset, Serialize, Deserialize))] #[cfg_attr(feature = "full", diesel(table_name = comment))] pub struct CommentUpdateForm { pub content: Option, pub removed: Option, // Don't use a default Utc::now here, because the create function does a lot of comment updates pub updated_at: Option>>, pub deleted: Option, pub ap_id: Option, pub local: Option, pub distinguished: Option, pub language_id: Option, pub federation_pending: Option, pub locked: Option, } #[skip_serializing_none] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, Associations, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::comment::Comment)))] #[cfg_attr(feature = "full", diesel(table_name = comment_actions))] #[cfg_attr(feature = "full", diesel(primary_key(person_id, comment_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = comment_actions_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CommentActions { /// When the comment was upvoted or downvoted. pub voted_at: Option>, /// When the comment was saved. pub saved_at: Option>, #[serde(skip)] pub person_id: PersonId, #[serde(skip)] pub comment_id: CommentId, /// True if upvoted, false if downvoted. Upvote is greater than downvote. pub vote_is_upvote: Option, } #[derive(Clone)] #[cfg_attr( feature = "full", derive(Insertable, AsChangeset, Serialize, Deserialize) )] #[cfg_attr(feature = "full", diesel(table_name = comment_actions))] pub struct CommentLikeForm { person_id: PersonId, comment_id: CommentId, vote_is_upvote: Option>, voted_at: Option>>, } impl CommentLikeForm { /// Pass `is_upvote: None` to remove an existing vote for this comment pub fn new(comment_id: CommentId, person_id: PersonId, is_upvote: Option) -> Self { let voted_at = if is_upvote.is_some() { Some(Some(Utc::now())) } else { Some(None) }; Self { comment_id, person_id, vote_is_upvote: Some(is_upvote), voted_at, } } } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = comment_actions))] pub struct CommentSavedForm { pub person_id: PersonId, pub comment_id: CommentId, #[new(value = "Utc::now()")] pub saved_at: DateTime, } ================================================ FILE: crates/db_schema/src/source/comment_report.rs ================================================ use crate::newtypes::{CommentId, CommentReportId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::comment_report; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Associations, Identifiable) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::comment::Comment)))] #[cfg_attr(feature = "full", diesel(table_name = comment_report))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A comment report. pub struct CommentReport { pub id: CommentReportId, pub creator_id: PersonId, pub comment_id: CommentId, pub original_comment_text: String, pub reason: String, pub resolved: bool, pub resolver_id: Option, pub published_at: DateTime, pub updated_at: Option>, pub violates_instance_rules: bool, } #[derive(Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = comment_report))] pub struct CommentReportForm { pub creator_id: PersonId, pub comment_id: CommentId, pub original_comment_text: String, pub reason: String, pub violates_instance_rules: bool, } ================================================ FILE: crates/db_schema/src/source/community.rs ================================================ use crate::{newtypes::CommunityId, source::placeholder_apub_url}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::{CommunityFollowerState, CommunityNotificationsMode, CommunityVisibility}, }; use lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::{community, community_actions}, }; #[skip_serializing_none] #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = community))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = community_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A community. pub struct Community { pub id: CommunityId, pub name: String, /// A longer title, that can contain other characters, and doesn't have to be unique. pub title: String, /// A sidebar for the community in markdown. pub sidebar: Option, /// Whether the community is removed by a mod. pub removed: bool, pub published_at: DateTime, pub updated_at: Option>, /// Whether the community has been deleted by its creator. pub deleted: bool, /// Whether its an NSFW community. pub nsfw: bool, /// The federated ap_id. pub ap_id: DbUrl, /// Whether the community is local. pub local: bool, #[serde(skip)] pub private_key: Option, #[serde(skip)] pub public_key: String, pub last_refreshed_at: DateTime, /// A URL for an icon. pub icon: Option, /// A URL for a banner. pub banner: Option, #[cfg_attr(feature = "ts-rs", ts(skip))] #[serde(skip)] pub followers_url: Option, #[cfg_attr(feature = "ts-rs", ts(skip))] #[serde(skip, default = "placeholder_apub_url")] pub inbox_url: DbUrl, /// Whether posting is restricted to mods only. pub posting_restricted_to_mods: bool, pub instance_id: InstanceId, /// Url where moderators collection is served over Activitypub #[serde(skip)] pub moderators_url: Option, /// Url where featured posts collection is served over Activitypub #[serde(skip)] pub featured_url: Option, pub visibility: CommunityVisibility, /// A shorter, one-line summary. pub summary: Option, #[serde(skip)] pub random_number: i16, pub subscribers: i32, pub posts: i32, pub comments: i32, /// The number of users with any activity in the last day. pub users_active_day: i32, /// The number of users with any activity in the last week. pub users_active_week: i32, /// The number of users with any activity in the last month. pub users_active_month: i32, /// The number of users with any activity in the last year. pub users_active_half_year: i32, #[serde(skip)] pub hot_rank: f32, pub subscribers_local: i32, /// Number of any interactions over the last month. #[serde(skip)] pub interactions_month: i32, pub report_count: i16, pub unresolved_report_count: i16, pub local_removed: bool, } #[derive(Debug, Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community))] pub struct CommunityInsertForm { pub instance_id: InstanceId, pub name: String, pub title: String, pub public_key: String, #[new(default)] pub sidebar: Option, #[new(default)] pub removed: Option, #[new(default)] pub published_at: Option>, #[new(default)] pub updated_at: Option>, #[new(default)] pub deleted: Option, #[new(default)] pub nsfw: Option, #[new(default)] pub ap_id: Option, #[new(default)] pub local: Option, #[new(default)] pub private_key: Option, #[new(default)] pub last_refreshed_at: Option>, #[new(default)] pub icon: Option, #[new(default)] pub banner: Option, #[new(default)] pub followers_url: Option, #[new(default)] pub inbox_url: Option, #[new(default)] pub moderators_url: Option, #[new(default)] pub featured_url: Option, #[new(default)] pub posting_restricted_to_mods: Option, #[new(default)] pub visibility: Option, #[new(default)] pub summary: Option, #[new(default)] pub local_removed: Option, } #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community))] pub struct CommunityUpdateForm { pub title: Option, pub sidebar: Option>, pub removed: Option, pub published_at: Option>, pub updated_at: Option>>, pub deleted: Option, pub nsfw: Option, pub ap_id: Option, pub local: Option, pub public_key: Option, pub private_key: Option>, pub last_refreshed_at: Option>, pub icon: Option>, pub banner: Option>, pub followers_url: Option, pub inbox_url: Option, pub moderators_url: Option>, pub featured_url: Option>, pub posting_restricted_to_mods: Option, pub visibility: Option, pub summary: Option>, pub local_removed: Option, } #[skip_serializing_none] #[derive(Clone, PartialEq, Debug, Serialize, Deserialize, Default)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, Associations, CursorKeysModule) )] #[cfg_attr( feature = "full", diesel(belongs_to(crate::source::community::Community)) )] #[cfg_attr(feature = "full", diesel(table_name = community_actions))] #[cfg_attr(feature = "full", diesel(primary_key(person_id, community_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = community_actions_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CommunityActions { /// When the community was followed. pub followed_at: Option>, /// When the community was blocked. pub blocked_at: Option>, /// When this user became a moderator. pub became_moderator_at: Option>, /// When this user received a ban. pub received_ban_at: Option>, /// When their ban expires. pub ban_expires_at: Option>, #[serde(skip)] pub person_id: PersonId, #[serde(skip)] pub community_id: CommunityId, /// The state of the community follow. pub follow_state: Option, /// The approver of the community follow. #[serde(skip)] pub follow_approver_id: Option, pub notifications: Option, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_actions))] pub struct CommunityModeratorForm { pub community_id: CommunityId, pub person_id: PersonId, #[new(value = "Utc::now()")] pub became_moderator_at: DateTime, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_actions))] pub struct CommunityPersonBanForm { pub community_id: CommunityId, pub person_id: PersonId, #[new(default)] pub ban_expires_at: Option>>, #[new(value = "Utc::now()")] pub received_ban_at: DateTime, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_actions))] pub struct CommunityFollowerForm { pub community_id: CommunityId, pub person_id: PersonId, pub follow_state: CommunityFollowerState, #[new(default)] pub follow_approver_id: Option, #[new(value = "Utc::now()")] pub followed_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_actions))] pub struct CommunityBlockForm { pub community_id: CommunityId, pub person_id: PersonId, #[new(value = "Utc::now()")] pub blocked_at: DateTime, } ================================================ FILE: crates/db_schema/src/source/community_community_follow.rs ================================================ use crate::newtypes::CommunityId; use lemmy_db_schema_file::schema::community_community_follow; #[derive(Clone, Debug, PartialEq, Queryable, Selectable)] #[diesel(belongs_to(crate::source::community::Community))] #[ diesel(table_name = community_community_follow)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct CommunityCommunityFollow { pub target_id: CommunityId, pub community_id: CommunityId, } ================================================ FILE: crates/db_schema/src/source/community_report.rs ================================================ use crate::newtypes::{CommunityId, CommunityReportId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::community_report; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Associations, Identifiable) )] #[cfg_attr( feature = "full", diesel(belongs_to(crate::source::community::Community)) )] #[cfg_attr(feature = "full", diesel(table_name = community_report))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A comment report. pub struct CommunityReport { pub id: CommunityReportId, pub creator_id: PersonId, pub community_id: CommunityId, pub original_community_name: String, pub original_community_title: String, pub original_community_summary: Option, pub original_community_sidebar: Option, pub original_community_icon: Option, pub original_community_banner: Option, pub reason: String, pub resolved: bool, pub resolver_id: Option, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_report))] pub struct CommunityReportForm { pub creator_id: PersonId, pub community_id: CommunityId, pub original_community_name: String, pub original_community_title: String, pub original_community_summary: Option, pub original_community_sidebar: Option, pub original_community_icon: Option, pub original_community_banner: Option, pub reason: String, } ================================================ FILE: crates/db_schema/src/source/community_tag.rs ================================================ use crate::newtypes::{CommunityId, CommunityTagId, PostId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use diesel::{AsExpression, FromSqlRow, sql_types::Nullable}; use lemmy_db_schema_file::enums::TagColor; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::{community_tag, post_community_tag}; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; /// A tag that is created by community moderators, and assigned to posts by the creator /// or by mods. #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = community_tag))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CommunityTag { pub id: CommunityTagId, pub ap_id: DbUrl, pub name: String, pub display_name: Option, pub summary: Option, /// The community that this tag belongs to pub community_id: CommunityId, pub published_at: DateTime, pub updated_at: Option>, pub deleted: bool, pub color: TagColor, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_tag))] pub struct CommunityTagInsertForm { pub ap_id: DbUrl, pub name: String, pub display_name: Option, pub summary: Option, pub community_id: CommunityId, pub deleted: Option, pub color: Option, } #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = community_tag))] pub struct CommunityTagUpdateForm { pub display_name: Option>, pub summary: Option>, pub community_id: Option, pub published_at: Option>, pub updated_at: Option>>, pub deleted: Option, pub color: Option, } /// We wrap this in a struct so we can implement FromSqlRow for it #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)] #[serde(transparent)] #[cfg_attr(feature = "full", derive(FromSqlRow, AsExpression))] #[cfg_attr(feature = "full", diesel(sql_type = Nullable))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CommunityTagsView(pub Vec); #[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::community_tag::CommunityTag)) )] #[cfg_attr(feature = "full", diesel(table_name = post_community_tag))] #[cfg_attr(feature = "full", diesel(primary_key(post_id, community_tag_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] /// An association between a post and a tag. Created/updated by the post author or mods of a /// community. pub struct PostCommunityTag { pub post_id: PostId, pub community_tag_id: CommunityTagId, pub published_at: DateTime, } #[derive(Clone, Debug)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_community_tag))] pub struct PostCommunityTagForm { pub post_id: PostId, pub community_tag_id: CommunityTagId, } ================================================ FILE: crates/db_schema/src/source/custom_emoji.rs ================================================ use crate::newtypes::CustomEmojiId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::custom_emoji; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = custom_emoji))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A custom emoji. pub struct CustomEmoji { pub id: CustomEmojiId, pub shortcode: String, pub image_url: DbUrl, pub alt_text: String, pub category: String, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = custom_emoji))] pub struct CustomEmojiInsertForm { pub shortcode: String, pub image_url: DbUrl, pub alt_text: String, pub category: String, } #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = custom_emoji))] pub struct CustomEmojiUpdateForm { pub shortcode: Option, pub image_url: Option, pub alt_text: Option, pub category: Option, } ================================================ FILE: crates/db_schema/src/source/custom_emoji_keyword.rs ================================================ use crate::newtypes::CustomEmojiId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::custom_emoji_keyword; use serde::{Deserialize, Serialize}; #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Associations, Identifiable) )] #[cfg_attr(feature = "full", diesel(table_name = custom_emoji_keyword))] #[cfg_attr( feature = "full", diesel(belongs_to(crate::source::custom_emoji::CustomEmoji)) )] #[cfg_attr(feature = "full", diesel(primary_key(custom_emoji_id, keyword)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A custom keyword for an emoji. pub struct CustomEmojiKeyword { pub custom_emoji_id: CustomEmojiId, pub keyword: String, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = custom_emoji_keyword))] pub struct CustomEmojiKeywordInsertForm { pub custom_emoji_id: CustomEmojiId, pub keyword: String, } ================================================ FILE: crates/db_schema/src/source/email_verification.rs ================================================ use crate::newtypes::LocalUserId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::email_verification; #[derive(Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = email_verification))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct EmailVerification { pub id: i32, pub local_user_id: LocalUserId, pub email: String, pub verification_token: String, pub published_at: DateTime, } #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = email_verification))] pub struct EmailVerificationForm { pub local_user_id: LocalUserId, pub email: String, pub verification_token: String, } ================================================ FILE: crates/db_schema/src/source/federation_allowlist.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema_file::InstanceId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::federation_allowlist; use serde::{Deserialize, Serialize}; use std::fmt::Debug; #[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::instance::Instance)) )] #[cfg_attr(feature = "full", diesel(table_name = federation_allowlist))] #[cfg_attr(feature = "full", diesel(primary_key(instance_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct FederationAllowList { #[serde(skip)] pub instance_id: InstanceId, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Clone, Default, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = federation_allowlist))] pub struct FederationAllowListForm { pub instance_id: InstanceId, #[new(default)] pub updated_at: Option>, } ================================================ FILE: crates/db_schema/src/source/federation_blocklist.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema_file::InstanceId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::federation_blocklist; use serde::{Deserialize, Serialize}; use std::fmt::Debug; #[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::instance::Instance)) )] #[cfg_attr(feature = "full", diesel(table_name = federation_blocklist))] #[cfg_attr(feature = "full", diesel(primary_key(instance_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct FederationBlockList { #[serde(skip)] pub instance_id: InstanceId, pub published_at: DateTime, pub updated_at: Option>, pub expires_at: Option>, } #[derive(Clone, Default, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = federation_blocklist))] pub struct FederationBlockListForm { pub instance_id: InstanceId, #[new(default)] pub updated_at: Option>, pub expires_at: Option>, } ================================================ FILE: crates/db_schema/src/source/federation_queue_state.rs ================================================ use crate::newtypes::ActivityId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use diesel::prelude::*; use lemmy_db_schema_file::InstanceId; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Insertable, AsChangeset) )] #[cfg_attr(feature = "full", diesel(table_name = lemmy_db_schema_file::schema::federation_queue_state))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct FederationQueueState { pub instance_id: InstanceId, /// the last successfully sent activity id pub last_successful_id: Option, pub last_successful_published_time_at: Option>, /// how many failed attempts have been made to send the next activity pub fail_count: i32, /// timestamp of the last retry attempt (when the last failing activity was resent) pub last_retry_at: Option>, } ================================================ FILE: crates/db_schema/src/source/images.rs ================================================ use crate::newtypes::PostId; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::fmt::Debug; #[cfg(feature = "full")] use { i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::{image_details, local_image, remote_image}, }; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, Associations, CursorKeysModule,) )] #[cfg_attr(feature = "full", diesel(table_name = local_image))] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))] #[cfg_attr(feature = "full", cursor_keys_module(name = local_image_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct LocalImage { pub pictrs_alias: String, pub published_at: DateTime, pub person_id: Option, /// This means the image is an auto-generated thumbnail, for a post. pub thumbnail_for_post_id: Option, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_image))] pub struct LocalImageForm { pub pictrs_alias: String, pub person_id: PersonId, pub thumbnail_for_post_id: Option>, } /// Stores all images which are hosted on remote domains. When attempting to proxy an image, it /// is checked against this table to avoid Lemmy being used as a general purpose proxy. #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = remote_image))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", diesel(primary_key(link)))] pub struct RemoteImage { pub link: DbUrl, pub published_at: DateTime, } #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = image_details))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", diesel(primary_key(link)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ImageDetails { pub link: DbUrl, pub width: i32, pub height: i32, pub content_type: String, pub blurhash: Option, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = image_details))] pub struct ImageDetailsInsertForm { pub link: DbUrl, pub width: i32, pub height: i32, pub content_type: String, pub blurhash: Option, } ================================================ FILE: crates/db_schema/src/source/instance.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema_file::{InstanceId, PersonId}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::fmt::Debug; #[cfg(feature = "full")] use { i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::{instance, instance_actions}, }; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = instance))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = instance_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Basic data about a Fediverse instance which is available for every known domain. Additional /// data may be available in [[Site]]. pub struct Instance { pub id: InstanceId, pub domain: String, pub published_at: DateTime, /// When the instance was updated. pub updated_at: Option>, /// The software of the instance. pub software: Option, /// The version of the instance's software. pub version: Option, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = instance))] pub struct InstanceForm { pub domain: String, #[new(default)] pub software: Option, #[new(default)] pub version: Option, #[new(default)] pub updated_at: Option>, } #[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::instance::Instance)) )] #[cfg_attr(feature = "full", diesel(table_name = instance_actions))] #[cfg_attr(feature = "full", diesel(primary_key(person_id, instance_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct InstanceActions { /// When the instance's communities were blocked. pub blocked_communities_at: Option>, #[serde(skip)] pub person_id: PersonId, #[serde(skip)] pub instance_id: InstanceId, /// When this user received a site ban. pub received_ban_at: Option>, /// When their ban expires. pub ban_expires_at: Option>, /// When the instance's persons were blocked. pub blocked_persons_at: Option>, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = instance_actions))] pub struct InstanceCommunitiesBlockForm { pub person_id: PersonId, pub instance_id: InstanceId, #[new(value = "Utc::now()")] pub blocked_communities_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = instance_actions))] pub struct InstancePersonsBlockForm { pub person_id: PersonId, pub instance_id: InstanceId, #[new(value = "Utc::now()")] pub blocked_persons_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = instance_actions))] pub struct InstanceBanForm { pub person_id: PersonId, pub instance_id: InstanceId, #[new(value = "Utc::now()")] pub received_ban_at: DateTime, pub ban_expires_at: Option>, } ================================================ FILE: crates/db_schema/src/source/keyword_block.rs ================================================ use crate::newtypes::LocalUserId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::local_user_keyword_block; use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = local_user_keyword_block))] #[cfg_attr(feature = "full", diesel(primary_key(local_user_id, keyword)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct LocalUserKeywordBlock { pub local_user_id: LocalUserId, pub keyword: String, } #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_user_keyword_block))] pub struct LocalUserKeywordBlockForm { pub local_user_id: LocalUserId, pub keyword: String, } ================================================ FILE: crates/db_schema/src/source/language.rs ================================================ use crate::newtypes::LanguageId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::language; use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = language))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A language. pub struct Language { pub id: LanguageId, pub code: String, pub name: String, } ================================================ FILE: crates/db_schema/src/source/local_site.rs ================================================ use crate::newtypes::{LocalSiteId, MultiCommunityId, SiteId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::local_site; use lemmy_db_schema_file::{ PersonId, enums::{ CommentSortType, FederationMode, ImageMode, ListingType, PostListingMode, PostSortType, RegistrationMode, }, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = local_site))] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::site::Site)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The local site. pub struct LocalSite { pub id: LocalSiteId, pub site_id: SiteId, /// True if the site is set up. pub site_setup: bool, /// Whether only admins can create communities. pub community_creation_admin_only: bool, /// Whether emails are required. pub require_email_verification: bool, /// An optional registration application questionnaire in markdown. pub application_question: Option, /// Whether the instance is private or public. pub private_instance: bool, /// The default front-end theme. pub default_theme: String, pub default_post_listing_type: ListingType, /// An optional legal disclaimer page. pub legal_information: Option, /// Whether new applications email admins. pub application_email_admins: bool, /// An optional regex to filter words. pub slur_filter_regex: Option, /// Whether federation is enabled. pub federation_enabled: bool, pub published_at: DateTime, pub updated_at: Option>, pub registration_mode: RegistrationMode, /// Whether to email admins on new reports. pub reports_email_admins: bool, /// Whether to sign outgoing Activitypub fetches with private key of local instance. Some /// Fediverse instances and platforms require this. pub federation_signed_fetch: bool, /// Default value for [LocalSite.post_listing_mode] pub default_post_listing_mode: PostListingMode, /// Default value for [LocalUser.post_sort_type] pub default_post_sort_type: PostSortType, /// Default value for [LocalUser.comment_sort_type] pub default_comment_sort_type: CommentSortType, /// Whether or not external auth methods can auto-register users. pub oauth_registration: bool, /// What kind of post upvotes your site allows. pub post_upvotes: FederationMode, /// What kind of post downvotes your site allows. pub post_downvotes: FederationMode, /// What kind of comment upvotes your site allows. pub comment_upvotes: FederationMode, /// What kind of comment downvotes your site allows. pub comment_downvotes: FederationMode, /// A default time range limit to apply to post sorts, in seconds. pub default_post_time_range_seconds: Option, /// Block NSFW content being created pub disallow_nsfw_content: bool, pub users: i32, pub posts: i32, pub comments: i32, pub communities: i32, /// The number of users with any activity in the last day. pub users_active_day: i32, /// The number of users with any activity in the last week. pub users_active_week: i32, /// The number of users with any activity in the last month. pub users_active_month: i32, /// The number of users with any activity in the last half year. pub users_active_half_year: i32, /// Dont send email notifications to users for new replies, mentions etc pub disable_email_notifications: bool, pub suggested_multi_community_id: Option, #[serde(skip)] pub system_account: PersonId, pub default_items_per_page: i32, /// A mode for setting how pictrs handles images. pub image_mode: ImageMode, /// Allows bypassing proxy for specific image hosts when using [[ImageMode.ProxyAllImages]]. Use /// a comma-delimited string. /// /// Example: i.imgur.com,postimg.cc pub image_proxy_bypass_domains: Option, pub image_upload_timeout_seconds: i32, /// These are pixel sizes. Larger images are automatically downscaled. pub image_max_thumbnail_size: i32, pub image_max_avatar_size: i32, pub image_max_banner_size: i32, /// This affects post and comment images, but not avatar and banner sizes. pub image_max_upload_size: i32, /// This affects post and comment images, but not avatars and banners. pub image_allow_video_uploads: bool, pub image_upload_disabled: bool, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = local_site))] pub struct LocalSiteInsertForm { pub site_id: SiteId, #[new(default)] pub site_setup: Option, #[new(default)] pub community_creation_admin_only: Option, #[new(default)] pub require_email_verification: Option, #[new(default)] pub application_question: Option, #[new(default)] pub private_instance: Option, #[new(default)] pub default_theme: Option, #[new(default)] pub default_post_listing_type: Option, #[new(default)] pub legal_information: Option, #[new(default)] pub application_email_admins: Option, #[new(default)] pub slur_filter_regex: Option, #[new(default)] pub federation_enabled: Option, #[new(default)] pub registration_mode: Option, #[new(default)] pub reports_email_admins: Option, #[new(default)] pub federation_signed_fetch: Option, #[new(default)] pub default_post_listing_mode: Option, #[new(default)] pub default_post_sort_type: Option, #[new(default)] pub default_comment_sort_type: Option, #[new(default)] pub oauth_registration: Option, #[new(default)] pub post_upvotes: Option, #[new(default)] pub post_downvotes: Option, #[new(default)] pub comment_upvotes: Option, #[new(default)] pub comment_downvotes: Option, #[new(default)] pub default_post_time_range_seconds: Option, #[new(default)] pub disallow_nsfw_content: bool, #[new(default)] pub disable_email_notifications: bool, #[new(default)] pub suggested_multi_community_id: Option, #[new(default)] pub system_account: Option, #[new(default)] pub image_mode: Option, #[new(default)] pub image_proxy_bypass_domains: Option, #[new(default)] pub image_upload_timeout_seconds: Option, #[new(default)] pub image_max_thumbnail_size: Option, #[new(default)] pub image_max_avatar_size: Option, #[new(default)] pub image_max_banner_size: Option, #[new(default)] pub image_max_upload_size: Option, #[new(default)] pub image_allow_video_uploads: Option, #[new(default)] pub image_upload_disabled: Option, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_site))] pub struct LocalSiteUpdateForm { pub site_setup: Option, pub community_creation_admin_only: Option, pub require_email_verification: Option, pub application_question: Option>, pub private_instance: Option, pub default_theme: Option, pub default_post_listing_type: Option, pub legal_information: Option>, pub application_email_admins: Option, pub slur_filter_regex: Option>, pub federation_enabled: Option, pub registration_mode: Option, pub reports_email_admins: Option, pub updated_at: Option>>, pub federation_signed_fetch: Option, pub default_post_listing_mode: Option, pub default_post_sort_type: Option, pub default_comment_sort_type: Option, pub oauth_registration: Option, pub post_upvotes: Option, pub post_downvotes: Option, pub comment_upvotes: Option, pub comment_downvotes: Option, pub default_post_time_range_seconds: Option>, pub disallow_nsfw_content: Option, pub disable_email_notifications: Option, pub suggested_multi_community_id: Option>, pub default_items_per_page: Option, pub image_mode: Option, pub image_proxy_bypass_domains: Option>, pub image_upload_timeout_seconds: Option, pub image_max_thumbnail_size: Option, pub image_max_avatar_size: Option, pub image_max_banner_size: Option, pub image_max_upload_size: Option, pub image_allow_video_uploads: Option, pub image_upload_disabled: Option, } ================================================ FILE: crates/db_schema/src/source/local_site_rate_limit.rs ================================================ use crate::newtypes::LocalSiteId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::local_site_rate_limit; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = local_site_rate_limit))] #[cfg_attr(feature = "full", diesel(primary_key(local_site_id)))] #[cfg_attr( feature = "full", diesel(belongs_to(crate::source::local_site::LocalSite)) )] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Rate limits for your site. Given in count / length of time. pub struct LocalSiteRateLimit { pub local_site_id: LocalSiteId, pub message_max_requests: i32, pub message_interval_seconds: i32, pub post_max_requests: i32, pub post_interval_seconds: i32, pub register_max_requests: i32, pub register_interval_seconds: i32, pub image_max_requests: i32, pub image_interval_seconds: i32, pub comment_max_requests: i32, pub comment_interval_seconds: i32, pub search_max_requests: i32, pub search_interval_seconds: i32, pub published_at: DateTime, pub updated_at: Option>, pub import_user_settings_max_requests: i32, pub import_user_settings_interval_seconds: i32, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = local_site_rate_limit))] pub struct LocalSiteRateLimitInsertForm { pub local_site_id: LocalSiteId, #[new(default)] pub message_max_requests: Option, #[new(default)] pub message_interval_seconds: Option, #[new(default)] pub post_max_requests: Option, #[new(default)] pub post_interval_seconds: Option, #[new(default)] pub register_max_requests: Option, #[new(default)] pub register_interval_seconds: Option, #[new(default)] pub image_max_requests: Option, #[new(default)] pub image_interval_seconds: Option, #[new(default)] pub comment_max_requests: Option, #[new(default)] pub comment_interval_seconds: Option, #[new(default)] pub search_max_requests: Option, #[new(default)] pub search_interval_seconds: Option, #[new(default)] pub import_user_settings_max_requests: Option, #[new(default)] pub import_user_settings_interval_seconds: Option, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_site_rate_limit))] pub struct LocalSiteRateLimitUpdateForm { pub message_max_requests: Option, pub message_interval_seconds: Option, pub post_max_requests: Option, pub post_interval_seconds: Option, pub register_max_requests: Option, pub register_interval_seconds: Option, pub image_max_requests: Option, pub image_interval_seconds: Option, pub comment_max_requests: Option, pub comment_interval_seconds: Option, pub search_max_requests: Option, pub search_interval_seconds: Option, pub import_user_settings_max_requests: Option, pub import_user_settings_interval_seconds: Option, pub updated_at: Option>>, } ================================================ FILE: crates/db_schema/src/source/local_site_url_blocklist.rs ================================================ use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::local_site_url_blocklist; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = local_site_url_blocklist))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct LocalSiteUrlBlocklist { pub id: i32, pub url: String, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Default, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_site_url_blocklist))] pub struct LocalSiteUrlBlocklistForm { pub url: String, pub updated_at: Option>, } ================================================ FILE: crates/db_schema/src/source/local_user.rs ================================================ use crate::newtypes::LocalUserId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::local_user; use lemmy_db_schema_file::{ PersonId, enums::{CommentSortType, ListingType, PostListingMode, PostSortType, VoteShow}, }; use lemmy_diesel_utils::sensitive::SensitiveString; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = local_user))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] #[serde(default)] /// A local user. pub struct LocalUser { pub id: LocalUserId, /// The person_id for the local user. pub person_id: PersonId, #[serde(skip)] pub password_encrypted: Option, pub email: Option, /// Whether to show NSFW content. pub show_nsfw: bool, pub theme: String, pub default_post_sort_type: PostSortType, pub default_listing_type: ListingType, pub interface_language: String, /// Whether to show avatars. pub show_avatars: bool, pub send_notifications_to_email: bool, /// Whether to show bot accounts. pub show_bot_accounts: bool, /// Whether to show read posts. pub show_read_posts: bool, /// Whether their email has been verified. pub email_verified: bool, /// Whether their registration application has been accepted. pub accepted_application: bool, #[serde(skip)] pub totp_2fa_secret: Option, /// Open links in a new tab. pub open_links_in_new_tab: bool, pub blur_nsfw: bool, /// Whether infinite scroll is enabled. pub infinite_scroll_enabled: bool, /// Whether the person is an admin. pub admin: bool, /// A post-view mode that changes how multiple post listings look. pub post_listing_mode: PostListingMode, pub totp_2fa_enabled: bool, /// Whether user avatars and inline images in the UI that are gifs should be allowed to play or /// should be paused pub enable_animated_images: bool, /// Whether to auto-collapse bot comments. pub collapse_bot_comments: bool, /// The last time a donation request was shown to this user. If this is more than a year ago, /// a new notification request should be shown. pub last_donation_notification_at: DateTime, /// Whether a user can send / receive private messages pub enable_private_messages: bool, pub default_comment_sort_type: CommentSortType, /// Whether to automatically mark fetched posts as read. pub auto_mark_fetched_posts_as_read: bool, /// Whether to hide posts containing images/videos pub hide_media: bool, /// A default time range limit to apply to post sorts, in seconds. pub default_post_time_range_seconds: Option, pub show_score: bool, pub show_upvotes: bool, pub show_downvotes: VoteShow, pub show_upvote_percentage: bool, pub show_person_votes: bool, pub default_items_per_page: i32, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = local_user))] pub struct LocalUserInsertForm { pub person_id: PersonId, pub password_encrypted: Option, #[new(default)] pub email: Option, #[new(default)] pub show_nsfw: Option, #[new(default)] pub theme: Option, #[new(default)] pub default_post_sort_type: Option, #[new(default)] pub default_listing_type: Option, #[new(default)] pub interface_language: Option, #[new(default)] pub show_avatars: Option, #[new(default)] pub send_notifications_to_email: Option, #[new(default)] pub show_bot_accounts: Option, #[new(default)] pub show_read_posts: Option, #[new(default)] pub email_verified: Option, #[new(default)] pub accepted_application: Option, #[new(default)] pub totp_2fa_secret: Option>, #[new(default)] pub open_links_in_new_tab: Option, #[new(default)] pub blur_nsfw: Option, #[new(default)] pub infinite_scroll_enabled: Option, #[new(default)] pub admin: Option, #[new(default)] pub post_listing_mode: Option, #[new(default)] pub totp_2fa_enabled: Option, #[new(default)] pub enable_animated_images: Option, #[new(default)] pub collapse_bot_comments: Option, #[new(default)] pub last_donation_notification_at: Option>, #[new(default)] pub enable_private_messages: Option, #[new(default)] pub default_comment_sort_type: Option, #[new(default)] pub auto_mark_fetched_posts_as_read: Option, #[new(default)] pub hide_media: Option, #[new(default)] pub default_post_time_range_seconds: Option, #[new(default)] pub show_score: Option, #[new(default)] pub show_upvotes: Option, #[new(default)] pub show_downvotes: Option, #[new(default)] pub show_upvote_percentage: Option, #[new(default)] pub show_person_votes: Option, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = local_user))] pub struct LocalUserUpdateForm { pub password_encrypted: Option>, pub email: Option>, pub show_nsfw: Option, pub theme: Option, pub default_post_sort_type: Option, pub default_listing_type: Option, pub interface_language: Option, pub show_avatars: Option, pub send_notifications_to_email: Option, pub show_bot_accounts: Option, pub show_read_posts: Option, pub email_verified: Option, pub accepted_application: Option, pub totp_2fa_secret: Option>, pub open_links_in_new_tab: Option, pub blur_nsfw: Option, pub infinite_scroll_enabled: Option, pub admin: Option, pub post_listing_mode: Option, pub totp_2fa_enabled: Option, pub enable_animated_images: Option, pub collapse_bot_comments: Option, pub last_donation_notification_at: Option>, pub enable_private_messages: Option, pub default_comment_sort_type: Option, pub auto_mark_fetched_posts_as_read: Option, pub hide_media: Option, pub default_post_time_range_seconds: Option>, pub show_score: Option, pub show_upvotes: Option, pub show_downvotes: Option, pub show_upvote_percentage: Option, pub show_person_votes: Option, pub default_items_per_page: Option, } ================================================ FILE: crates/db_schema/src/source/login_token.rs ================================================ use crate::newtypes::LocalUserId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::login_token; use lemmy_diesel_utils::sensitive::SensitiveString; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; /// Stores data related to a specific user login session. #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = login_token))] #[cfg_attr(feature = "full", diesel(primary_key(token)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct LoginToken { /// Jwt token for this login #[serde(skip)] pub token: SensitiveString, pub user_id: LocalUserId, /// Time of login pub published_at: DateTime, /// IP address where login was made from, allows invalidating logins by IP address. /// Could be stored in truncated format, or store derived information for better privacy. pub ip: Option, pub user_agent: Option, } #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = login_token))] pub struct LoginTokenCreateForm { pub token: SensitiveString, pub user_id: LocalUserId, pub ip: Option, pub user_agent: Option, } ================================================ FILE: crates/db_schema/src/source/mod.rs ================================================ use lemmy_diesel_utils::dburl::DbUrl; use url::Url; #[cfg(feature = "full")] pub mod activity; pub mod actor_language; pub mod combined; pub mod comment; pub mod comment_report; pub mod community; #[cfg(feature = "full")] pub mod community_community_follow; pub mod community_report; pub mod community_tag; pub mod custom_emoji; pub mod custom_emoji_keyword; pub mod email_verification; pub mod federation_allowlist; pub mod federation_blocklist; pub mod federation_queue_state; pub mod images; pub mod instance; pub mod keyword_block; pub mod language; pub mod local_site; pub mod local_site_rate_limit; pub mod local_site_url_blocklist; pub mod local_user; pub mod login_token; pub mod modlog; pub mod multi_community; pub mod notification; pub mod oauth_account; pub mod oauth_provider; pub mod password_reset_request; pub mod person; pub mod post; pub mod post_report; pub mod private_message; pub mod private_message_report; pub mod registration_application; pub mod secret; pub mod site; pub mod tagline; /// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip). /// /// This is necessary so they can be successfully deserialized from API responses, even though the /// value is not sent by Lemmy. Necessary for crates which rely on Rust API such as /// lemmy-stats-crawler. #[expect(clippy::expect_used)] fn placeholder_apub_url() -> DbUrl { DbUrl(Box::new( Url::parse("http://example.com").expect("parse placeholder url"), )) } ================================================ FILE: crates/db_schema/src/source/modlog.rs ================================================ use crate::newtypes::{CommentId, CommunityId, ModlogId, PostId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::modlog; use lemmy_db_schema_file::{InstanceId, PersonId, enums::ModlogKind}; use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = modlog))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] #[cfg_attr(feature = "full", cursor_keys_module(name = modlog_keys))] pub struct Modlog { pub id: ModlogId, pub kind: ModlogKind, pub is_revert: bool, #[serde(skip)] pub mod_id: PersonId, pub reason: Option, #[serde(skip)] pub target_person_id: Option, #[serde(skip)] pub target_community_id: Option, #[serde(skip)] pub target_post_id: Option, #[serde(skip)] pub target_comment_id: Option, #[serde(skip)] pub target_instance_id: Option, pub expires_at: Option>, pub published_at: DateTime, pub bulk_action_parent_id: Option, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = modlog))] pub struct ModlogInsertForm<'a> { pub(crate) kind: ModlogKind, pub(crate) is_revert: bool, #[new(default)] pub bulk_action_parent_id: Option, pub(crate) mod_id: PersonId, #[new(default)] pub(crate) reason: Option<&'a str>, #[new(default)] pub(crate) target_person_id: Option, #[new(default)] pub(crate) target_community_id: Option, #[new(default)] pub(crate) target_post_id: Option, #[new(default)] pub(crate) target_comment_id: Option, #[new(default)] pub(crate) target_instance_id: Option, #[new(default)] pub(crate) expires_at: Option>, } ================================================ FILE: crates/db_schema/src/source/multi_community.rs ================================================ use crate::{ newtypes::{CommunityId, MultiCommunityId}, source::placeholder_apub_url, }; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::{ multi_community, multi_community_entry, multi_community_follow, }; use lemmy_db_schema_file::{InstanceId, PersonId, enums::CommunityFollowerState}; use lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = multi_community))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = multi_community_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct MultiCommunity { pub id: MultiCommunityId, pub creator_id: PersonId, pub instance_id: InstanceId, pub name: String, pub title: Option, /// A shorter, one-line summary. pub summary: Option, pub local: bool, pub deleted: bool, pub ap_id: DbUrl, #[serde(skip)] pub public_key: String, #[serde(skip)] pub private_key: Option, #[serde(skip, default = "placeholder_apub_url")] pub inbox_url: DbUrl, pub last_refreshed_at: DateTime, #[serde(skip, default = "placeholder_apub_url")] pub following_url: DbUrl, pub published_at: DateTime, pub updated_at: Option>, pub subscribers: i32, pub subscribers_local: i32, pub communities: i32, /// A sidebar in markdown. pub sidebar: Option, } #[derive(Debug, Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = multi_community))] pub struct MultiCommunityInsertForm { pub creator_id: PersonId, pub instance_id: InstanceId, pub name: String, pub public_key: String, #[new(default)] pub ap_id: Option, #[new(default)] pub local: Option, #[new(default)] pub title: Option, #[new(default)] pub summary: Option, #[new(default)] pub sidebar: Option, #[new(default)] pub last_refreshed_at: Option>, #[new(default)] pub private_key: Option, #[new(default)] pub inbox_url: Option, #[new(default)] pub following_url: Option, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = multi_community))] pub struct MultiCommunityUpdateForm { pub title: Option>, pub summary: Option>, pub sidebar: Option>, pub deleted: Option, pub updated_at: Option>, } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(table_name = multi_community_follow))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct MultiCommunityFollow { pub multi_community_id: MultiCommunityId, pub person_id: PersonId, pub follow_state: CommunityFollowerState, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = multi_community_follow))] pub struct MultiCommunityFollowForm { pub multi_community_id: MultiCommunityId, pub person_id: PersonId, pub follow_state: CommunityFollowerState, } #[skip_serializing_none] #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = multi_community_entry))] #[cfg_attr( feature = "full", diesel(primary_key(multi_community_id, community_id)) )] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct MultiCommunityEntry { pub multi_community_id: MultiCommunityId, pub community_id: CommunityId, } #[derive(Debug, Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = multi_community_entry))] pub struct MultiCommunityEntryForm { pub multi_community_id: MultiCommunityId, pub community_id: CommunityId, } ================================================ FILE: crates/db_schema/src/source/notification.rs ================================================ use crate::{ newtypes::{CommentId, ModlogId, NotificationId, PostId, PrivateMessageId}, source::{comment::Comment, post::Post, private_message::PrivateMessage}, }; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::notification; use lemmy_db_schema_file::{PersonId, enums::NotificationType}; use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = notification))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] #[cfg_attr(feature = "full", cursor_keys_module(name = notification_keys))] pub struct Notification { pub id: NotificationId, pub recipient_id: PersonId, pub comment_id: Option, pub read: bool, pub published_at: DateTime, pub kind: NotificationType, pub post_id: Option, pub private_message_id: Option, pub modlog_id: Option, pub creator_id: PersonId, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = notification))] pub struct NotificationInsertForm { pub recipient_id: PersonId, pub creator_id: PersonId, pub kind: NotificationType, #[new(default)] pub comment_id: Option, #[new(default)] pub post_id: Option, #[new(default)] pub private_message_id: Option, #[new(default)] pub modlog_id: Option, } impl NotificationInsertForm { pub fn new_post(post: &Post, recipient_id: PersonId, kind: NotificationType) -> Self { Self { post_id: Some(post.id), ..Self::new(recipient_id, post.creator_id, kind) } } pub fn new_comment(comment: &Comment, recipient_id: PersonId, kind: NotificationType) -> Self { Self { comment_id: Some(comment.id), ..Self::new(recipient_id, comment.creator_id, kind) } } pub fn new_private_message(private_message: &PrivateMessage) -> Self { Self { private_message_id: Some(private_message.id), ..Self::new( private_message.recipient_id, private_message.creator_id, NotificationType::PrivateMessage, ) } } pub fn new_mod_action(modlog_id: ModlogId, recipient_id: PersonId, creator_id: PersonId) -> Self { Self { modlog_id: Some(modlog_id), ..Self::new(recipient_id, creator_id, NotificationType::ModAction) } } } ================================================ FILE: crates/db_schema/src/source/oauth_account.rs ================================================ use crate::newtypes::{LocalUserId, OAuthProviderId}; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::oauth_account; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(table_name = oauth_account))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// An auth account method. pub struct OAuthAccount { pub local_user_id: LocalUserId, pub oauth_provider_id: OAuthProviderId, pub oauth_user_id: String, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Debug, Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = oauth_account))] pub struct OAuthAccountInsertForm { pub local_user_id: LocalUserId, pub oauth_provider_id: OAuthProviderId, pub oauth_user_id: String, } ================================================ FILE: crates/db_schema/src/source/oauth_provider.rs ================================================ use crate::newtypes::OAuthProviderId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::oauth_provider; use lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = oauth_provider))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// oauth provider with client_secret - should never be sent to the client pub struct AdminOAuthProvider { pub id: OAuthProviderId, /// The OAuth 2.0 provider name displayed to the user on the Login page pub display_name: String, /// The issuer url of the OAUTH provider. #[cfg_attr(feature = "ts-rs", ts(type = "string"))] pub issuer: DbUrl, /// The authorization endpoint is used to interact with the resource owner and obtain an /// authorization grant. This is usually provided by the OAUTH provider. #[cfg_attr(feature = "ts-rs", ts(type = "string"))] pub authorization_endpoint: DbUrl, /// The token endpoint is used by the client to obtain an access token by presenting its /// authorization grant or refresh token. This is usually provided by the OAUTH provider. #[cfg_attr(feature = "ts-rs", ts(type = "string"))] pub token_endpoint: DbUrl, /// The UserInfo Endpoint is an OAuth 2.0 Protected Resource that returns Claims about the /// authenticated End-User. This is defined in the OIDC specification. #[cfg_attr(feature = "ts-rs", ts(type = "string"))] pub userinfo_endpoint: DbUrl, /// The OAuth 2.0 claim containing the unique user ID returned by the provider. Usually this /// should be set to "sub". pub id_claim: String, /// The client_id is provided by the OAuth 2.0 provider and is a unique identifier to this /// service pub client_id: String, /// The client_secret is provided by the OAuth 2.0 provider and is used to authenticate this /// service with the provider #[serde(skip)] pub client_secret: SensitiveString, /// Lists the scopes requested from users. Users will have to grant access to the requested scope /// at sign up. pub scopes: String, /// Automatically sets email as verified on registration pub auto_verify_email: bool, /// Allows linking an OAUTH account to an existing user account by matching emails pub account_linking_enabled: bool, /// switch to enable or disable an oauth provider pub enabled: bool, pub published_at: DateTime, pub updated_at: Option>, /// switch to enable or disable PKCE pub use_pkce: bool, } #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] // A subset of OAuthProvider used for public requests, for example to display the OAUTH buttons on // the login page pub struct PublicOAuthProvider { pub id: OAuthProviderId, /// The OAuth 2.0 provider name displayed to the user on the Login page pub display_name: String, /// The authorization endpoint is used to interact with the resource owner and obtain an /// authorization grant. This is usually provided by the OAUTH provider. #[cfg_attr(feature = "ts-rs", ts(type = "string"))] pub authorization_endpoint: DbUrl, /// The client_id is provided by the OAuth 2.0 provider and is a unique identifier to this /// service pub client_id: String, /// Lists the scopes requested from users. Users will have to grant access to the requested scope /// at sign up. pub scopes: String, /// switch to enable or disable PKCE pub use_pkce: bool, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = oauth_provider))] pub struct OAuthProviderInsertForm { pub display_name: String, pub issuer: DbUrl, pub authorization_endpoint: DbUrl, pub token_endpoint: DbUrl, pub userinfo_endpoint: DbUrl, pub id_claim: String, pub client_id: String, pub client_secret: String, pub scopes: String, pub auto_verify_email: Option, pub account_linking_enabled: Option, pub use_pkce: Option, pub enabled: Option, } #[derive(Debug, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = oauth_provider))] pub struct OAuthProviderUpdateForm { pub display_name: Option, pub authorization_endpoint: Option, pub token_endpoint: Option, pub userinfo_endpoint: Option, pub id_claim: Option, pub client_secret: Option, pub scopes: Option, pub auto_verify_email: Option, pub account_linking_enabled: Option, pub use_pkce: Option, pub enabled: Option, pub updated_at: Option>>, } ================================================ FILE: crates/db_schema/src/source/password_reset_request.rs ================================================ use crate::newtypes::LocalUserId; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::password_reset_request; use lemmy_diesel_utils::sensitive::SensitiveString; #[derive(PartialEq, Eq, Debug)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = password_reset_request))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct PasswordResetRequest { pub id: i32, pub token: SensitiveString, pub published_at: DateTime, pub local_user_id: LocalUserId, } #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = password_reset_request))] pub struct PasswordResetRequestForm { pub local_user_id: LocalUserId, pub token: SensitiveString, } ================================================ FILE: crates/db_schema/src/source/person.rs ================================================ use crate::source::placeholder_apub_url; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::{person, person_actions}; use lemmy_db_schema_file::{InstanceId, PersonId}; use lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = person))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = person_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A person. pub struct Person { pub id: PersonId, pub name: String, /// A shorter display name. pub display_name: Option, /// A URL for an avatar. pub avatar: Option, pub published_at: DateTime, pub updated_at: Option>, /// The federated ap_id. pub ap_id: DbUrl, /// An optional bio, in markdown. pub bio: Option, /// Whether the person is local to our site. pub local: bool, #[serde(skip)] pub private_key: Option, #[serde(skip)] pub public_key: String, pub last_refreshed_at: DateTime, /// A URL for a banner. pub banner: Option, /// Whether the person is deleted. pub deleted: bool, #[cfg_attr(feature = "ts-rs", ts(skip))] #[serde(skip, default = "placeholder_apub_url")] pub inbox_url: DbUrl, /// A matrix id, usually given an @person:matrix.org pub matrix_user_id: Option, /// Whether the person is a bot account. pub bot_account: bool, pub instance_id: InstanceId, pub post_count: i32, #[serde(skip)] pub post_score: i32, pub comment_count: i32, #[serde(skip)] pub comment_score: i32, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = person))] pub struct PersonInsertForm { pub name: String, pub public_key: String, pub instance_id: InstanceId, #[new(default)] pub display_name: Option, #[new(default)] pub avatar: Option, #[new(default)] pub published_at: Option>, #[new(default)] pub updated_at: Option>, #[new(default)] pub ap_id: Option, #[new(default)] pub bio: Option, #[new(default)] pub local: Option, #[new(default)] pub private_key: Option, #[new(default)] pub last_refreshed_at: Option>, #[new(default)] pub banner: Option, #[new(default)] pub deleted: Option, #[new(default)] pub inbox_url: Option, #[new(default)] pub matrix_user_id: Option, #[new(default)] pub bot_account: Option, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = person))] pub struct PersonUpdateForm { pub display_name: Option>, pub avatar: Option>, pub updated_at: Option>>, pub ap_id: Option, pub bio: Option>, pub local: Option, pub public_key: Option, pub private_key: Option>, pub last_refreshed_at: Option>, pub banner: Option>, pub deleted: Option, pub inbox_url: Option, pub matrix_user_id: Option>, pub bot_account: Option, } #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, Associations) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))] #[cfg_attr(feature = "full", diesel(table_name = person_actions))] #[cfg_attr(feature = "full", diesel(primary_key(person_id, target_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PersonActions { #[serde(skip)] pub followed_at: Option>, /// When the person was blocked. pub blocked_at: Option>, #[serde(skip)] pub person_id: PersonId, #[serde(skip)] pub target_id: PersonId, #[serde(skip)] pub follow_pending: Option, /// When the person was noted. pub noted_at: Option>, /// A note about the person. pub note: Option, /// When the person was voted on. pub voted_at: Option>, /// A total of upvotes given to this person pub upvotes: Option, /// A total of downvotes given to this person pub downvotes: Option, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = person_actions))] pub struct PersonFollowerForm { pub target_id: PersonId, pub person_id: PersonId, pub follow_pending: bool, #[new(value = "Utc::now()")] pub followed_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = person_actions))] pub struct PersonBlockForm { pub person_id: PersonId, pub target_id: PersonId, #[new(value = "Utc::now()")] pub blocked_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = person_actions))] pub struct PersonNoteForm { pub person_id: PersonId, pub target_id: PersonId, pub note: String, #[new(value = "Utc::now()")] pub noted_at: DateTime, } ================================================ FILE: crates/db_schema/src/source/post.rs ================================================ use crate::newtypes::{CommunityId, LanguageId, PostId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::{PersonId, enums::PostNotificationsMode}; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::{post, post_actions}, }; #[skip_serializing_none] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = post))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = post_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A post. pub struct Post { pub id: PostId, pub name: String, /// An optional link / url for the post. pub url: Option, /// An optional post body, in markdown. pub body: Option, pub creator_id: PersonId, pub community_id: CommunityId, /// Whether the post is removed. pub removed: bool, /// Whether the post is locked. pub locked: bool, pub published_at: DateTime, pub updated_at: Option>, /// Whether the post is deleted. pub deleted: bool, /// Whether the post is NSFW. pub nsfw: bool, /// A title for the link. pub embed_title: Option, /// A description for the link. pub embed_description: Option, /// A thumbnail picture url. pub thumbnail_url: Option, /// The federated activity id / ap_id. pub ap_id: DbUrl, /// Whether the post is local. pub local: bool, /// A video url for the link. pub embed_video_url: Option, pub language_id: LanguageId, /// Whether the post is featured to its community. pub featured_community: bool, /// Whether the post is featured to its site. pub featured_local: bool, pub url_content_type: Option, /// An optional alt_text, usable for image posts. pub alt_text: Option, /// Time at which the post will be published. None means publish immediately. pub scheduled_publish_time_at: Option>, #[serde(skip)] /// A newest comment time, limited to 2 days, to prevent necrobumping pub newest_comment_time_necro_at: Option>, /// The time of the newest comment in the post, if the post has any comments. pub newest_comment_time_at: Option>, pub comments: i32, pub score: i32, pub upvotes: i32, pub downvotes: i32, #[serde(skip)] pub hot_rank: f32, #[serde(skip)] pub hot_rank_active: f32, #[serde(skip)] pub controversy_rank: f32, /// A rank that amplifies smaller communities #[serde(skip)] pub scaled_rank: f32, pub report_count: i16, pub unresolved_report_count: i16, /// If a local user posts in a remote community, the comment is hidden until it is confirmed /// accepted by the community (by receiving it back via federation). pub federation_pending: bool, pub embed_video_width: Option, pub embed_video_height: Option, } // TODO: FromBytes, ToBytes are only needed to develop wasm plugin, could be behind feature flag #[derive(Debug, Clone, derive_new::new, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset,))] #[cfg_attr(feature = "full", diesel(table_name = post))] pub struct PostInsertForm { pub name: String, pub creator_id: PersonId, pub community_id: CommunityId, #[new(default)] pub nsfw: Option, #[new(default)] pub url: Option, #[new(default)] pub body: Option, #[new(default)] pub removed: Option, #[new(default)] pub locked: Option, #[new(default)] pub updated_at: Option>, #[new(default)] pub published_at: Option>, #[new(default)] pub deleted: Option, #[new(default)] pub embed_title: Option, #[new(default)] pub embed_description: Option, #[new(default)] pub embed_video_url: Option, #[new(default)] pub embed_video_width: Option, #[new(default)] pub embed_video_height: Option, #[new(default)] pub thumbnail_url: Option, #[new(default)] pub ap_id: Option, #[new(default)] pub local: Option, #[new(default)] pub language_id: Option, #[new(default)] pub featured_community: Option, #[new(default)] pub featured_local: Option, #[new(default)] pub url_content_type: Option, #[new(default)] pub alt_text: Option, #[new(default)] pub scheduled_publish_time_at: Option>, #[new(default)] pub federation_pending: Option, } #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset, Serialize, Deserialize))] #[cfg_attr(feature = "full", diesel(table_name = post))] pub struct PostUpdateForm { pub name: Option, pub nsfw: Option, pub url: Option>, pub body: Option>, pub removed: Option, pub locked: Option, pub published_at: Option>, pub updated_at: Option>>, pub deleted: Option, pub embed_title: Option>, pub embed_description: Option>, pub embed_video_url: Option>, pub embed_video_width: Option>, pub embed_video_height: Option>, pub thumbnail_url: Option>, pub ap_id: Option, pub local: Option, pub language_id: Option, pub featured_community: Option, pub featured_local: Option, pub url_content_type: Option>, pub alt_text: Option>, pub scheduled_publish_time_at: Option>>, pub federation_pending: Option, } #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, Associations, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] #[cfg_attr(feature = "full", diesel(table_name = post_actions))] #[cfg_attr(feature = "full", diesel(primary_key(person_id, post_id)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = post_actions_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PostActions { /// When the post was read. pub read_at: Option>, /// When was the last time you read the comments. pub read_comments_at: Option>, /// When the post was saved. pub saved_at: Option>, /// When the post was upvoted or downvoted. pub voted_at: Option>, /// When the post was hidden. pub hidden_at: Option>, #[serde(skip)] pub person_id: PersonId, #[serde(skip)] pub post_id: PostId, /// The number of comments you read last. Subtract this from total comments to get an unread /// count. pub read_comments_amount: Option, /// True if upvoted, false if downvoted. Upvote is greater than downvote. pub vote_is_upvote: Option, pub notifications: Option, } #[derive(Clone, Serialize, Deserialize, Debug)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_actions))] pub struct PostLikeForm { pub post_id: PostId, pub person_id: PersonId, pub vote_is_upvote: Option>, pub voted_at: Option>>, } impl PostLikeForm { /// Pass `is_upvote: None` to remove an existing vote for this post pub fn new(post_id: PostId, person_id: PersonId, is_upvote: Option) -> Self { let voted_at = if is_upvote.is_some() { Some(Some(Utc::now())) } else { Some(None) }; Self { post_id, person_id, vote_is_upvote: Some(is_upvote), voted_at, } } } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_actions))] pub struct PostSavedForm { pub post_id: PostId, pub person_id: PersonId, #[new(value = "Utc::now()")] pub saved_at: DateTime, } #[derive(derive_new::new, Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_actions))] pub(crate) struct PostReadForm { pub post_id: PostId, pub person_id: PersonId, #[new(value = "Utc::now()")] pub read_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_actions))] pub struct PostReadCommentsForm { pub post_id: PostId, pub person_id: PersonId, pub read_comments_amount: i32, #[new(value = "Utc::now()")] pub read_comments_at: DateTime, } #[derive(derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_actions))] pub struct PostHideForm { pub post_id: PostId, pub person_id: PersonId, #[new(value = "Utc::now()")] pub hidden_at: DateTime, } ================================================ FILE: crates/db_schema/src/source/post_report.rs ================================================ use crate::newtypes::{PostId, PostReportId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::post_report; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, Associations) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] // Is this the right assoc? #[cfg_attr(feature = "full", diesel(table_name = post_report))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A post report. pub struct PostReport { pub id: PostReportId, pub creator_id: PersonId, pub post_id: PostId, /// The original post title. pub original_post_name: String, /// The original post url. pub original_post_url: Option, /// The original post body. pub original_post_body: Option, pub reason: String, pub resolved: bool, pub resolver_id: Option, pub published_at: DateTime, pub updated_at: Option>, pub violates_instance_rules: bool, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = post_report))] pub struct PostReportForm { pub creator_id: PersonId, pub post_id: PostId, pub original_post_name: String, pub original_post_url: Option, pub original_post_body: Option, pub reason: String, pub violates_instance_rules: bool, } ================================================ FILE: crates/db_schema/src/source/private_message.rs ================================================ use crate::newtypes::PrivateMessageId; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::private_message; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[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::person::Person, foreign_key = creator_id) ))] // Is this the right assoc? #[cfg_attr(feature = "full", diesel(table_name = private_message))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A private message. pub struct PrivateMessage { pub id: PrivateMessageId, pub creator_id: PersonId, pub recipient_id: PersonId, pub content: String, pub deleted: bool, pub published_at: DateTime, pub updated_at: Option>, pub ap_id: DbUrl, pub local: bool, pub removed: bool, pub deleted_by_recipient: bool, } #[derive(Clone, derive_new::new)] #[cfg_attr( feature = "full", derive(Insertable, AsChangeset, Serialize, Deserialize) )] #[cfg_attr(feature = "full", diesel(table_name = private_message))] pub struct PrivateMessageInsertForm { pub creator_id: PersonId, pub recipient_id: PersonId, pub content: String, #[new(default)] pub deleted: Option, #[new(default)] pub published_at: Option>, #[new(default)] pub updated_at: Option>, #[new(default)] pub ap_id: Option, #[new(default)] pub local: Option, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset, Serialize, Deserialize))] #[cfg_attr(feature = "full", diesel(table_name = private_message))] pub struct PrivateMessageUpdateForm { pub content: Option, pub deleted: Option, pub published_at: Option>, pub updated_at: Option>>, pub ap_id: Option, pub local: Option, pub removed: Option, pub deleted_by_recipient: Option, } ================================================ FILE: crates/db_schema/src/source/private_message_report.rs ================================================ use crate::newtypes::{PrivateMessageId, PrivateMessageReportId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::private_message_report; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Associations, Identifiable) )] #[cfg_attr( feature = "full", diesel(belongs_to(crate::source::private_message::PrivateMessage)) )] #[cfg_attr(feature = "full", diesel(table_name = private_message_report))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The private message report. pub struct PrivateMessageReport { pub id: PrivateMessageReportId, pub creator_id: PersonId, pub private_message_id: PrivateMessageId, /// The original text. pub original_pm_text: String, pub reason: String, pub resolved: bool, pub resolver_id: Option, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Clone)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = private_message_report))] pub struct PrivateMessageReportForm { pub creator_id: PersonId, pub private_message_id: PrivateMessageId, pub original_pm_text: String, pub reason: String, } ================================================ FILE: crates/db_schema/src/source/registration_application.rs ================================================ use crate::newtypes::{LocalUserId, RegistrationApplicationId}; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::PersonId; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use {i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::registration_application}; #[skip_serializing_none] #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = registration_application))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = registration_application_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A registration application. pub struct RegistrationApplication { pub id: RegistrationApplicationId, pub local_user_id: LocalUserId, pub answer: String, pub admin_id: Option, pub deny_reason: Option, pub published_at: DateTime, pub updated_at: Option>, } #[cfg_attr(feature = "full", derive(Insertable))] #[cfg_attr(feature = "full", diesel(table_name = registration_application))] pub struct RegistrationApplicationInsertForm { pub local_user_id: LocalUserId, pub answer: String, } #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = registration_application))] pub struct RegistrationApplicationUpdateForm { pub admin_id: Option>, pub deny_reason: Option>, pub updated_at: Option>>, } ================================================ FILE: crates/db_schema/src/source/secret.rs ================================================ #[cfg(feature = "full")] use lemmy_db_schema_file::schema::secret; use lemmy_diesel_utils::sensitive::SensitiveString; #[derive(Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = secret))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] pub struct Secret { pub id: i32, pub jwt_secret: SensitiveString, } ================================================ FILE: crates/db_schema/src/source/site.rs ================================================ use crate::newtypes::SiteId; use chrono::{DateTime, Utc}; use lemmy_db_schema_file::InstanceId; #[cfg(feature = "full")] use lemmy_db_schema_file::schema::site; use lemmy_diesel_utils::{dburl::DbUrl, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable))] #[cfg_attr(feature = "full", diesel(table_name = site))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Additional data for federated instances. This may be missing for other platforms which are not /// fully compatible. Basic data is guaranteed to be available via [[Instance]]. pub struct Site { pub id: SiteId, pub name: String, /// A sidebar for the site in markdown. pub sidebar: Option, pub published_at: DateTime, pub updated_at: Option>, /// An icon URL. pub icon: Option, /// A banner url. pub banner: Option, /// A shorter, one-line summary of the site. pub summary: Option, /// The federated ap_id. pub ap_id: DbUrl, /// The time the site was last refreshed. pub last_refreshed_at: DateTime, /// The site inbox pub inbox_url: DbUrl, #[serde(skip)] pub private_key: Option, #[serde(skip)] pub public_key: String, pub instance_id: InstanceId, /// If present, nsfw content is visible by default. Should be displayed by frontends/clients /// when the site is first opened by a user. pub content_warning: Option, } #[derive(Clone, derive_new::new)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = site))] pub struct SiteInsertForm { pub name: String, pub instance_id: InstanceId, #[new(default)] pub sidebar: Option, #[new(default)] pub published_at: Option>, #[new(default)] pub updated_at: Option>, #[new(default)] pub icon: Option, #[new(default)] pub banner: Option, #[new(default)] pub summary: Option, #[new(default)] pub ap_id: Option, #[new(default)] pub last_refreshed_at: Option>, #[new(default)] pub inbox_url: Option, #[new(default)] pub private_key: Option, #[new(default)] pub public_key: Option, #[new(default)] pub content_warning: Option, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = site))] pub struct SiteUpdateForm { pub name: Option, pub sidebar: Option>, pub updated_at: Option>>, // when you want to null out a column, you have to send Some(None)), since sending None means you // just don't want to update that column. pub icon: Option>, pub banner: Option>, pub summary: Option>, pub ap_id: Option, pub last_refreshed_at: Option>, pub inbox_url: Option, pub private_key: Option>, pub public_key: Option, pub content_warning: Option>, } ================================================ FILE: crates/db_schema/src/source/tagline.rs ================================================ use crate::newtypes::TaglineId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use {i_love_jesus::CursorKeysModule, lemmy_db_schema_file::schema::tagline}; #[skip_serializing_none] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[cfg_attr( feature = "full", derive(Queryable, Selectable, Identifiable, CursorKeysModule) )] #[cfg_attr(feature = "full", diesel(table_name = tagline))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", cursor_keys_module(name = tagline_keys))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A tagline, shown at the top of your site. pub struct Tagline { pub id: TaglineId, pub content: String, pub published_at: DateTime, pub updated_at: Option>, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = tagline))] pub struct TaglineInsertForm { pub content: String, } #[derive(Clone, Default)] #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = tagline))] pub struct TaglineUpdateForm { pub content: String, pub updated_at: Option>>, } ================================================ FILE: crates/db_schema/src/test_data.rs ================================================ use crate::source::{ instance::Instance, local_site::{LocalSite, LocalSiteInsertForm}, local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitInsertForm}, person::{Person, PersonInsertForm}, site::{Site, SiteInsertForm}, }; use lemmy_diesel_utils::{connection::DbPool, traits::Crud}; use lemmy_utils::error::LemmyResult; pub struct TestData { pub instance: Instance, pub site: Site, pub local_site: LocalSite, pub person: Person, } impl TestData { pub async fn create(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let site_form = SiteInsertForm::new("test site".to_string(), instance.id); let site = Site::create(pool, &site_form).await?; let person = Person::create(pool, &PersonInsertForm::test_form(instance.id, "langs")).await?; let local_site_form = LocalSiteInsertForm { system_account: Some(person.id), ..LocalSiteInsertForm::new(site.id) }; let local_site = LocalSite::create(pool, &local_site_form).await?; LocalSiteRateLimit::create(pool, &LocalSiteRateLimitInsertForm::new(local_site.id)).await?; let person_form = PersonInsertForm::test_form(instance.id, "holly"); let person = Person::create(pool, &person_form).await?; Ok(Self { instance, site, local_site, person, }) } pub async fn delete(self, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, self.instance.id).await?; Site::delete(pool, self.site.id).await?; Ok(()) } } ================================================ FILE: crates/db_schema/src/traits.rs ================================================ use crate::newtypes::CommunityId; use diesel_uplete::UpleteCount; use lemmy_db_schema_file::PersonId; use lemmy_diesel_utils::{connection::DbPool, dburl::DbUrl}; use lemmy_utils::{error::LemmyResult, settings::structs::Settings}; use std::future::Future; use url::Url; pub trait Followable: Sized { type Form; type IdType; fn follow( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn follow_accepted( pool: &mut DbPool<'_>, community_id: CommunityId, person_id: PersonId, ) -> impl Future> + Send; fn unfollow( pool: &mut DbPool<'_>, person_id: PersonId, item_id: Self::IdType, ) -> impl Future> + Send; } pub trait Likeable: Sized { type Form; type IdType; fn like( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn remove_all_likes( pool: &mut DbPool<'_>, creator_id: PersonId, ) -> impl Future> + Send; fn remove_likes_in_community( pool: &mut DbPool<'_>, creator_id: PersonId, community_id: CommunityId, ) -> impl Future> + Send; } pub trait Bannable: Sized { type Form; fn ban( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn unban( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; } pub trait Saveable: Sized { type Form; fn save( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn unsave( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; } pub trait Blockable: Sized { type Form; type ObjectIdType; type ObjectType; fn block( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn unblock( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn read_block( pool: &mut DbPool<'_>, for_person_id: PersonId, for_item_id: Self::ObjectIdType, ) -> impl Future> + Send; fn read_blocks_for_person( pool: &mut DbPool<'_>, person_id: PersonId, // Note: cant use lemmyresult because of try_pool ) -> impl Future>> + Send; } pub trait Reportable: Sized { type Form; type IdType; type ObjectIdType; fn report( pool: &mut DbPool<'_>, form: &Self::Form, ) -> impl Future> + Send; fn update_resolved( pool: &mut DbPool<'_>, report_id: Self::IdType, resolver_id: PersonId, is_resolved: bool, ) -> impl Future> + Send; fn resolve_apub( pool: &mut DbPool<'_>, object_id: Self::ObjectIdType, report_creator_id: PersonId, resolver_id: PersonId, ) -> impl Future> + Send; fn resolve_all_for_object( pool: &mut DbPool<'_>, comment_id_: Self::ObjectIdType, by_resolver_id: PersonId, ) -> impl Future> + Send; } pub trait ApubActor: Sized { fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: &DbUrl, ) -> impl Future>> + Send; /// - actor_name is the name of the community or user to read. /// - domain if None only local actors are searched, if Some only actors from that domain /// - include_deleted, if true, will return communities or users that were deleted/removed fn read_from_name( pool: &mut DbPool<'_>, actor_name: &str, domain: Option<&str>, include_deleted: bool, ) -> impl Future>> + Send; fn generate_local_actor_url(name: &str, settings: &Settings) -> LemmyResult; fn actor_url(&self, settings: &Settings) -> LemmyResult; } pub trait InternalToCombinedView { type CombinedView; /// Maps the combined DB row to an enum fn map_to_enum(self) -> Option; } ================================================ FILE: crates/db_schema/src/utils/mod.rs ================================================ pub mod queries; use chrono::TimeDelta; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use url::Url; const FETCH_LIMIT_DEFAULT: i64 = 20; pub const FETCH_LIMIT_MAX: usize = 50; pub const SITEMAP_LIMIT: i64 = 50000; pub const SITEMAP_DAYS: TimeDelta = TimeDelta::days(31); pub const RANK_DEFAULT: f32 = 0.0001; pub const DELETED_REPLACEMENT_TEXT: &str = "*Permanently Deleted*"; pub fn limit_fetch(limit: Option, no_limit: Option) -> LemmyResult { Ok(if no_limit.unwrap_or_default() { i64::MAX } else { match limit { Some(limit) => limit_fetch_check(limit)?, None => FETCH_LIMIT_DEFAULT, } }) } pub fn limit_fetch_check(limit: i64) -> LemmyResult { if !(1..=FETCH_LIMIT_MAX.try_into()?).contains(&limit) { Err(LemmyErrorType::InvalidFetchLimit.into()) } else { Ok(limit) } } pub(crate) fn format_actor_url( name: &str, domain: &str, prefix: char, settings: &Settings, ) -> LemmyResult { let local_protocol_and_hostname = settings.get_protocol_and_hostname(); let local_hostname = &settings.hostname; let url = if domain != local_hostname { format!("{local_protocol_and_hostname}/{prefix}/{name}@{domain}",) } else { format!("{local_protocol_and_hostname}/{prefix}/{name}") }; Ok(Url::parse(&url)?) } ================================================ FILE: crates/db_schema/src/utils/queries/filters.rs ================================================ use diesel::{ BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, QueryDsl, helper_types::{Eq, NotEq}, }; use lemmy_db_schema_file::{ aliases::my_instance_persons_actions, enums::{CommunityFollowerState, CommunityVisibility}, schema::{ community, community_actions, instance_actions, local_site, multi_community, multi_community_entry, person_actions, }, }; /// Hide all content from blocked communities and persons. Content from blocked instances is also /// hidden, unless the user followed the community explicitly. #[diesel::dsl::auto_type] pub fn filter_blocked() -> _ { instance_actions::blocked_communities_at .is_null() .or(community_actions::followed_at.is_not_null()) .and(community_actions::blocked_at.is_null()) .and(person_actions::blocked_at.is_null()) .and( my_instance_persons_actions .field(instance_actions::blocked_persons_at) .is_null(), ) } type IsSubscribedType = Eq>; pub fn filter_is_subscribed() -> IsSubscribedType { community_actions::follow_state.eq(Some(CommunityFollowerState::Accepted)) } type IsNotUnlistedType = NotEq; #[diesel::dsl::auto_type] pub fn filter_not_unlisted_or_is_subscribed() -> _ { let not_unlisted: IsNotUnlistedType = community::visibility.ne(CommunityVisibility::Unlisted); let is_subscribed: IsSubscribedType = filter_is_subscribed(); not_unlisted.or(is_subscribed) } #[diesel::dsl::auto_type] pub fn filter_suggested_communities() -> _ { community::id.eq_any( local_site::table .left_join(multi_community::table.inner_join(multi_community_entry::table)) .filter(multi_community_entry::community_id.is_not_null()) .select(multi_community_entry::community_id.assume_not_null()), ) } ================================================ FILE: crates/db_schema/src/utils/queries/mod.rs ================================================ pub mod filters; pub mod selects; ================================================ FILE: crates/db_schema/src/utils/queries/selects.rs ================================================ use crate::{Person1AliasAllColumnsTuple, Person2AliasAllColumnsTuple}; use diesel::{ BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, PgExpressionMethods, QueryDsl, dsl::{case_when, exists, not}, expression::SqlLiteral, helper_types::Nullable, query_source::AliasedField, sql_types::{Json, Timestamptz}, }; use lemmy_db_schema_file::{ aliases::{ CreatorCommunityInstanceActions, CreatorHomeInstanceActions, CreatorLocalInstanceActions, creator_community_actions, creator_community_instance_actions, creator_home_instance_actions, creator_local_instance_actions, creator_local_user, person1, person2, }, schema::{ comment, community, community_actions, community_tag, instance_actions, local_user, person, post, post_community_tag, }, }; use lemmy_diesel_utils::utils::functions::{coalesce_2_nullable, coalesce_3_nullable}; /// Checks that the creator_local_user is an admin. #[diesel::dsl::auto_type] pub fn creator_is_admin() -> _ { creator_local_user .field(local_user::admin) .nullable() .is_not_distinct_from(true) } /// Checks that the local_user is an admin. #[diesel::dsl::auto_type] pub fn local_user_is_admin() -> _ { local_user::admin.nullable().is_not_distinct_from(true) } /// Checks to see if the comment creator is an admin. #[diesel::dsl::auto_type] pub fn comment_creator_is_admin() -> _ { exists( creator_local_user.filter( comment::creator_id .eq(creator_local_user.field(local_user::person_id)) .and(creator_local_user.field(local_user::admin).eq(true)), ), ) } #[diesel::dsl::auto_type] pub fn post_creator_is_admin() -> _ { exists( creator_local_user.filter( post::creator_id .eq(creator_local_user.field(local_user::person_id)) .and(creator_local_user.field(local_user::admin).eq(true)), ), ) } #[diesel::dsl::auto_type] pub fn creator_is_moderator() -> _ { creator_community_actions .field(community_actions::became_moderator_at) .nullable() .is_not_null() } #[diesel::dsl::auto_type] pub fn creator_banned_from_community() -> _ { creator_community_actions .field(community_actions::received_ban_at) .nullable() .is_not_null() } #[diesel::dsl::auto_type] pub fn creator_ban_expires_from_community() -> _ { creator_community_actions .field(community_actions::ban_expires_at) .nullable() } #[diesel::dsl::auto_type] /// Checks to see if a creator is banned from the local instance. fn creator_local_banned() -> _ { creator_local_instance_actions .field(instance_actions::received_ban_at) .nullable() .is_not_null() } #[diesel::dsl::auto_type] fn creator_local_ban_expires() -> _ { creator_local_instance_actions .field(instance_actions::ban_expires_at) .nullable() } #[diesel::dsl::auto_type] /// Checks to see if a creator is banned from their community's instance fn creator_community_instance_banned() -> _ { creator_community_instance_actions .field(instance_actions::received_ban_at) .nullable() .is_not_null() } #[diesel::dsl::auto_type] fn creator_community_instance_ban_expires() -> _ { creator_community_instance_actions .field(instance_actions::ban_expires_at) .nullable() } #[diesel::dsl::auto_type] /// Checks to see if a creator is banned from their home instance pub fn creator_home_banned() -> _ { creator_home_instance_actions .field(instance_actions::received_ban_at) .nullable() .is_not_null() } #[diesel::dsl::auto_type] /// Checks to see if a creator is banned from their home instance pub fn creator_home_ban_expires() -> _ { creator_home_instance_actions .field(instance_actions::ban_expires_at) .nullable() } #[diesel::dsl::auto_type] /// Checks to see if a user is site banned from any of these places: /// - Their own instance /// - The local instance pub fn creator_local_home_banned() -> _ { creator_local_banned().or(creator_home_banned()) } pub type CreatorLocalHomeBanExpiresType = coalesce_2_nullable< Timestamptz, Nullable>, Nullable>, >; pub fn creator_local_home_ban_expires() -> CreatorLocalHomeBanExpiresType { coalesce_2_nullable(creator_local_ban_expires(), creator_home_ban_expires()) } /// Checks to see if a user is site banned from any of these places: /// - The local instance /// - Their own instance /// - The community instance. #[diesel::dsl::auto_type] pub fn creator_local_home_community_banned() -> _ { creator_local_banned() .or(creator_home_banned()) .or(creator_community_instance_banned()) } pub type CreatorLocalHomeCommunityBanExpiresType = coalesce_3_nullable< Timestamptz, Nullable>, Nullable>, Nullable>, >; pub fn creator_local_home_community_ban_expires() -> CreatorLocalHomeCommunityBanExpiresType { coalesce_3_nullable( creator_local_ban_expires(), creator_home_ban_expires(), creator_community_instance_ban_expires(), ) } #[diesel::dsl::auto_type] fn am_higher_mod() -> _ { let i_became_moderator = community_actions::became_moderator_at.nullable(); let creator_became_moderator = creator_community_actions .field(community_actions::became_moderator_at) .nullable(); i_became_moderator.is_not_null().and( creator_became_moderator .ge(i_became_moderator) .is_distinct_from(false), ) } /// Checks to see if you can mod an item. /// /// Caveat: Since admin status isn't federated or ordered, it can't know whether /// item creator is a federated admin, or a higher admin. /// The back-end will reject an action for admin that is higher via /// LocalUser::is_higher_mod_or_admin_check #[diesel::dsl::auto_type] pub fn local_user_can_mod() -> _ { local_user_is_admin().or(not(creator_is_admin()).and(am_higher_mod())) } /// Checks to see if you can mod a post. #[diesel::dsl::auto_type] pub fn local_user_can_mod_post() -> _ { local_user_is_admin().or(not(post_creator_is_admin()).and(am_higher_mod())) } /// Checks to see if you can mod a comment. #[diesel::dsl::auto_type] pub fn local_user_can_mod_comment() -> _ { local_user_is_admin().or(not(comment_creator_is_admin()).and(am_higher_mod())) } /// A special type of can_mod for communities, which dont have creators. #[diesel::dsl::auto_type] pub fn local_user_community_can_mod() -> _ { let am_admin = local_user::admin.nullable(); let am_moderator = community_actions::became_moderator_at .nullable() .is_not_null(); am_admin.or(am_moderator).is_not_distinct_from(true) } /// Selects the comment columns, but gives an empty string for content when /// deleted or removed, and you're not a mod/admin. #[diesel::dsl::auto_type] pub fn comment_select_remove_deletes() -> _ { let deleted_or_removed = comment::deleted.or(comment::removed); // You can only view the content if it hasn't been removed, you're a mod or it's your own comment. let is_creator = local_user::person_id .nullable() .eq(comment::creator_id.nullable()); let can_view_content = not(deleted_or_removed) .or(local_user_can_mod_comment()) .or(is_creator); let content = case_when(can_view_content, comment::content).otherwise(""); ( comment::id, comment::creator_id, comment::post_id, content, comment::removed, comment::published_at, comment::updated_at, comment::deleted, comment::ap_id, comment::local, comment::path, comment::distinguished, comment::language_id, comment::score, comment::upvotes, comment::downvotes, comment::child_count, comment::hot_rank, comment::controversy_rank, comment::report_count, comment::unresolved_report_count, comment::federation_pending, comment::locked, ) } /// Selects the post columns, but gives an empty string for content when /// deleted or removed, and you're not a mod/admin. #[diesel::dsl::auto_type] pub fn post_select_remove_deletes() -> _ { let deleted_or_removed = post::deleted.or(post::removed); // You can only view the content if it hasn't been removed, you're a mod or it's your own post. let is_creator = local_user::person_id .nullable() .eq(post::creator_id.nullable()); let can_view_content = not(deleted_or_removed) .or(local_user_can_mod_post()) .or(is_creator); let body = case_when(can_view_content, post::body).otherwise(""); ( post::id, post::name, post::url, body, post::creator_id, post::community_id, post::removed, post::locked, post::published_at, post::updated_at, post::deleted, post::nsfw, post::embed_title, post::embed_description, post::thumbnail_url, post::ap_id, post::local, post::embed_video_url, post::language_id, post::featured_community, post::featured_local, post::url_content_type, post::alt_text, post::scheduled_publish_time_at, post::newest_comment_time_necro_at, post::newest_comment_time_at, post::comments, post::score, post::upvotes, post::downvotes, post::hot_rank, post::hot_rank_active, post::controversy_rank, post::scaled_rank, post::report_count, post::unresolved_report_count, post::federation_pending, post::embed_video_width, post::embed_video_height, ) } #[diesel::dsl::auto_type] // Gets the post community tags set on a specific post. pub fn post_community_tags_fragment() -> _ { let sel: SqlLiteral = diesel::dsl::sql::("json_agg(community_tag.*)"); post_community_tag::table .inner_join(community_tag::table) .select(sel) .filter(post_community_tag::post_id.eq(post::id)) .filter(community_tag::deleted.eq(false)) .single_value() } #[diesel::dsl::auto_type] /// Gets the tags available within a specific community pub fn community_tags_fragment() -> _ { let sel: SqlLiteral = diesel::dsl::sql::("json_agg(community_tag.*)"); community_tag::table .select(sel) .filter(community_tag::community_id.eq(community::id)) .filter( community_tag::deleted .eq(false) // Show deleted tags for admins and mods .or(local_user_community_can_mod()), ) .single_value() } /// The select for the person1 alias. pub fn person1_select() -> Person1AliasAllColumnsTuple { person1.fields(person::all_columns) } /// The select for the person2 alias. pub fn person2_select() -> Person2AliasAllColumnsTuple { person2.fields(person::all_columns) } ================================================ FILE: crates/db_schema_file/Cargo.toml ================================================ [package] name = "lemmy_db_schema_file" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_db_schema_file" path = "src/lib.rs" doctest = false test = false [lints] workspace = true [features] full = [ "diesel", "diesel_ltree", "diesel-derive-enum", "diesel-uplete", "diesel-derive-newtype", ] ts-rs = ["dep:ts-rs"] [dependencies] serde = { workspace = true } strum = { workspace = true } diesel = { workspace = true, optional = true } diesel_ltree = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true } diesel-derive-enum = { workspace = true, optional = true } diesel-uplete = { workspace = true, optional = true } diesel-derive-newtype = { workspace = true, optional = true } ================================================ FILE: crates/db_schema_file/diesel_ltree.patch ================================================ diff --git a/crates/db_schema_file/src/schema.rs b/crates/db_schema_file/src/schema.rs index 8bc07d2c3..3b95487ec 100644 --- a/crates/db_schema_file/src/schema.rs +++ b/crates/db_schema_file/src/schema.rs @@ -68,7 +68,7 @@ pub mod sql_types { diesel::table! { use diesel::sql_types::*; - use super::sql_types::Ltree; + use diesel_ltree::sql_types::Ltree; comment (id) { id -> Int4, @@ -1078,5 +1078,7 @@ diesel::allow_tables_to_appear_in_same_query!( search_combined, site, site_language, + person_actions, + image_details, ); diesel::allow_tables_to_appear_in_same_query!(custom_emoji, custom_emoji_keyword,); ================================================ FILE: crates/db_schema_file/src/enums.rs ================================================ #[cfg(feature = "full")] use diesel_derive_enum::DbEnum; use serde::{Deserialize, Serialize}; use strum::Display; #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::PostSortTypeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The post sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html pub enum PostSortType { #[default] Active, Hot, New, Old, Top, MostComments, NewComments, Controversial, Scaled, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::CommentSortTypeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The comment sort types. See here for descriptions: https://join-lemmy.org/docs/en/users/03-votes-and-ranking.html pub enum CommentSortType { #[default] Hot, Top, New, Old, Controversial, } #[derive(Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::ListingTypeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A listing type for post and comment list fetches. pub enum ListingType { /// Content from your own site, as well as all connected / federated sites. All, /// Content from your site only. #[default] Local, /// Content only from communities you've subscribed to. Subscribed, /// Content that you can moderate (because you are a moderator of the community it is posted to) ModeratorView, /// Communities which are recommended by local instance admins Suggested, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::RegistrationModeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The registration mode for your site. Determines what happens after a user signs up. pub enum RegistrationMode { /// Closed to public. Closed, /// Open, but pending approval of a registration application. RequireApplication, /// Open to all. #[default] Open, } #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::PostListingModeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A post-view mode that changes how multiple post listings look. pub enum PostListingMode { /// A compact, list-type view. #[default] List, /// A larger card-type view. Card, /// A smaller card-type view, usually with images as thumbnails SmallCard, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::CommunityVisibility" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// Defines who can browse and interact with content in a community. pub enum CommunityVisibility { /// Public community, any local or federated user can interact. #[default] Public, /// Community is unlisted/hidden and doesn't appear in community list. Posts from the community /// are not shown in Local and All feeds, except for subscribed users. Unlisted, /// Unfederated community, only local users can interact (with or without login). LocalOnlyPublic, /// Unfederated community, only logged-in local users can interact. LocalOnlyPrivate, /// Users need to be approved by mods before they are able to browse or post. Private, } impl CommunityVisibility { pub fn can_federate(&self) -> bool { use CommunityVisibility::*; self != &LocalOnlyPublic && self != &LocalOnlyPrivate } pub fn can_view_without_login(&self) -> bool { use CommunityVisibility::*; self == &Public || self == &LocalOnlyPublic } } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::FederationModeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// The federation mode for an item pub enum FederationMode { #[default] /// Allows all All, /// Allows only local Local, /// Disables Disable, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::ImageModeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A mode for setting how pictrs handles images. pub enum ImageMode { /// Leave images unchanged, don't generate any local thumbnails for post urls. Instead the /// Opengraph image is directly returned as thumbnail None, /// Generate thumbnails for external post urls and store them persistently in pict-rs. This /// ensures that they can be reliably retrieved and can be resized using pict-rs APIs. However it /// also increases storage usage. /// /// This behaviour matches Lemmy 0.18. StoreLinkPreviews, /// If enabled, all images from remote domains are rewritten to pass through /// `/api/v4/image/proxy`, including embedded images in markdown. Images are stored temporarily in /// pict-rs for caching. This improves privacy as users don't expose their IP to untrusted /// servers, and decreases load on other servers. However it increases bandwidth use for the local /// server. /// /// Requires pict-rs 0.5 #[default] ProxyAllImages, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::ActorTypeEnum" )] pub enum ActorType { Site, Community, Person, MultiCommunity, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::CommunityFollowerState" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] pub enum CommunityFollowerState { Accepted, Pending, ApprovalRequired, Denied, } #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::TagColorEnum" )] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// Color of community tag. pub enum TagColor { #[default] Color01, Color02, Color03, Color04, Color05, Color06, Color07, Color08, Color09, Color10, } #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::VoteShowEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// Lets you show votes for others only, show all votes, or hide all votes. pub enum VoteShow { #[default] Show, ShowForOthers, Hide, } #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::PostNotificationsModeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// Available settings for post notifications pub enum PostNotificationsMode { AllComments, #[default] RepliesAndMentions, Mute, } #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::CommunityNotificationsModeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// Available settings for community notifications pub enum CommunityNotificationsMode { AllPostsAndComments, AllPosts, #[default] RepliesAndMentions, Mute, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::NotificationTypeEnum" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// Types of notifications which can be received in inbox pub enum NotificationType { // Necessary for enumstring #[default] Mention, Reply, Subscribed, PrivateMessage, ModAction, } #[derive(Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Hash)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "full", derive(DbEnum))] #[cfg_attr( feature = "full", ExistingTypePath = "crate::schema::sql_types::ModlogKind" )] #[cfg_attr(feature = "full", DbValueStyle = "verbatim")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A list of possible types for the various modlog actions. pub enum ModlogKind { // Necessary for enumstring #[default] AdminAdd, AdminBan, AdminAllowInstance, AdminBlockInstance, AdminPurgeComment, AdminPurgeCommunity, AdminPurgePerson, AdminPurgePost, ModAddToCommunity, ModBanFromCommunity, AdminFeaturePostSite, ModFeaturePostCommunity, ModChangeCommunityVisibility, ModLockPost, ModRemoveComment, AdminRemoveCommunity, ModRemovePost, ModTransferCommunity, ModLockComment, ModWarnComment, ModWarnPost, } ================================================ FILE: crates/db_schema_file/src/joins.rs ================================================ use crate::{ InstanceId, PersonId, aliases::{ creator_community_actions, creator_community_instance_actions, creator_home_instance_actions, creator_local_instance_actions, creator_local_user, my_instance_persons_actions, }, schema::{ comment, comment_actions, community, community_actions, image_details, instance_actions, local_user, multi_community, multi_community_follow, person, person_actions, post, post_actions, }, }; use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods}; #[diesel::dsl::auto_type] pub fn creator_local_user_admin_join() -> _ { creator_local_user.on( person::id .eq(creator_local_user.field(local_user::person_id)) .and(creator_local_user.field(local_user::admin).eq(true)), ) } #[diesel::dsl::auto_type] pub fn community_join() -> _ { community::table.on(post::community_id.eq(community::id)) } #[diesel::dsl::auto_type] pub fn creator_home_instance_actions_join() -> _ { creator_home_instance_actions.on( creator_home_instance_actions .field(instance_actions::instance_id) .eq(person::instance_id) .and( creator_home_instance_actions .field(instance_actions::person_id) .eq(person::id), ), ) } #[diesel::dsl::auto_type] pub fn creator_community_instance_actions_join() -> _ { creator_community_instance_actions.on( creator_home_instance_actions .field(instance_actions::instance_id) .eq(community::instance_id) .and( creator_community_instance_actions .field(instance_actions::person_id) .eq(person::id), ), ) } /// join with instance actions for local instance /// /// Requires annotation for return type, see https://docs.diesel.rs/2.2.x/diesel/dsl/attr.auto_type.html#annotating-types #[diesel::dsl::auto_type] pub fn creator_local_instance_actions_join(local_instance_id: InstanceId) -> _ { creator_local_instance_actions.on( creator_local_instance_actions .field(instance_actions::instance_id) .eq(local_instance_id) .and( creator_local_instance_actions .field(instance_actions::person_id) .eq(person::id), ), ) } /// Your instance actions for the community's instance. #[diesel::dsl::auto_type] pub fn my_instance_communities_actions_join(my_person_id: Option) -> _ { instance_actions::table.on( instance_actions::instance_id .eq(community::instance_id) .and(instance_actions::person_id.nullable().eq(my_person_id)), ) } /// Your instance actions for the person's instance. #[diesel::dsl::auto_type] pub fn my_instance_persons_actions_join(my_person_id: Option) -> _ { instance_actions::table.on( instance_actions::instance_id .eq(person::instance_id) .and(instance_actions::person_id.nullable().eq(my_person_id)), ) } /// Your instance actions for the person's instance. /// A dupe of the above function, but aliased #[diesel::dsl::auto_type] pub fn my_instance_persons_actions_join_1(my_person_id: Option) -> _ { my_instance_persons_actions.on( my_instance_persons_actions .field(instance_actions::instance_id) .eq(person::instance_id) .and( my_instance_persons_actions .field(instance_actions::person_id) .nullable() .eq(my_person_id), ), ) } #[diesel::dsl::auto_type] pub fn image_details_join() -> _ { image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable())) } #[diesel::dsl::auto_type] pub fn my_community_actions_join(my_person_id: Option) -> _ { community_actions::table.on( community_actions::community_id .eq(community::id) .and(community_actions::person_id.nullable().eq(my_person_id)), ) } #[diesel::dsl::auto_type] pub fn my_post_actions_join(my_person_id: Option) -> _ { post_actions::table.on( post_actions::post_id .eq(post::id) .and(post_actions::person_id.nullable().eq(my_person_id)), ) } #[diesel::dsl::auto_type] pub fn my_comment_actions_join(my_person_id: Option) -> _ { comment_actions::table.on( comment_actions::comment_id .eq(comment::id) .and(comment_actions::person_id.nullable().eq(my_person_id)), ) } #[diesel::dsl::auto_type] pub fn my_person_actions_join(my_person_id: Option) -> _ { person_actions::table.on( person_actions::target_id .eq(person::id) .and(person_actions::person_id.nullable().eq(my_person_id)), ) } #[diesel::dsl::auto_type] pub fn my_local_user_admin_join(my_person_id: Option) -> _ { local_user::table.on( local_user::person_id .nullable() .eq(my_person_id) .and(local_user::admin.eq(true)), ) } #[diesel::dsl::auto_type] pub fn my_multi_community_follower_join(my_person_id: Option) -> _ { multi_community_follow::table.on( multi_community_follow::multi_community_id .eq(multi_community::id) .and( multi_community_follow::person_id .nullable() .eq(my_person_id), ), ) } #[diesel::dsl::auto_type] pub fn creator_community_actions_join() -> _ { creator_community_actions.on( creator_community_actions .field(community_actions::community_id) .eq(community::id) .and( creator_community_actions .field(community_actions::person_id) .eq(person::id), ), ) } ================================================ FILE: crates/db_schema_file/src/lib.rs ================================================ use core::default::Default; #[cfg(feature = "full")] use diesel_derive_newtype::DieselNewType; use serde::{Deserialize, Serialize}; pub mod enums; #[cfg(feature = "full")] pub mod joins; #[cfg(feature = "full")] pub mod schema; #[cfg(feature = "full")] pub mod table_impls; #[cfg(feature = "full")] pub mod aliases { use crate::schema::{community_actions, instance_actions, local_user, person}; diesel::alias!( community_actions as creator_community_actions: CreatorCommunityActions, instance_actions as creator_home_instance_actions: CreatorHomeInstanceActions, instance_actions as creator_community_instance_actions: CreatorCommunityInstanceActions, instance_actions as creator_local_instance_actions: CreatorLocalInstanceActions, instance_actions as my_instance_persons_actions: MyInstancePersonsActions, local_user as creator_local_user: CreatorLocalUser, person as person1: Person1, person as person2: Person2, ); } #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The person id. pub struct PersonId(pub i32); #[derive( Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default, Ord, PartialOrd, )] #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The instance id. pub struct InstanceId(pub i32); impl InstanceId { pub fn inner(self) -> i32 { self.0 } } ================================================ FILE: crates/db_schema_file/src/schema.rs ================================================ // @generated automatically by Diesel CLI. pub mod sql_types { #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "actor_type_enum"))] pub struct ActorTypeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "comment_sort_type_enum"))] pub struct CommentSortTypeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "community_follower_state"))] pub struct CommunityFollowerState; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "community_notifications_mode_enum"))] pub struct CommunityNotificationsModeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "community_visibility"))] pub struct CommunityVisibility; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "federation_mode_enum"))] pub struct FederationModeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "image_mode_enum"))] pub struct ImageModeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "listing_type_enum"))] pub struct ListingTypeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "ltree"))] pub struct Ltree; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "modlog_kind"))] pub struct ModlogKind; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "notification_type_enum"))] pub struct NotificationTypeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "post_listing_mode_enum"))] pub struct PostListingModeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "post_notifications_mode_enum"))] pub struct PostNotificationsModeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "post_sort_type_enum"))] pub struct PostSortTypeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "registration_mode_enum"))] pub struct RegistrationModeEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "tag_color_enum"))] pub struct TagColorEnum; #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "vote_show_enum"))] pub struct VoteShowEnum; } diesel::table! { use diesel::sql_types::*; use diesel_ltree::sql_types::Ltree; comment (id) { id -> Int4, creator_id -> Int4, post_id -> Int4, content -> Text, removed -> Bool, published_at -> Timestamptz, updated_at -> Nullable, deleted -> Bool, #[max_length = 255] ap_id -> Varchar, local -> Bool, path -> Ltree, distinguished -> Bool, language_id -> Int4, score -> Int4, upvotes -> Int4, downvotes -> Int4, child_count -> Int4, hot_rank -> Float4, controversy_rank -> Float4, report_count -> Int2, unresolved_report_count -> Int2, federation_pending -> Bool, locked -> Bool, } } diesel::table! { comment_actions (person_id, comment_id) { voted_at -> Nullable, saved_at -> Nullable, person_id -> Int4, comment_id -> Int4, vote_is_upvote -> Nullable, } } diesel::table! { comment_report (id) { id -> Int4, creator_id -> Int4, comment_id -> Int4, original_comment_text -> Text, reason -> Text, resolved -> Bool, resolver_id -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, violates_instance_rules -> Bool, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::CommunityVisibility; community (id) { id -> Int4, #[max_length = 255] name -> Varchar, #[max_length = 50] title -> Varchar, sidebar -> Nullable, removed -> Bool, published_at -> Timestamptz, updated_at -> Nullable, deleted -> Bool, nsfw -> Bool, #[max_length = 255] ap_id -> Varchar, local -> Bool, private_key -> Nullable, public_key -> Text, last_refreshed_at -> Timestamptz, icon -> Nullable, banner -> Nullable, #[max_length = 255] followers_url -> Nullable, #[max_length = 255] inbox_url -> Varchar, posting_restricted_to_mods -> Bool, instance_id -> Int4, #[max_length = 255] moderators_url -> Nullable, #[max_length = 255] featured_url -> Nullable, visibility -> CommunityVisibility, #[max_length = 150] summary -> Nullable, random_number -> Int2, subscribers -> Int4, posts -> Int4, comments -> Int4, users_active_day -> Int4, users_active_week -> Int4, users_active_month -> Int4, users_active_half_year -> Int4, hot_rank -> Float4, subscribers_local -> Int4, interactions_month -> Int4, report_count -> Int2, unresolved_report_count -> Int2, local_removed -> Bool, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::CommunityFollowerState; use super::sql_types::CommunityNotificationsModeEnum; community_actions (person_id, community_id) { followed_at -> Nullable, blocked_at -> Nullable, became_moderator_at -> Nullable, received_ban_at -> Nullable, ban_expires_at -> Nullable, person_id -> Int4, community_id -> Int4, follow_state -> Nullable, follow_approver_id -> Nullable, notifications -> Nullable, } } diesel::table! { community_community_follow (community_id, target_id) { target_id -> Int4, community_id -> Int4, published_at -> Timestamptz, } } diesel::table! { community_language (community_id, language_id) { community_id -> Int4, language_id -> Int4, } } diesel::table! { community_report (id) { id -> Int4, creator_id -> Int4, community_id -> Int4, original_community_name -> Text, original_community_title -> Text, original_community_summary -> Nullable, original_community_sidebar -> Nullable, original_community_icon -> Nullable, original_community_banner -> Nullable, reason -> Text, resolved -> Bool, resolver_id -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::TagColorEnum; community_tag (id) { id -> Int4, ap_id -> Text, #[max_length = 255] name -> Varchar, #[max_length = 255] display_name -> Nullable, #[max_length = 150] summary -> Nullable, community_id -> Int4, published_at -> Timestamptz, updated_at -> Nullable, deleted -> Bool, color -> TagColorEnum, } } diesel::table! { custom_emoji (id) { id -> Int4, #[max_length = 128] shortcode -> Varchar, image_url -> Text, alt_text -> Text, category -> Text, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { custom_emoji_keyword (custom_emoji_id, keyword) { custom_emoji_id -> Int4, #[max_length = 128] keyword -> Varchar, } } diesel::table! { email_verification (id) { id -> Int4, local_user_id -> Int4, email -> Text, verification_token -> Text, published_at -> Timestamptz, } } diesel::table! { federation_allowlist (instance_id) { instance_id -> Int4, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { federation_blocklist (instance_id) { instance_id -> Int4, published_at -> Timestamptz, updated_at -> Nullable, expires_at -> Nullable, } } diesel::table! { federation_queue_state (instance_id) { instance_id -> Int4, last_successful_id -> Nullable, fail_count -> Int4, last_retry_at -> Nullable, last_successful_published_time_at -> Nullable, } } diesel::table! { image_details (link) { link -> Text, width -> Int4, height -> Int4, content_type -> Text, #[max_length = 50] blurhash -> Nullable, } } diesel::table! { instance (id) { id -> Int4, #[max_length = 255] domain -> Varchar, published_at -> Timestamptz, updated_at -> Nullable, #[max_length = 255] software -> Nullable, #[max_length = 255] version -> Nullable, } } diesel::table! { instance_actions (person_id, instance_id) { blocked_communities_at -> Nullable, person_id -> Int4, instance_id -> Int4, received_ban_at -> Nullable, ban_expires_at -> Nullable, blocked_persons_at -> Nullable, } } diesel::table! { language (id) { id -> Int4, #[max_length = 3] code -> Varchar, name -> Text, } } diesel::table! { local_image (pictrs_alias) { pictrs_alias -> Text, published_at -> Timestamptz, person_id -> Nullable, thumbnail_for_post_id -> Nullable, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::ListingTypeEnum; use super::sql_types::RegistrationModeEnum; use super::sql_types::PostListingModeEnum; use super::sql_types::PostSortTypeEnum; use super::sql_types::CommentSortTypeEnum; use super::sql_types::FederationModeEnum; use super::sql_types::ImageModeEnum; local_site (id) { id -> Int4, site_id -> Int4, site_setup -> Bool, community_creation_admin_only -> Bool, require_email_verification -> Bool, application_question -> Nullable, private_instance -> Bool, default_theme -> Text, default_post_listing_type -> ListingTypeEnum, legal_information -> Nullable, application_email_admins -> Bool, slur_filter_regex -> Nullable, federation_enabled -> Bool, published_at -> Timestamptz, updated_at -> Nullable, registration_mode -> RegistrationModeEnum, reports_email_admins -> Bool, federation_signed_fetch -> Bool, default_post_listing_mode -> PostListingModeEnum, default_post_sort_type -> PostSortTypeEnum, default_comment_sort_type -> CommentSortTypeEnum, oauth_registration -> Bool, post_upvotes -> FederationModeEnum, post_downvotes -> FederationModeEnum, comment_upvotes -> FederationModeEnum, comment_downvotes -> FederationModeEnum, default_post_time_range_seconds -> Nullable, disallow_nsfw_content -> Bool, users -> Int4, posts -> Int4, comments -> Int4, communities -> Int4, users_active_day -> Int4, users_active_week -> Int4, users_active_month -> Int4, users_active_half_year -> Int4, disable_email_notifications -> Bool, suggested_multi_community_id -> Nullable, system_account -> Int4, default_items_per_page -> Int4, image_mode -> ImageModeEnum, image_proxy_bypass_domains -> Nullable, image_upload_timeout_seconds -> Int4, image_max_thumbnail_size -> Int4, image_max_avatar_size -> Int4, image_max_banner_size -> Int4, image_max_upload_size -> Int4, image_allow_video_uploads -> Bool, image_upload_disabled -> Bool, } } diesel::table! { local_site_rate_limit (local_site_id) { local_site_id -> Int4, message_max_requests -> Int4, message_interval_seconds -> Int4, post_max_requests -> Int4, post_interval_seconds -> Int4, register_max_requests -> Int4, register_interval_seconds -> Int4, image_max_requests -> Int4, image_interval_seconds -> Int4, comment_max_requests -> Int4, comment_interval_seconds -> Int4, search_max_requests -> Int4, search_interval_seconds -> Int4, published_at -> Timestamptz, updated_at -> Nullable, import_user_settings_max_requests -> Int4, import_user_settings_interval_seconds -> Int4, } } diesel::table! { local_site_url_blocklist (id) { id -> Int4, url -> Text, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::PostSortTypeEnum; use super::sql_types::ListingTypeEnum; use super::sql_types::PostListingModeEnum; use super::sql_types::CommentSortTypeEnum; use super::sql_types::VoteShowEnum; local_user (id) { id -> Int4, person_id -> Int4, password_encrypted -> Nullable, email -> Nullable, show_nsfw -> Bool, theme -> Text, default_post_sort_type -> PostSortTypeEnum, default_listing_type -> ListingTypeEnum, #[max_length = 20] interface_language -> Varchar, show_avatars -> Bool, send_notifications_to_email -> Bool, show_bot_accounts -> Bool, show_read_posts -> Bool, email_verified -> Bool, accepted_application -> Bool, totp_2fa_secret -> Nullable, open_links_in_new_tab -> Bool, blur_nsfw -> Bool, infinite_scroll_enabled -> Bool, admin -> Bool, post_listing_mode -> PostListingModeEnum, totp_2fa_enabled -> Bool, enable_animated_images -> Bool, collapse_bot_comments -> Bool, last_donation_notification_at -> Timestamptz, enable_private_messages -> Bool, default_comment_sort_type -> CommentSortTypeEnum, auto_mark_fetched_posts_as_read -> Bool, hide_media -> Bool, default_post_time_range_seconds -> Nullable, show_score -> Bool, show_upvotes -> Bool, show_downvotes -> VoteShowEnum, show_upvote_percentage -> Bool, show_person_votes -> Bool, default_items_per_page -> Int4, } } diesel::table! { local_user_keyword_block (local_user_id, keyword) { local_user_id -> Int4, #[max_length = 50] keyword -> Varchar, } } diesel::table! { local_user_language (local_user_id, language_id) { local_user_id -> Int4, language_id -> Int4, } } diesel::table! { login_token (token) { token -> Text, user_id -> Int4, published_at -> Timestamptz, ip -> Nullable, user_agent -> Nullable, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::ModlogKind; modlog (id) { id -> Int4, kind -> ModlogKind, is_revert -> Bool, mod_id -> Int4, reason -> Nullable, target_person_id -> Nullable, target_community_id -> Nullable, target_post_id -> Nullable, target_comment_id -> Nullable, target_instance_id -> Nullable, expires_at -> Nullable, published_at -> Timestamptz, bulk_action_parent_id -> Nullable, } } diesel::table! { multi_community (id) { id -> Int4, creator_id -> Int4, instance_id -> Int4, #[max_length = 255] name -> Varchar, #[max_length = 255] title -> Nullable, #[max_length = 255] summary -> Nullable, local -> Bool, deleted -> Bool, ap_id -> Text, public_key -> Text, private_key -> Nullable, inbox_url -> Text, last_refreshed_at -> Timestamptz, following_url -> Text, published_at -> Timestamptz, updated_at -> Nullable, subscribers -> Int4, subscribers_local -> Int4, communities -> Int4, sidebar -> Nullable, } } diesel::table! { multi_community_entry (multi_community_id, community_id) { multi_community_id -> Int4, community_id -> Int4, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::CommunityFollowerState; multi_community_follow (person_id, multi_community_id) { multi_community_id -> Int4, person_id -> Int4, follow_state -> CommunityFollowerState, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::NotificationTypeEnum; notification (id) { id -> Int4, recipient_id -> Int4, comment_id -> Nullable, read -> Bool, published_at -> Timestamptz, kind -> NotificationTypeEnum, post_id -> Nullable, private_message_id -> Nullable, modlog_id -> Nullable, creator_id -> Int4, } } diesel::table! { oauth_account (oauth_provider_id, local_user_id) { local_user_id -> Int4, oauth_provider_id -> Int4, oauth_user_id -> Text, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { oauth_provider (id) { id -> Int4, display_name -> Text, issuer -> Text, authorization_endpoint -> Text, token_endpoint -> Text, userinfo_endpoint -> Text, id_claim -> Text, client_id -> Text, client_secret -> Text, scopes -> Text, auto_verify_email -> Bool, account_linking_enabled -> Bool, enabled -> Bool, published_at -> Timestamptz, updated_at -> Nullable, use_pkce -> Bool, } } diesel::table! { password_reset_request (id) { id -> Int4, token -> Text, published_at -> Timestamptz, local_user_id -> Int4, } } diesel::table! { person (id) { id -> Int4, #[max_length = 255] name -> Varchar, #[max_length = 50] display_name -> Nullable, avatar -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, #[max_length = 255] ap_id -> Varchar, bio -> Nullable, local -> Bool, private_key -> Nullable, public_key -> Text, last_refreshed_at -> Timestamptz, banner -> Nullable, deleted -> Bool, #[max_length = 255] inbox_url -> Varchar, matrix_user_id -> Nullable, bot_account -> Bool, instance_id -> Int4, post_count -> Int4, post_score -> Int4, comment_count -> Int4, comment_score -> Int4, } } diesel::table! { person_actions (person_id, target_id) { followed_at -> Nullable, blocked_at -> Nullable, person_id -> Int4, target_id -> Int4, follow_pending -> Nullable, noted_at -> Nullable, note -> Nullable, voted_at -> Nullable, upvotes -> Nullable, downvotes -> Nullable, } } diesel::table! { person_content_combined (id) { published_at -> Timestamptz, creator_id -> Int4, post_id -> Nullable, comment_id -> Nullable, id -> Int4, } } diesel::table! { person_liked_combined (id) { voted_at -> Timestamptz, id -> Int4, person_id -> Int4, creator_id -> Int4, post_id -> Nullable, comment_id -> Nullable, vote_is_upvote -> Bool, } } diesel::table! { person_saved_combined (id) { saved_at -> Timestamptz, person_id -> Int4, creator_id -> Int4, post_id -> Nullable, comment_id -> Nullable, id -> Int4, } } diesel::table! { post (id) { id -> Int4, #[max_length = 200] name -> Varchar, #[max_length = 2000] url -> Nullable, body -> Nullable, creator_id -> Int4, community_id -> Int4, removed -> Bool, locked -> Bool, published_at -> Timestamptz, updated_at -> Nullable, deleted -> Bool, nsfw -> Bool, embed_title -> Nullable, embed_description -> Nullable, thumbnail_url -> Nullable, #[max_length = 255] ap_id -> Varchar, local -> Bool, embed_video_url -> Nullable, language_id -> Int4, featured_community -> Bool, featured_local -> Bool, url_content_type -> Nullable, alt_text -> Nullable, scheduled_publish_time_at -> Nullable, newest_comment_time_necro_at -> Nullable, newest_comment_time_at -> Nullable, comments -> Int4, score -> Int4, upvotes -> Int4, downvotes -> Int4, hot_rank -> Float4, hot_rank_active -> Float4, controversy_rank -> Float4, scaled_rank -> Float4, report_count -> Int2, unresolved_report_count -> Int2, federation_pending -> Bool, embed_video_width -> Nullable, embed_video_height -> Nullable, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::PostNotificationsModeEnum; post_actions (person_id, post_id) { read_at -> Nullable, read_comments_at -> Nullable, saved_at -> Nullable, voted_at -> Nullable, hidden_at -> Nullable, person_id -> Int4, post_id -> Int4, read_comments_amount -> Nullable, vote_is_upvote -> Nullable, notifications -> Nullable, } } diesel::table! { post_community_tag (post_id, community_tag_id) { post_id -> Int4, community_tag_id -> Int4, published_at -> Timestamptz, } } diesel::table! { post_report (id) { id -> Int4, creator_id -> Int4, post_id -> Int4, #[max_length = 200] original_post_name -> Varchar, original_post_url -> Nullable, original_post_body -> Nullable, reason -> Text, resolved -> Bool, resolver_id -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, violates_instance_rules -> Bool, } } diesel::table! { private_message (id) { id -> Int4, creator_id -> Int4, recipient_id -> Int4, content -> Text, deleted -> Bool, published_at -> Timestamptz, updated_at -> Nullable, #[max_length = 255] ap_id -> Varchar, local -> Bool, removed -> Bool, deleted_by_recipient -> Bool, } } diesel::table! { private_message_report (id) { id -> Int4, creator_id -> Int4, private_message_id -> Int4, original_pm_text -> Text, reason -> Text, resolved -> Bool, resolver_id -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { received_activity (ap_id) { ap_id -> Text, published_at -> Timestamptz, } } diesel::table! { registration_application (id) { id -> Int4, local_user_id -> Int4, answer -> Text, admin_id -> Nullable, deny_reason -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::table! { remote_image (link) { link -> Text, published_at -> Timestamptz, } } diesel::table! { report_combined (id) { id -> Int4, published_at -> Timestamptz, post_report_id -> Nullable, comment_report_id -> Nullable, private_message_report_id -> Nullable, community_report_id -> Nullable, resolved -> Bool, } } diesel::table! { search_combined (id) { published_at -> Timestamptz, score -> Int4, post_id -> Nullable, comment_id -> Nullable, community_id -> Nullable, person_id -> Nullable, id -> Int4, multi_community_id -> Nullable, } } diesel::table! { secret (id) { id -> Int4, jwt_secret -> Varchar, } } diesel::table! { use diesel::sql_types::*; use super::sql_types::ActorTypeEnum; sent_activity (id) { id -> Int8, ap_id -> Text, data -> Json, sensitive -> Bool, published_at -> Timestamptz, send_inboxes -> Array>, send_community_followers_of -> Nullable, send_all_instances -> Bool, actor_type -> ActorTypeEnum, actor_apub_id -> Nullable, } } diesel::table! { site (id) { id -> Int4, #[max_length = 20] name -> Varchar, sidebar -> Nullable, published_at -> Timestamptz, updated_at -> Nullable, icon -> Nullable, banner -> Nullable, #[max_length = 150] summary -> Nullable, #[max_length = 255] ap_id -> Varchar, last_refreshed_at -> Timestamptz, #[max_length = 255] inbox_url -> Varchar, private_key -> Nullable, public_key -> Text, instance_id -> Int4, content_warning -> Nullable, } } diesel::table! { site_language (site_id, language_id) { site_id -> Int4, language_id -> Int4, } } diesel::table! { tagline (id) { id -> Int4, content -> Text, published_at -> Timestamptz, updated_at -> Nullable, } } diesel::joinable!(comment -> language (language_id)); diesel::joinable!(comment -> person (creator_id)); diesel::joinable!(comment -> post (post_id)); diesel::joinable!(comment_actions -> comment (comment_id)); diesel::joinable!(comment_actions -> person (person_id)); diesel::joinable!(comment_report -> comment (comment_id)); diesel::joinable!(community -> instance (instance_id)); diesel::joinable!(community_actions -> community (community_id)); diesel::joinable!(community_language -> community (community_id)); diesel::joinable!(community_language -> language (language_id)); diesel::joinable!(community_report -> community (community_id)); diesel::joinable!(community_tag -> community (community_id)); diesel::joinable!(custom_emoji_keyword -> custom_emoji (custom_emoji_id)); diesel::joinable!(email_verification -> local_user (local_user_id)); diesel::joinable!(federation_allowlist -> instance (instance_id)); diesel::joinable!(federation_blocklist -> instance (instance_id)); diesel::joinable!(federation_queue_state -> instance (instance_id)); diesel::joinable!(instance_actions -> instance (instance_id)); diesel::joinable!(instance_actions -> person (person_id)); diesel::joinable!(local_image -> person (person_id)); diesel::joinable!(local_image -> post (thumbnail_for_post_id)); diesel::joinable!(local_site -> multi_community (suggested_multi_community_id)); diesel::joinable!(local_site -> person (system_account)); diesel::joinable!(local_site -> site (site_id)); diesel::joinable!(local_site_rate_limit -> local_site (local_site_id)); diesel::joinable!(local_user -> person (person_id)); diesel::joinable!(local_user_keyword_block -> local_user (local_user_id)); diesel::joinable!(local_user_language -> language (language_id)); diesel::joinable!(local_user_language -> local_user (local_user_id)); diesel::joinable!(login_token -> local_user (user_id)); diesel::joinable!(modlog -> comment (target_comment_id)); diesel::joinable!(modlog -> community (target_community_id)); diesel::joinable!(modlog -> instance (target_instance_id)); diesel::joinable!(modlog -> post (target_post_id)); diesel::joinable!(multi_community -> instance (instance_id)); diesel::joinable!(multi_community -> person (creator_id)); diesel::joinable!(multi_community_entry -> community (community_id)); diesel::joinable!(multi_community_entry -> multi_community (multi_community_id)); diesel::joinable!(multi_community_follow -> multi_community (multi_community_id)); diesel::joinable!(multi_community_follow -> person (person_id)); diesel::joinable!(notification -> comment (comment_id)); diesel::joinable!(notification -> modlog (modlog_id)); diesel::joinable!(notification -> post (post_id)); diesel::joinable!(notification -> private_message (private_message_id)); diesel::joinable!(oauth_account -> local_user (local_user_id)); diesel::joinable!(oauth_account -> oauth_provider (oauth_provider_id)); diesel::joinable!(password_reset_request -> local_user (local_user_id)); diesel::joinable!(person -> instance (instance_id)); diesel::joinable!(person_content_combined -> comment (comment_id)); diesel::joinable!(person_content_combined -> person (creator_id)); diesel::joinable!(person_content_combined -> post (post_id)); diesel::joinable!(person_liked_combined -> comment (comment_id)); diesel::joinable!(person_liked_combined -> post (post_id)); diesel::joinable!(person_saved_combined -> comment (comment_id)); diesel::joinable!(person_saved_combined -> post (post_id)); diesel::joinable!(post -> community (community_id)); diesel::joinable!(post -> language (language_id)); diesel::joinable!(post -> person (creator_id)); diesel::joinable!(post_actions -> person (person_id)); diesel::joinable!(post_actions -> post (post_id)); diesel::joinable!(post_community_tag -> community_tag (community_tag_id)); diesel::joinable!(post_community_tag -> post (post_id)); diesel::joinable!(post_report -> post (post_id)); diesel::joinable!(private_message_report -> private_message (private_message_id)); diesel::joinable!(registration_application -> local_user (local_user_id)); diesel::joinable!(registration_application -> person (admin_id)); diesel::joinable!(report_combined -> comment_report (comment_report_id)); diesel::joinable!(report_combined -> community_report (community_report_id)); diesel::joinable!(report_combined -> post_report (post_report_id)); diesel::joinable!(report_combined -> private_message_report (private_message_report_id)); diesel::joinable!(search_combined -> comment (comment_id)); diesel::joinable!(search_combined -> community (community_id)); diesel::joinable!(search_combined -> multi_community (multi_community_id)); diesel::joinable!(search_combined -> person (person_id)); diesel::joinable!(search_combined -> post (post_id)); diesel::joinable!(site -> instance (instance_id)); diesel::joinable!(site_language -> language (language_id)); diesel::joinable!(site_language -> site (site_id)); diesel::allow_tables_to_appear_in_same_query!( comment, comment_actions, comment_report, community, community_actions, community_language, community_report, community_tag, email_verification, federation_allowlist, federation_blocklist, federation_queue_state, instance, instance_actions, language, local_image, local_site, local_site_rate_limit, local_user, local_user_keyword_block, local_user_language, login_token, modlog, multi_community, multi_community_entry, multi_community_follow, notification, oauth_account, oauth_provider, password_reset_request, person, person_content_combined, person_liked_combined, person_saved_combined, post, post_actions, post_community_tag, post_report, private_message, private_message_report, registration_application, report_combined, search_combined, site, site_language, person_actions, image_details, ); diesel::allow_tables_to_appear_in_same_query!(custom_emoji, custom_emoji_keyword,); ================================================ FILE: crates/db_schema_file/src/table_impls.rs ================================================ use crate::schema::{ comment_actions, community_actions, instance_actions, person_actions, post_actions, }; impl diesel_uplete::SupportedTable for comment_actions::table { type Key = (comment_actions::person_id, comment_actions::comment_id); type AdditionalIgnoredColumns = (); } impl diesel_uplete::SupportedTable for community_actions::table { type Key = ( community_actions::person_id, community_actions::community_id, ); type AdditionalIgnoredColumns = (); } impl diesel_uplete::SupportedTable for instance_actions::table { type Key = (instance_actions::person_id, instance_actions::instance_id); type AdditionalIgnoredColumns = (); } impl diesel_uplete::SupportedTable for person_actions::table { type Key = (person_actions::person_id, person_actions::target_id); type AdditionalIgnoredColumns = (); } impl diesel_uplete::SupportedTable for post_actions::table { type Key = (post_actions::person_id, post_actions::post_id); type AdditionalIgnoredColumns = (); } ================================================ FILE: crates/db_views/comment/Cargo.toml ================================================ [package] name = "lemmy_db_views_comment" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "diesel_ltree", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } diesel_ltree = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } chrono = { workspace = true } [dev-dependencies] lemmy_db_views_local_user = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_views/comment/src/api.rs ================================================ use crate::CommentView; use lemmy_db_schema::newtypes::{CommentId, CommunityId, LanguageId, PostId}; use lemmy_db_schema_file::enums::{CommentSortType, ListingType}; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A comment response. pub struct CommentResponse { pub comment_view: CommentView, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a comment. pub struct CreateComment { pub content: String, pub post_id: PostId, pub parent_id: Option, pub language_id: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Like a comment. pub struct CreateCommentLike { pub comment_id: CommentId, /// True means Upvote, False means Downvote, and None means remove vote. pub is_upvote: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete your own comment. pub struct DeleteComment { pub comment_id: CommentId, pub deleted: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Distinguish a comment (IE speak as moderator). pub struct DistinguishComment { pub comment_id: CommentId, pub distinguished: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetch an individual comment. pub struct GetComment { pub id: CommentId, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Get a list of comments. pub struct GetComments { pub type_: Option, pub sort: Option, /// Filter to within a given time range, in seconds. /// IE 60 would give results for the past minute. pub time_range_seconds: Option, pub max_depth: Option, pub page_cursor: Option, pub limit: Option, pub community_id: Option, pub community_name: Option, pub post_id: Option, pub parent_id: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// List comment likes. Admins-only. pub struct ListCommentLikes { pub comment_id: CommentId, pub page_cursor: Option, pub limit: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Locks a comment and its children, IE prevents new replies. pub struct LockComment { pub comment_id: CommentId, pub locked: bool, pub reason: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Purges a comment from the database. This will delete all content attached to that comment. pub struct PurgeComment { pub comment_id: CommentId, pub reason: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Remove a comment (only doable by mods). pub struct RemoveComment { pub comment_id: CommentId, pub removed: bool, pub reason: String, /// Setting this will override whatever `removed` was set to, /// leave as null or unset to act just on the comment itself. pub remove_children: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Save / bookmark a comment. pub struct SaveComment { pub comment_id: CommentId, pub save: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edit a comment. pub struct EditComment { pub comment_id: CommentId, pub content: Option, pub language_id: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Creates a warning against a comment and notifies the user. pub struct CreateCommentWarning { pub comment_id: CommentId, pub reason: String, } ================================================ FILE: crates/db_views/comment/src/impls.rs ================================================ use crate::{CommentSlimView, CommentView}; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, SelectableHelper, dsl::exists, }; use diesel_async::RunQueryDsl; use diesel_ltree::{Ltree, LtreeExtensions, nlevel}; use i_love_jesus::asc_if; use lemmy_db_schema::{ impls::local_user::LocalUserOptionHelper, newtypes::{CommentId, CommunityId, PostId}, source::{ comment::{Comment, comment_keys as key}, local_user::LocalUser, site::Site, }, utils::{ limit_fetch, queries::filters::{filter_blocked, filter_suggested_communities}, }, }; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::{ CommentSortType::{self, *}, CommunityFollowerState, CommunityVisibility, ListingType, }, joins::{ creator_community_actions_join, creator_community_instance_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, my_comment_actions_join, my_community_actions_join, my_instance_communities_actions_join, my_instance_persons_actions_join_1, my_local_user_admin_join, my_person_actions_join, }, schema::{comment, community, community_actions, local_user_language, person, post}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, traits::Crud, utils::{Subpath, now, seconds_to_pg_interval}, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl PaginationCursorConversion for CommentView { type PaginatedType = Comment; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.comment.id.0) } async fn from_cursor( data: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Comment::read(pool, CommentId(data.id()?)).await } } impl CommentView { #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: Option, local_instance_id: InstanceId) -> _ { let community_join = community::table.on(post::community_id.eq(community::id)); let my_community_actions_join: my_community_actions_join = my_community_actions_join(my_person_id); let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(my_person_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id); let my_instance_communities_actions_join: my_instance_communities_actions_join = my_instance_communities_actions_join(my_person_id); let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 = my_instance_persons_actions_join_1(my_person_id); let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); comment::table .inner_join(person::table) .inner_join(post::table) .inner_join(community_join) .left_join(creator_home_instance_actions_join()) .left_join(creator_community_instance_actions_join()) .left_join(creator_community_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(my_community_actions_join) .left_join(my_comment_actions_join) .left_join(my_person_actions_join) .left_join(my_local_user_admin_join) .left_join(my_instance_communities_actions_join) .left_join(my_instance_persons_actions_join_1) } pub async fn read( pool: &mut DbPool<'_>, comment_id: CommentId, my_local_user: Option<&'_ LocalUser>, local_instance_id: InstanceId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut query = Self::joins(my_local_user.person_id(), local_instance_id) .filter(comment::id.eq(comment_id)) .select(Self::as_select()) .into_boxed(); query = my_local_user.visible_communities_only(query); // Check permissions to view private community content. // Specifically, if the community is private then only accepted followers may view its // content, otherwise it is filtered out. Admins can view private community content // without restriction. if !my_local_user.is_admin() { query = query.filter( community::visibility .ne(CommunityVisibility::Private) .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), ); } query .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub fn map_to_slim(self) -> CommentSlimView { CommentSlimView { comment: self.comment, creator: self.creator, comment_actions: self.comment_actions, person_actions: self.person_actions, creator_is_admin: self.creator_is_admin, can_mod: self.can_mod, creator_banned: self.creator_banned, creator_banned_from_community: self.creator_banned_from_community, creator_is_moderator: self.creator_is_moderator, } } } #[derive(Default)] pub struct CommentQuery<'a> { pub listing_type: Option, pub sort: Option, pub time_range_seconds: Option, pub community_id: Option, pub post_id: Option, pub parent_path: Option, pub local_user: Option<&'a LocalUser>, pub max_depth: Option, pub page_cursor: Option, pub limit: Option, } impl CommentQuery<'_> { pub async fn list( self, site: &Site, pool: &mut DbPool<'_>, ) -> LemmyResult> { let o = self; // The left join below will return None in this case let my_person_id = o.local_user.person_id(); let local_user_id = o.local_user.local_user_id(); let mut query = CommentView::joins(my_person_id, site.instance_id) .select(CommentView::as_select()) .into_boxed(); if let Some(post_id) = o.post_id { query = query.filter(comment::post_id.eq(post_id)); }; if let Some(parent_path) = o.parent_path.as_ref() { query = query.filter(comment::path.contained_by(parent_path)); }; if let Some(community_id) = o.community_id { query = query.filter(post::community_id.eq(community_id)); } let is_subscribed = community_actions::followed_at.is_not_null(); // For posts, we only show hidden if its subscribed, but for comments, // we ignore hidden. query = match o.listing_type.unwrap_or_default() { ListingType::Subscribed => query.filter(is_subscribed), ListingType::Local => query.filter(community::local.eq(true)), ListingType::All => query, ListingType::ModeratorView => { query.filter(community_actions::became_moderator_at.is_not_null()) } ListingType::Suggested => query.filter(filter_suggested_communities()), }; if !o.local_user.show_bot_accounts() { query = query.filter(person::bot_account.eq(false)); }; if o.local_user.is_some() && o.listing_type.unwrap_or_default() != ListingType::ModeratorView { // Filter out the rows with missing languages query = query.filter(exists( local_user_language::table.filter( comment::language_id .eq(local_user_language::language_id) .and( local_user_language::local_user_id .nullable() .eq(local_user_id), ), ), )); query = query.filter(filter_blocked()); }; if !o.local_user.show_nsfw(site) { query = query .filter(post::nsfw.eq(false)) .filter(community::nsfw.eq(false)); }; query = o.local_user.visible_communities_only(query); query = query.filter( comment::federation_pending .eq(false) .or(comment::creator_id.nullable().eq(my_person_id)), ); if !o.local_user.is_admin() { query = query.filter( community::visibility .ne(CommunityVisibility::Private) .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), ); } // Filter by the time range if let Some(time_range_seconds) = o.time_range_seconds { query = query.filter(comment::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds))); } // A Max depth given means its a tree fetch let limit = if let Some(max_depth) = o.max_depth { let depth_limit = if let Some(parent_path) = o.parent_path.as_ref() { let count: i32 = parent_path.0.split('.').count().try_into()?; count + max_depth // Add one because of root "0" } else { max_depth + 1 }; query = query.filter(nlevel(comment::path).le(depth_limit)); // TODO limit question. Limiting does not work for comment threads ATM, only max_depth // For now, don't do any limiting for tree fetches // https://stackoverflow.com/questions/72983614/postgres-ltree-how-to-limit-the-max-number-of-children-at-any-given-level // Don't use the regular error-checking one, many more comments must ofter be fetched. // This does not work for comment trees, and the limit should be manually set to a high number // // If a max depth is given, then you know its a tree fetch, and limits should be ignored // TODO a kludge to prevent attacks. Limit comments to 300 for now. // (i64::MAX, 0) 300 } else { limit_fetch(o.limit, None)? }; query = query.limit(limit); // Only sort by ascending for Old let sort = o.sort.unwrap_or(Hot); let sort_direction = asc_if(sort == Old); let mut pq = CommentView::paginate(query, &o.page_cursor, sort_direction, pool, None).await?; // Order by a subpath for max depth queries // Only order if filtering by a post id, or parent_path. DOS potential otherwise and max_depth // + !post_id isn't used anyways (afaik) if o.max_depth.is_some() && (o.post_id.is_some() || o.parent_path.is_some()) { // Always order by the parent path first pq = pq.then_order_by(Subpath(key::path)); } // Distinguished comments should go first when viewing post // Don't do for new / old sorts if sort != New && sort != Old && (o.post_id.is_some() || o.parent_path.is_some()) { pq = pq.then_order_by(key::distinguished); } pq = match sort { Hot => pq.then_order_by(key::hot_rank).then_order_by(key::score), Controversial => pq.then_order_by(key::controversy_rank), Old | New => pq.then_order_by(key::published_at), Top => pq.then_order_by(key::score), }; let conn = &mut get_conn(pool).await?; let res = pq.load::(conn).await?; paginate_response(res, limit, o.page_cursor) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use crate::{CommentView, impls::CommentQuery}; use lemmy_db_schema::{ assert_length, impls::actor_language::UNDETERMINED_ID, newtypes::CommentId, source::{ actor_language::LocalUserLanguage, comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentUpdateForm}, community::{ Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, CommunityPersonBanForm, CommunityUpdateForm, }, instance::Instance, language::Language, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, person::{Person, PersonActions, PersonBlockForm, PersonInsertForm}, post::{Post, PostInsertForm, PostUpdateForm}, site::{Site, SiteInsertForm}, }, traits::{Bannable, Blockable, Followable, Likeable}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; // TODO rename these struct Data { instance: Instance, comment_0: Comment, comment_1: Comment, comment_2: Comment, _comment_5: Comment, post: Post, timmy_local_user_view: LocalUserView, sara_person: Person, community: Community, site: Site, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { Instance::read_all(pool).await?; let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_person_form = PersonInsertForm::test_form(inserted_instance.id, "timmy"); let inserted_timmy_person = Person::create(pool, &timmy_person_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form_admin(inserted_timmy_person.id); let inserted_timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let sara_person_form = PersonInsertForm::test_form(inserted_instance.id, "sara"); let sara_person = Person::create(pool, &sara_person_form).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "test community 5".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post 2".into(), inserted_timmy_person.id, community.id, ); let post = Post::create(pool, &new_post).await?; let english_id = Language::read_id_from_code(pool, "en").await?; // Create a comment tree with this hierarchy // 0 // \ \ // 1 2 // \ // 3 4 // \ // 5 let comment_form_0 = CommentInsertForm { language_id: Some(english_id), ..CommentInsertForm::new(inserted_timmy_person.id, post.id, "Comment 0".into()) }; let comment_0 = Comment::create(pool, &comment_form_0, None).await?; let comment_form_1 = CommentInsertForm { language_id: Some(english_id), ..CommentInsertForm::new(sara_person.id, post.id, "Comment 1".into()) }; let comment_1 = Comment::create(pool, &comment_form_1, Some(&comment_0.path)).await?; let finnish_id = Language::read_id_from_code(pool, "fi").await?; let comment_form_2 = CommentInsertForm { language_id: Some(finnish_id), ..CommentInsertForm::new(inserted_timmy_person.id, post.id, "Comment 2".into()) }; let comment_2 = Comment::create(pool, &comment_form_2, Some(&comment_0.path)).await?; let comment_form_3 = CommentInsertForm { language_id: Some(english_id), ..CommentInsertForm::new(inserted_timmy_person.id, post.id, "Comment 3".into()) }; let _inserted_comment_3 = Comment::create(pool, &comment_form_3, Some(&comment_1.path)).await?; let polish_id = Language::read_id_from_code(pool, "pl").await?; let comment_form_4 = CommentInsertForm { language_id: Some(polish_id), ..CommentInsertForm::new(inserted_timmy_person.id, post.id, "Comment 4".into()) }; let inserted_comment_4 = Comment::create(pool, &comment_form_4, Some(&comment_1.path)).await?; let comment_form_5 = CommentInsertForm::new(inserted_timmy_person.id, post.id, "Comment 5".into()); let _comment_5 = Comment::create(pool, &comment_form_5, Some(&inserted_comment_4.path)).await?; let timmy_blocks_sara_form = PersonBlockForm::new(inserted_timmy_person.id, sara_person.id); let inserted_block = PersonActions::block(pool, &timmy_blocks_sara_form).await?; assert_eq!( (inserted_timmy_person.id, sara_person.id, true), ( inserted_block.person_id, inserted_block.target_id, inserted_block.blocked_at.is_some() ) ); let comment_like_form = CommentLikeForm::new(comment_0.id, inserted_timmy_person.id, Some(true)); CommentActions::like(pool, &comment_like_form).await?; let timmy_local_user_view = LocalUserView { local_user: inserted_timmy_local_user.clone(), person: inserted_timmy_person.clone(), banned: false, ban_expires_at: None, }; let site_form = SiteInsertForm::new("test site".to_string(), inserted_instance.id); let site = Site::create(pool, &site_form).await?; Ok(Data { instance: inserted_instance, comment_0, comment_1, comment_2, _comment_5, post, timmy_local_user_view, sara_person, community, site, }) } #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let read_comment_views_no_person = CommentQuery { sort: (Some(CommentSortType::Old)), post_id: (Some(data.post.id)), ..Default::default() } .list(&data.site, pool) .await?; assert!(read_comment_views_no_person[0].comment_actions.is_none()); assert!(!read_comment_views_no_person[0].can_mod); let read_comment_views_with_person = CommentQuery { sort: (Some(CommentSortType::Old)), post_id: (Some(data.post.id)), local_user: (Some(&data.timmy_local_user_view.local_user)), ..Default::default() } .list(&data.site, pool) .await?; assert!( read_comment_views_with_person[0] .comment_actions .as_ref() .is_some_and(|x| x.vote_is_upvote == Some(true)) ); assert!(read_comment_views_with_person[0].can_mod); // Make sure its 1, not showing the blocked comment assert_length!(5, read_comment_views_with_person); let read_comment_from_blocked_person = CommentView::read( pool, data.comment_1.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await?; // Make sure block set the creator blocked assert!( read_comment_from_blocked_person .person_actions .is_some_and(|x| x.blocked_at.is_some()) ); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_comment_tree() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let top_path = data.comment_0.path.clone(); let read_comment_views_top_path = CommentQuery { post_id: (Some(data.post.id)), parent_path: (Some(top_path)), ..Default::default() } .list(&data.site, pool) .await?; let child_path = data.comment_1.path.clone(); let read_comment_views_child_path = CommentQuery { post_id: (Some(data.post.id)), parent_path: (Some(child_path)), ..Default::default() } .list(&data.site, pool) .await?; // Make sure the comment parent-limited fetch is correct assert_length!(6, read_comment_views_top_path); assert_length!(4, read_comment_views_child_path); // Make sure it contains the parent, but not the comment from the other tree let child_comments = read_comment_views_child_path .iter() .map(|c| c.comment.id) .collect::>(); assert!(child_comments.contains(&data.comment_1.id)); assert!(!child_comments.contains(&data.comment_2.id)); let read_comment_views_top_max_depth = CommentQuery { post_id: (Some(data.post.id)), max_depth: (Some(1)), ..Default::default() } .list(&data.site, pool) .await?; // Make sure a depth limited one only has the top comment assert_length!(1, read_comment_views_top_max_depth); let child_path = data.comment_1.path.clone(); let read_comment_views_parent_max_depth = CommentQuery { post_id: (Some(data.post.id)), parent_path: (Some(child_path)), max_depth: (Some(1)), sort: (Some(CommentSortType::Old)), ..Default::default() } .list(&data.site, pool) .await?; // Make sure a depth limited one, and given child comment 1, has 3 // 1, 3, 4 assert_eq!( vec!["Comment 1", "Comment 3", "Comment 4"], read_comment_views_parent_max_depth .iter() .map(|r| r.comment.content.as_str()) .collect::>() ); assert!( read_comment_views_parent_max_depth[1] .comment .content .eq("Comment 3") ); assert_length!(3, read_comment_views_parent_max_depth); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_languages() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // by default, user has all languages enabled and should see all comments // (except from blocked user) let all_languages = CommentQuery { local_user: (Some(&data.timmy_local_user_view.local_user)), ..Default::default() } .list(&data.site, pool) .await?; assert_length!(5, all_languages); // change user lang to finnish, should only show one post in finnish and one undetermined let finnish_id = Language::read_id_from_code(pool, "fi").await?; LocalUserLanguage::update( pool, vec![finnish_id], data.timmy_local_user_view.local_user.id, ) .await?; let finnish_comments = CommentQuery { local_user: (Some(&data.timmy_local_user_view.local_user)), ..Default::default() } .list(&data.site, pool) .await?; assert_length!(1, finnish_comments); let finnish_comment = finnish_comments .iter() .find(|c| c.comment.language_id == finnish_id); assert!(finnish_comment.is_some()); assert_eq!( Some(&data.comment_2.content), finnish_comment.map(|c| &c.comment.content) ); // now show all comments with undetermined language (which is the default value) LocalUserLanguage::update( pool, vec![UNDETERMINED_ID], data.timmy_local_user_view.local_user.id, ) .await?; let undetermined_comment = CommentQuery { local_user: (Some(&data.timmy_local_user_view.local_user)), ..Default::default() } .list(&data.site, pool) .await?; assert_length!(1, undetermined_comment); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_distinguished_first() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let form = CommentUpdateForm { distinguished: Some(true), ..Default::default() }; Comment::update(pool, data.comment_2.id, &form).await?; let comments = CommentQuery { post_id: Some(data.comment_2.post_id), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(comments[0].comment.id, data.comment_2.id); assert!(comments[0].comment.distinguished); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_creator_is_moderator() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Make one of the inserted persons a moderator let person_id = data.sara_person.id; let community_id = data.community.id; let form = CommunityModeratorForm::new(community_id, person_id); CommunityActions::join(pool, &form).await?; // Make sure that they come back as a mod in the list let comments = CommentQuery { sort: (Some(CommentSortType::Old)), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(comments[1].creator.name, "sara"); assert!(comments[1].creator_is_moderator); assert!(!comments[0].creator_is_moderator); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_creator_is_admin() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let comments = CommentQuery { sort: (Some(CommentSortType::Old)), ..Default::default() } .list(&data.site, pool) .await?; // Timmy is an admin, and make sure that field is true assert_eq!(comments[0].creator.name, "timmy"); assert!(comments[0].creator_is_admin); // Sara isn't, make sure its false assert_eq!(comments[1].creator.name, "sara"); assert!(!comments[1].creator_is_admin); cleanup(data, pool).await } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Community::delete(pool, data.community.id).await?; Person::delete(pool, data.timmy_local_user_view.person.id).await?; LocalUser::delete(pool, data.timmy_local_user_view.local_user.id).await?; Person::delete(pool, data.sara_person.id).await?; Instance::delete(pool, data.instance.id).await?; Site::delete(pool, data.site.id).await?; Ok(()) } #[tokio::test] #[serial] async fn local_only_instance() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; Community::update( pool, data.community.id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::LocalOnlyPrivate), ..Default::default() }, ) .await?; let unauthenticated_query = CommentQuery { ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(0, unauthenticated_query.len()); let authenticated_query = CommentQuery { local_user: Some(&data.timmy_local_user_view.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(5, authenticated_query.len()); let unauthenticated_comment = CommentView::read(pool, data.comment_0.id, None, data.instance.id).await; assert!(unauthenticated_comment.is_err()); let authenticated_comment = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await; assert!(authenticated_comment.is_ok()); cleanup(data, pool).await } #[tokio::test] #[serial] async fn comment_listing_local_user_banned_from_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Test that comment view shows if local user is blocked from community let banned_from_comm_person = PersonInsertForm::test_form(data.instance.id, "jill"); let inserted_banned_from_comm_person = Person::create(pool, &banned_from_comm_person).await?; let inserted_banned_from_comm_local_user = LocalUser::create( pool, &LocalUserInsertForm::test_form(inserted_banned_from_comm_person.id), vec![], ) .await?; CommunityActions::ban( pool, &CommunityPersonBanForm::new(data.community.id, inserted_banned_from_comm_person.id), ) .await?; let comment_view = CommentView::read( pool, data.comment_0.id, Some(&inserted_banned_from_comm_local_user), data.instance.id, ) .await?; assert!( comment_view .community_actions .is_some_and(|x| x.received_ban_at.is_some()) ); Person::delete(pool, inserted_banned_from_comm_person.id).await?; cleanup(data, pool).await } #[tokio::test] #[serial] async fn comment_listing_local_user_not_banned_from_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let comment_view = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await?; assert!(comment_view.community_actions.is_none()); cleanup(data, pool).await } #[tokio::test] #[serial] async fn comment_listings_hide_nsfw() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Mark a post as nsfw let update_form = PostUpdateForm { nsfw: Some(true), ..Default::default() }; Post::update(pool, data.post.id, &update_form).await?; // Make sure comments of this post are not returned let comments = CommentQuery::default().list(&data.site, pool).await?; assert_eq!(0, comments.len()); // Mark site as nsfw let mut site = data.site.clone(); site.content_warning = Some("nsfw".to_string()); // Now comments of nsfw post are returned let comments = CommentQuery::default().list(&site, pool).await?; assert_eq!(6, comments.len()); cleanup(data, pool).await } #[tokio::test] #[serial] async fn comment_listing_private_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let mut data = init_data(pool).await?; // Mark community as private Community::update( pool, data.community.id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::Private), ..Default::default() }, ) .await?; // No comments returned without auth let read_comment_listing = CommentQuery::default().list(&data.site, pool).await?; assert_eq!(0, read_comment_listing.len()); let comment_view = CommentView::read(pool, data.comment_0.id, None, data.instance.id).await; assert!(comment_view.is_err()); // No comments returned for non-follower who is not admin data.timmy_local_user_view.local_user.admin = false; let read_comment_listing = CommentQuery { community_id: Some(data.community.id), local_user: Some(&data.timmy_local_user_view.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(0, read_comment_listing.len()); let comment_view = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await; assert!(comment_view.is_err()); // Admin can view content without following data.timmy_local_user_view.local_user.admin = true; let read_comment_listing = CommentQuery { community_id: Some(data.community.id), local_user: Some(&data.timmy_local_user_view.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(5, read_comment_listing.len()); let comment_view = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await; assert!(comment_view.is_ok()); data.timmy_local_user_view.local_user.admin = false; // User can view after following CommunityActions::follow( pool, &CommunityFollowerForm::new( data.community.id, data.timmy_local_user_view.person.id, CommunityFollowerState::Accepted, ), ) .await?; let read_comment_listing = CommentQuery { community_id: Some(data.community.id), local_user: Some(&data.timmy_local_user_view.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(5, read_comment_listing.len()); let comment_view = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await; assert!(comment_view.is_ok()); cleanup(data, pool).await } #[tokio::test] #[serial] async fn comment_removed() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let mut data = init_data(pool).await?; // Mark a comment as removed let form = CommentUpdateForm { removed: Some(true), ..Default::default() }; Comment::update(pool, data.comment_0.id, &form).await?; // Read as normal user, content is cleared // Timmy leaves admin LocalUser::update( pool, data.timmy_local_user_view.local_user.id, &LocalUserUpdateForm { admin: Some(false), ..Default::default() }, ) .await?; data.timmy_local_user_view.local_user.admin = false; let comment_view = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await?; assert_eq!("", comment_view.comment.content); let comment_listing = CommentQuery { community_id: Some(data.community.id), local_user: Some(&data.timmy_local_user_view.local_user), sort: Some(CommentSortType::Old), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!("", comment_listing[0].comment.content); // Read as admin, content is returned LocalUser::update( pool, data.timmy_local_user_view.local_user.id, &LocalUserUpdateForm { admin: Some(true), ..Default::default() }, ) .await?; data.timmy_local_user_view.local_user.admin = true; let comment_view = CommentView::read( pool, data.comment_0.id, Some(&data.timmy_local_user_view.local_user), data.instance.id, ) .await?; assert_eq!(data.comment_0.content, comment_view.comment.content); let comment_listing = CommentQuery { community_id: Some(data.community.id), local_user: Some(&data.timmy_local_user_view.local_user), sort: Some(CommentSortType::Old), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(data.comment_0.content, comment_listing[0].comment.content); cleanup(data, pool).await } } ================================================ FILE: crates/db_views/comment/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::source::{ comment::{Comment, CommentActions}, community::{Community, CommunityActions}, community_tag::CommunityTagsView, person::{Person, PersonActions}, post::Post, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::utils::queries::selects::{ CreatorLocalHomeCommunityBanExpiresType, comment_creator_is_admin, comment_select_remove_deletes, creator_ban_expires_from_community, creator_banned_from_community, creator_is_moderator, creator_local_home_community_ban_expires, creator_local_home_community_banned, local_user_can_mod_comment, post_community_tags_fragment, }, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A comment view. pub struct CommentView { #[cfg_attr(feature = "full", diesel( select_expression = comment_select_remove_deletes() ) )] pub comment: Comment, #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel(embed))] pub post: Post, #[cfg_attr(feature = "full", diesel(embed))] pub community: Community, #[cfg_attr(feature = "full", diesel(embed))] pub community_actions: Option, #[cfg_attr(feature = "full", diesel(embed))] pub comment_actions: Option, #[cfg_attr(feature = "full", diesel(embed))] pub person_actions: Option, #[cfg_attr(feature = "full", diesel( select_expression = comment_creator_is_admin() ) )] pub creator_is_admin: bool, #[cfg_attr(feature = "full", diesel( select_expression = post_community_tags_fragment() ) )] pub tags: CommunityTagsView, #[cfg_attr(feature = "full", diesel( select_expression = local_user_can_mod_comment() ) )] pub can_mod: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_local_home_community_banned() ) )] pub creator_banned: bool, #[cfg_attr(feature = "full", diesel( select_expression_type = CreatorLocalHomeCommunityBanExpiresType, select_expression = creator_local_home_community_ban_expires() ) )] pub creator_ban_expires_at: Option>, #[cfg_attr(feature = "full", diesel( select_expression = creator_is_moderator() ) )] pub creator_is_moderator: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_banned_from_community() ) )] pub creator_banned_from_community: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_ban_expires_from_community() ) )] pub creator_community_ban_expires_at: Option>, } #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A slimmer comment view, without the post, or community. pub struct CommentSlimView { pub comment: Comment, pub creator: Person, pub comment_actions: Option, pub person_actions: Option, pub creator_is_admin: bool, pub can_mod: bool, pub creator_banned: bool, pub creator_is_moderator: bool, pub creator_banned_from_community: bool, } ================================================ FILE: crates/db_views/community/Cargo.toml ================================================ [package] name = "lemmy_db_views_community" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_db_views_community_moderator/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs", "lemmy_db_views_community_moderator/ts-rs", ] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_db_views_community_moderator = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } url = { workspace = true } ================================================ FILE: crates/db_views/community/src/api.rs ================================================ use crate::{CommunityView, MultiCommunityView}; use lemmy_db_schema::{ CommunitySortType, MultiCommunityListingType, MultiCommunitySortType, newtypes::{CommunityId, CommunityTagId, LanguageId, MultiCommunityId}, source::site::Site, }; use lemmy_db_schema_file::{ PersonId, enums::{CommunityNotificationsMode, CommunityVisibility, ListingType, TagColor}, }; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Add a moderator to a community. pub struct AddModToCommunity { pub community_id: CommunityId, pub person_id: PersonId, pub added: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The response of adding a moderator to a community. pub struct AddModToCommunityResponse { pub moderators: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ApproveCommunityPendingFollower { pub community_id: CommunityId, pub follower_id: PersonId, pub approve: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Ban a user from a community. pub struct BanFromCommunity { pub community_id: CommunityId, pub person_id: PersonId, pub ban: bool, /// Optionally remove or restore all their data. Useful for new troll accounts. /// If ban is true, then this means remove. If ban is false, it means restore. pub remove_or_restore_data: Option, pub reason: String, /// A time that the ban will expire, in unix epoch seconds. /// /// An i64 unix timestamp is used for a simpler API client implementation. pub expires_at: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Block a community. pub struct BlockCommunity { pub community_id: CommunityId, pub block: bool, } /// Parameter for setting community icon or banner. Can't use POST data here as it already contains /// the image data. #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CommunityIdQuery { pub id: CommunityId, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A simple community response. pub struct CommunityResponse { pub community_view: CommunityView, pub discussion_languages: Vec, } #[skip_serializing_none] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] /// Create a community. pub struct CreateCommunity { /// The unique name. pub name: String, /// A longer title. pub title: String, /// A sidebar for the community in markdown. pub sidebar: Option, /// A shorter, one line summary of your community. pub summary: Option, /// An icon URL. pub icon: Option, /// A banner URL. pub banner: Option, /// Whether its an NSFW community. pub nsfw: Option, /// Whether to restrict posting only to moderators. pub posting_restricted_to_mods: Option, pub discussion_languages: Option>, pub visibility: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete your own community. pub struct DeleteCommunity { pub community_id: CommunityId, pub deleted: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edit a community. pub struct EditCommunity { pub community_id: CommunityId, /// A longer title. pub title: Option, /// A sidebar for the community in markdown. pub sidebar: Option, /// A shorter, one line summary of your community. pub summary: Option, /// Whether its an NSFW community. pub nsfw: Option, /// Whether to restrict posting only to moderators. pub posting_restricted_to_mods: Option, pub discussion_languages: Option>, pub visibility: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Follow / subscribe to a community. pub struct FollowCommunity { pub community_id: CommunityId, pub follow: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] // TODO make this into a tagged enum /// Get a community. Must provide either an id, or a name. pub struct GetCommunity { pub id: Option, /// Example: star_trek , or star_trek@xyz.tld pub name: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The community response. pub struct GetCommunityResponse { pub community_view: CommunityView, pub site: Option, pub moderators: Vec, pub discussion_languages: Vec, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetches a random community pub struct GetRandomCommunity { pub type_: Option, pub show_nsfw: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Hide a community from the main view. pub struct HideCommunity { pub community_id: CommunityId, pub hidden: bool, pub reason: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetches a list of communities. pub struct ListCommunities { pub type_: Option, pub sort: Option, /// Filter to within a given time range, in seconds. /// IE 60 would give results for the past minute. pub time_range_seconds: Option, pub show_nsfw: Option, pub page_cursor: Option, pub limit: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Purges a community from the database. This will delete all content attached to that community. pub struct PurgeCommunity { pub community_id: CommunityId, pub reason: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Remove a community (only doable by moderators). pub struct RemoveCommunity { pub community_id: CommunityId, pub removed: bool, pub reason: String, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Transfer a community to a new owner. pub struct TransferCommunity { pub community_id: CommunityId, pub person_id: PersonId, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CreateMultiCommunity { pub name: String, pub title: Option, pub summary: Option, pub sidebar: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct EditMultiCommunity { pub id: MultiCommunityId, pub title: Option, pub summary: Option, pub sidebar: Option, pub deleted: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct CreateOrDeleteMultiCommunityEntry { pub id: MultiCommunityId, pub community_id: CommunityId, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ListMultiCommunities { pub type_: Option, pub sort: Option, pub creator_id: Option, /// Filter to within a given time range, in seconds. /// IE 60 would give results for the past minute. pub time_range_seconds: Option, pub page_cursor: Option, pub limit: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct GetMultiCommunity { pub id: Option, pub name: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct GetMultiCommunityResponse { pub multi_community_view: MultiCommunityView, pub communities: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct MultiCommunityResponse { pub multi_community_view: MultiCommunityView, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct FollowMultiCommunity { pub multi_community_id: MultiCommunityId, pub follow: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Change notification settings for a community pub struct EditCommunityNotifications { pub community_id: CommunityId, pub mode: CommunityNotificationsMode, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a tag for a community. pub struct CreateCommunityTag { pub community_id: CommunityId, pub name: String, pub display_name: Option, pub summary: Option, pub color: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Make changes to a community tag pub struct EditCommunityTag { pub tag_id: CommunityTagId, pub display_name: Option, pub summary: Option, pub color: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete a community tag. pub struct DeleteCommunityTag { pub tag_id: CommunityTagId, pub delete: bool, } ================================================ FILE: crates/db_views/community/src/impls.rs ================================================ use crate::{CommunityView, MultiCommunityView}; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; use i_love_jesus::asc_if; use lemmy_db_schema::{ CommunitySortType, MultiCommunityListingType, MultiCommunitySortType, impls::local_user::LocalUserOptionHelper, newtypes::{CommunityId, MultiCommunityId}, source::{ community::{Community, community_keys as key}, local_user::LocalUser, multi_community::{MultiCommunity, multi_community_keys as mkey}, site::Site, }, utils::{ limit_fetch, queries::filters::{ filter_is_subscribed, filter_not_unlisted_or_is_subscribed, filter_suggested_communities, }, }, }; use lemmy_db_schema_file::{ PersonId, enums::ListingType, joins::{ my_community_actions_join, my_instance_communities_actions_join, my_local_user_admin_join, my_multi_community_follower_join, }, schema::{ community, community_actions, instance_actions, multi_community, multi_community_entry, multi_community_follow, person, }, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, traits::Crud, utils::{LowerKey, now, seconds_to_pg_interval}, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl CommunityView { #[diesel::dsl::auto_type(no_type_alias)] fn joins(person_id: Option) -> _ { let community_actions_join: my_community_actions_join = my_community_actions_join(person_id); let instance_actions_community_join: my_instance_communities_actions_join = my_instance_communities_actions_join(person_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(person_id); community::table .left_join(community_actions_join) .left_join(instance_actions_community_join) .left_join(my_local_user_admin_join) } pub async fn read( pool: &mut DbPool<'_>, community_id: CommunityId, my_local_user: Option<&'_ LocalUser>, is_mod_or_admin: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut query = Self::joins(my_local_user.person_id()) .filter(community::id.eq(community_id)) .select(Self::as_select()) .into_boxed(); // Hide deleted and removed for non-admins or mods if !is_mod_or_admin { query = query .filter(Community::hide_removed_and_deleted()) .filter(filter_not_unlisted_or_is_subscribed()); } query = my_local_user.visible_communities_only(query); query .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl PaginationCursorConversion for CommunityView { type PaginatedType = Community; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.community.id.0) } async fn from_cursor( data: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Community::read(pool, CommunityId(data.id()?)).await } } #[derive(Default)] pub struct CommunityQuery<'a> { pub listing_type: Option, pub sort: Option, pub time_range_seconds: Option, pub local_user: Option<&'a LocalUser>, pub show_nsfw: Option, pub multi_community_id: Option, pub page_cursor: Option, pub limit: Option, } impl CommunityQuery<'_> { pub async fn list( self, site: &Site, pool: &mut DbPool<'_>, ) -> LemmyResult> { use lemmy_db_schema::CommunitySortType::*; let o = self; let limit = limit_fetch(o.limit, None)?; let mut query = CommunityView::joins(o.local_user.person_id()) .select(CommunityView::as_select()) .limit(limit) .into_boxed(); // Hide deleted and removed for non-admins let is_admin = o.local_user.map(|l| l.admin).unwrap_or_default(); if !is_admin { query = query .filter(Community::hide_removed_and_deleted()) .filter(filter_not_unlisted_or_is_subscribed()); } if let Some(listing_type) = o.listing_type { query = match listing_type { ListingType::All => query.filter(filter_not_unlisted_or_is_subscribed()), ListingType::Subscribed => query.filter(filter_is_subscribed()), ListingType::Local => query .filter(community::local.eq(true)) .filter(filter_not_unlisted_or_is_subscribed()), ListingType::ModeratorView => { query.filter(community_actions::became_moderator_at.is_not_null()) } ListingType::Suggested => query.filter(filter_suggested_communities()), }; } // Don't show blocked communities and communities on blocked instances. nsfw communities are // also hidden (based on profile setting) query = query.filter(instance_actions::blocked_communities_at.is_null()); query = query.filter(community_actions::blocked_at.is_null()); if !(o.local_user.show_nsfw(site) || o.show_nsfw.unwrap_or_default()) { query = query.filter(community::nsfw.eq(false)); } query = o.local_user.visible_communities_only(query); if let Some(multi_community_id) = o.multi_community_id { let communities = multi_community_entry::table .filter(multi_community_entry::multi_community_id.eq(multi_community_id)) .select(multi_community_entry::community_id); query = query.filter(community::id.eq_any(communities)) } // Filter by the time range if let Some(time_range_seconds) = o.time_range_seconds { query = query .filter(community::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds))); } // Only sort by ascending for Old or NameAsc sorts. let sort = o.sort.unwrap_or_default(); let sort_direction = asc_if(sort == Old || sort == NameAsc); let mut pq = CommunityView::paginate(query, &o.page_cursor, sort_direction, pool, None).await?; pq = match sort { Hot => pq.then_order_by(key::hot_rank), Comments => pq.then_order_by(key::comments), Posts => pq.then_order_by(key::posts), New => pq.then_order_by(key::published_at), Old => pq.then_order_by(key::published_at), Subscribers => pq.then_order_by(key::subscribers), SubscribersLocal => pq.then_order_by(key::subscribers_local), ActiveSixMonths => pq.then_order_by(key::users_active_half_year), ActiveMonthly => pq.then_order_by(key::users_active_month), ActiveWeekly => pq.then_order_by(key::users_active_week), ActiveDaily => pq.then_order_by(key::users_active_day), NameAsc => pq.then_order_by(LowerKey(key::name)), NameDesc => pq.then_order_by(LowerKey(key::name)), }; // finally use unique id as tie breaker pq = pq.then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = pq .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, o.page_cursor) } } impl MultiCommunityView { #[diesel::dsl::auto_type(no_type_alias)] fn joins(person_id: Option) -> _ { let my_multi_community_follower_join: my_multi_community_follower_join = my_multi_community_follower_join(person_id); multi_community::table .inner_join(person::table) .left_join(my_multi_community_follower_join) } pub async fn read( pool: &mut DbPool<'_>, id: MultiCommunityId, my_person_id: Option, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins(my_person_id) .filter(multi_community::id.eq(id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl PaginationCursorConversion for MultiCommunityView { type PaginatedType = MultiCommunity; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.multi.id.0) } async fn from_cursor( data: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { MultiCommunity::read(pool, MultiCommunityId(data.id()?)).await } } #[derive(Default)] pub struct MultiCommunityQuery { pub listing_type: Option, pub sort: Option, pub time_range_seconds: Option, pub my_person_id: Option, pub creator_id: Option, pub page_cursor: Option, pub limit: Option, pub no_limit: Option, } impl MultiCommunityQuery { pub async fn list(self, pool: &mut DbPool<'_>) -> LemmyResult> { use lemmy_db_schema::{MultiCommunityListingType::*, MultiCommunitySortType::*}; let o = self; let limit = limit_fetch(o.limit, o.no_limit)?; let mut query = MultiCommunityView::joins(o.my_person_id) .select(MultiCommunityView::as_select()) .limit(limit) .into_boxed(); if let Some(listing_type) = o.listing_type { query = match listing_type { All => query, Subscribed => { if let Some(my_person_id) = o.my_person_id { query.filter(multi_community_follow::person_id.eq(my_person_id)) } else { query } } Local => query.filter(multi_community::local), }; } if let Some(creator_id) = o.creator_id { query = query.filter(multi_community::creator_id.eq(creator_id)); } // Filter by the time range if let Some(time_range_seconds) = o.time_range_seconds { query = query.filter( multi_community::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)), ); } // Only sort by ascending for Old or NameAsc sorts. let sort = o.sort.unwrap_or_default(); let sort_direction = asc_if(sort == Old || sort == NameAsc); let mut pq = MultiCommunityView::paginate(query, &o.page_cursor, sort_direction, pool, None).await?; pq = match sort { New => pq.then_order_by(mkey::published_at), Old => pq.then_order_by(mkey::published_at), Communities => pq.then_order_by(mkey::communities), Subscribers => pq.then_order_by(mkey::subscribers), SubscribersLocal => pq.then_order_by(mkey::subscribers_local), NameAsc => pq.then_order_by(LowerKey(mkey::name)), NameDesc => pq.then_order_by(LowerKey(mkey::name)), }; // finally use unique id as tie breaker pq = pq.then_order_by(mkey::id); let conn = &mut get_conn(pool).await?; let res = pq .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, o.page_cursor) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::{ CommunityView, impls::{CommunityQuery, MultiCommunityListingType, MultiCommunityQuery}, }; use lemmy_db_schema::{ CommunitySortType, source::{ community::{ Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, CommunityUpdateForm, }, instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, multi_community::{MultiCommunity, MultiCommunityFollowForm, MultiCommunityInsertForm}, person::{Person, PersonInsertForm}, site::Site, }, traits::Followable, }; use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility}; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serial_test::serial; use std::collections::HashSet; use url::Url; struct Data { instance: Instance, local_user: LocalUser, communities: [Community; 3], site: Site, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let person_name = "tegan".to_string(); let new_person = PersonInsertForm::test_form(instance.id, &person_name); let inserted_person = Person::create(pool, &new_person).await?; let local_user_form = LocalUserInsertForm::test_form(inserted_person.id); let local_user = LocalUser::create(pool, &local_user_form, vec![]).await?; let communities = [ Community::create( pool, &CommunityInsertForm::new( instance.id, "test_community_1".to_string(), "nada1".to_owned(), "pubkey".to_string(), ), ) .await?, Community::create( pool, &CommunityInsertForm::new( instance.id, "test_community_2".to_string(), "nada2".to_owned(), "pubkey".to_string(), ), ) .await?, Community::create( pool, &CommunityInsertForm::new( instance.id, "test_community_3".to_string(), "nada3".to_owned(), "pubkey".to_string(), ), ) .await?, ]; let url = Url::parse("http://example.com")?; let site = Site { id: Default::default(), name: String::new(), sidebar: None, published_at: Default::default(), updated_at: None, icon: None, banner: None, summary: None, ap_id: url.clone().into(), last_refreshed_at: Default::default(), inbox_url: url.into(), private_key: None, public_key: String::new(), instance_id: Default::default(), content_warning: None, }; Ok(Data { instance, local_user, communities, site, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { for Community { id, .. } in data.communities { Community::delete(pool, id).await?; } Person::delete(pool, data.local_user.person_id).await?; Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn follow_state() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let community = &data.communities[0]; let unauthenticated = CommunityView::read(pool, community.id, None, false).await?; assert!(unauthenticated.community_actions.is_none()); let authenticated = CommunityView::read(pool, community.id, Some(&data.local_user), false).await?; assert!(authenticated.community_actions.is_none()); let form = CommunityFollowerForm::new( community.id, data.local_user.person_id, CommunityFollowerState::Pending, ); CommunityActions::follow(pool, &form).await?; let with_pending_follow = CommunityView::read(pool, community.id, Some(&data.local_user), false).await?; assert!( with_pending_follow .community_actions .is_some_and(|x| x.follow_state == Some(CommunityFollowerState::Pending)) ); // mark community private and set follow as approval required Community::update( pool, community.id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::Private), ..Default::default() }, ) .await?; let form = CommunityFollowerForm::new( community.id, data.local_user.person_id, CommunityFollowerState::ApprovalRequired, ); CommunityActions::follow(pool, &form).await?; let with_approval_required_follow = CommunityView::read(pool, community.id, Some(&data.local_user), false).await?; assert!( with_approval_required_follow .community_actions .is_some_and(|x| x.follow_state == Some(CommunityFollowerState::ApprovalRequired)) ); let form = CommunityFollowerForm::new( community.id, data.local_user.person_id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &form).await?; let with_accepted_follow = CommunityView::read(pool, community.id, Some(&data.local_user), false).await?; assert!( with_accepted_follow .community_actions .is_some_and(|x| x.follow_state == Some(CommunityFollowerState::Accepted)) ); cleanup(data, pool).await } #[tokio::test] #[serial] async fn local_only_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; Community::update( pool, data.communities[0].id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::LocalOnlyPrivate), ..Default::default() }, ) .await?; let unauthenticated_query = CommunityQuery { sort: Some(CommunitySortType::New), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(data.communities.len() - 1, unauthenticated_query.len()); let authenticated_query = CommunityQuery { local_user: Some(&data.local_user), sort: Some(CommunitySortType::New), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(data.communities.len(), authenticated_query.len()); let unauthenticated_community = CommunityView::read(pool, data.communities[0].id, None, false).await; assert!(unauthenticated_community.is_err()); let authenticated_community = CommunityView::read(pool, data.communities[0].id, Some(&data.local_user), false).await; assert!(authenticated_community.is_ok()); cleanup(data, pool).await } #[tokio::test] #[serial] async fn community_sort_name() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let query = CommunityQuery { sort: Some(CommunitySortType::NameAsc), ..Default::default() }; let communities = query.list(&data.site, pool).await?; for (i, c) in communities.iter().enumerate().skip(1) { let prev = communities.get(i - 1).ok_or(LemmyErrorType::NotFound)?; assert!(c.community.title.cmp(&prev.community.title).is_ge()); } let query = CommunityQuery { sort: Some(CommunitySortType::NameDesc), ..Default::default() }; let communities = query.list(&data.site, pool).await?; for (i, c) in communities.iter().enumerate().skip(1) { let prev = communities.get(i - 1).ok_or(LemmyErrorType::NotFound)?; assert!(c.community.title.cmp(&prev.community.title).is_le()); } cleanup(data, pool).await } #[tokio::test] #[serial] async fn can_mod() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Make sure can_mod is false for all of them. CommunityQuery { local_user: Some(&data.local_user), sort: Some(CommunitySortType::New), ..Default::default() } .list(&data.site, pool) .await? .iter() .for_each(|c| assert!(!c.can_mod)); let person_id = data.local_user.person_id; // Now join the mod team of test community 1 and 2 let mod_form_1 = CommunityModeratorForm::new(data.communities[0].id, person_id); CommunityActions::join(pool, &mod_form_1).await?; let mod_form_2 = CommunityModeratorForm::new(data.communities[1].id, person_id); CommunityActions::join(pool, &mod_form_2).await?; let mod_query = CommunityQuery { local_user: Some(&data.local_user), ..Default::default() } .list(&data.site, pool) .await? .iter() .map(|c| (c.community.name.clone(), c.can_mod)) .collect::>(); let expected_communities = HashSet::from([ ("test_community_3".to_owned(), false), ("test_community_2".to_owned(), true), ("test_community_1".to_owned(), true), ]); assert_eq!(expected_communities, mod_query); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_multi_community_list() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let tom_form = PersonInsertForm::test_form(data.instance.id, "tom"); let tom = Person::create(pool, &tom_form).await?; let multi_1_form = MultiCommunityInsertForm::new( data.local_user.person_id, data.instance.id, "multi2".to_string(), String::new(), ); let multi = MultiCommunity::create(pool, &multi_1_form).await?; let multi_2_form = MultiCommunityInsertForm::new(tom.id, tom.instance_id, "multi2".to_string(), String::new()); let multi2 = MultiCommunity::create(pool, &multi_2_form).await?; // list all multis let list_all = MultiCommunityQuery::default() .list(pool) .await? .iter() .map(|m| m.multi.id) .collect::>(); assert_eq!(list_all, HashSet::from([multi.id, multi2.id])); // list multis by owner let list_owner = MultiCommunityQuery { creator_id: Some(data.local_user.person_id), my_person_id: Some(data.local_user.person_id), ..Default::default() } .list(pool) .await?; assert_eq!(list_owner.len(), 1); assert_eq!(list_owner[0].multi.id, multi.id); assert_eq!(list_owner[0].follow_state, None); // Tegan follows multi2 let follow_form = MultiCommunityFollowForm { multi_community_id: multi2.id, person_id: data.local_user.person_id, follow_state: CommunityFollowerState::Accepted, }; MultiCommunity::follow(pool, &follow_form).await?; // list multis followed by user, followed_only let list_followed = MultiCommunityQuery { my_person_id: Some(data.local_user.person_id), listing_type: Some(MultiCommunityListingType::Subscribed), ..Default::default() } .list(pool) .await?; assert_eq!(list_followed.len(), 1); assert_eq!(list_followed[0].multi.id, multi2.id); assert_eq!(list_followed[0].owner.id, tom.id); assert_eq!( list_followed[0].follow_state, Some(CommunityFollowerState::Accepted) ); // Unfollow, and make sure its removed MultiCommunity::unfollow(pool, data.local_user.person_id, multi2.id).await?; let list_followed = MultiCommunityQuery { my_person_id: Some(data.local_user.person_id), listing_type: Some(MultiCommunityListingType::Subscribed), ..Default::default() } .list(pool) .await?; assert_eq!(list_followed.len(), 0); cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/community/src/lib.rs ================================================ use lemmy_db_schema::source::{ community::{Community, CommunityActions}, community_tag::CommunityTagsView, multi_community::MultiCommunity, person::Person, }; use lemmy_db_schema_file::enums::CommunityFollowerState; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{NullableExpressionMethods, Queryable, Selectable}, lemmy_db_schema::utils::queries::selects::{ community_tags_fragment, local_user_community_can_mod, }, lemmy_db_schema_file::schema::multi_community_follow, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A community view. pub struct CommunityView { #[cfg_attr(feature = "full", diesel(embed))] pub community: Community, #[cfg_attr(feature = "full", diesel(embed))] pub community_actions: Option, #[cfg_attr(feature = "full", diesel( select_expression = local_user_community_can_mod() ) )] pub can_mod: bool, #[cfg_attr(feature = "full", diesel( select_expression = community_tags_fragment() ) )] pub tags: CommunityTagsView, } #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct MultiCommunityView { #[cfg_attr(feature = "full", diesel(embed))] pub multi: MultiCommunity, #[cfg_attr(feature = "full", diesel( select_expression = multi_community_follow::follow_state.nullable() ) )] pub follow_state: Option, #[cfg_attr(feature = "full", diesel(embed))] pub owner: Person, } ================================================ FILE: crates/db_views/community_follower/Cargo.toml ================================================ [package] name = "lemmy_db_views_community_follower" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } chrono = { workspace = true } lemmy_diesel_utils = { workspace = true, optional = true } [dev-dependencies] ================================================ FILE: crates/db_views/community_follower/src/impls.rs ================================================ use crate::CommunityFollowerView; use chrono::Utc; use diesel::{ ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper, dsl::{count_star, exists, not}, select, }; use diesel_async::RunQueryDsl; use lemmy_db_schema::newtypes::CommunityId; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::CommunityFollowerState, schema::{community, community_actions, person}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, dburl::DbUrl, utils::functions::lower, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl CommunityFollowerView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { community_actions::table .filter(community_actions::followed_at.is_not_null()) .inner_join(community::table) .inner_join(person::table.on(community_actions::person_id.eq(person::id))) } /// return a list of local community ids and remote inboxes that at least one user of the given /// instance has followed pub async fn get_instance_followed_community_inboxes( pool: &mut DbPool<'_>, instance_id: InstanceId, published_since: chrono::DateTime, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; // In most cases this will fetch the same url many times (the shared inbox url) // PG will only send a single copy to rust, but it has to scan through all follower rows (same // as it was before). So on the PG side it would be possible to optimize this further by // adding e.g. a new table community_followed_instances (community_id, instance_id) // that would work for all instances that support fully shared inboxes. // It would be a bit more complicated though to keep it in sync. Self::joins() .filter(person::instance_id.eq(instance_id)) .filter(community::local) // this should be a no-op since community_followers table only has // local-person+remote-community or remote-person+local-community .filter(not(person::local)) .filter(community_actions::followed_at.gt(published_since.naive_utc())) .select((community::id, person::inbox_url)) .distinct() // only need each community_id, inbox combination once .load::<(CommunityId, DbUrl)>(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn count_community_followers( pool: &mut DbPool<'_>, community_id: CommunityId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(community_actions::community_id.eq(community_id)) .select(count_star()) .first::(conn) .await .map(i32::try_from)? .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn for_person(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins() .filter(community_actions::person_id.eq(person_id)) .filter(community::deleted.eq(false)) .filter(community::removed.eq(false)) .filter(community::local_removed.eq(false)) // Exclude private community follows which still need to be approved by a mod .filter(community_actions::follow_state.ne(CommunityFollowerState::ApprovalRequired)) .filter(community_actions::follow_state.ne(CommunityFollowerState::Denied)) .select(Self::as_select()) .order_by(lower(community::title)) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn is_follower( community_id: CommunityId, instance_id: InstanceId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( Self::joins() .filter(community_actions::community_id.eq(community_id)) .filter(person::instance_id.eq(instance_id)) .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotFound.into()) } } ================================================ FILE: crates/db_views/community_follower/src/lib.rs ================================================ #[cfg(feature = "full")] use diesel::{Queryable, Selectable}; use lemmy_db_schema::source::{community::Community, person::Person}; use lemmy_db_schema_file::enums::CommunityFollowerState; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A community follower. pub struct CommunityFollowerView { #[cfg_attr(feature = "full", diesel(embed))] pub community: Community, #[cfg_attr(feature = "full", diesel(embed))] pub follower: Person, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PendingFollow { pub person: Person, pub community: Community, pub is_new_instance: bool, pub follow_state: Option, } ================================================ FILE: crates/db_views/community_follower_approval/Cargo.toml ================================================ [package] name = "lemmy_db_views_community_follower_approval" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/community_follower_approval/src/api.rs ================================================ use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ListCommunityPendingFollows { /// Only shows the unapproved applications pub unread_only: Option, // Only for admins, show pending follows for communities which you dont moderate pub all_communities: Option, pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/community_follower_approval/src/impls.rs ================================================ use crate::PendingFollowerView; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, dsl::{count, exists, sql}, pg::sql_types::Array, select, sql_types::Integer, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ newtypes::CommunityId, source::{ community::{Community, CommunityActions, community_actions_keys as key}, person::Person, }, utils::{limit_fetch, queries::selects::person1_select}, }; use lemmy_db_schema_file::{ InstanceId, PersonId, aliases, enums::{CommunityFollowerState, CommunityVisibility}, schema::{community, community_actions, person}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use std::collections::HashMap; diesel::alias!(community_actions as follower_community_actions: FollowerCommunityActions); impl PendingFollowerView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { let follower_community_actions_join = follower_community_actions .on(community::id.eq(follower_community_actions.field(community_actions::community_id))); let follower_id = aliases::person1.field(person::id); let follower_join = aliases::person1.on( follower_community_actions .field(community_actions::person_id) .eq(follower_id) .and( follower_community_actions .field(community_actions::followed_at) .is_not_null(), ) .and(community::id.eq(follower_community_actions.field(community_actions::community_id))), ); let person_join = person::table.on(community_actions::person_id.eq(person::id)); community_actions::table .inner_join(community::table) .inner_join(person_join) .inner_join(follower_community_actions_join) .inner_join(follower_join) } pub async fn list_approval_required( pool: &mut DbPool<'_>, mod_id: PersonId, all_communities: bool, unread_only: bool, page_cursor: Option, limit: Option, ) -> LemmyResult> { let limit = limit_fetch(limit, None)?; let mut query = Self::joins() .filter(community_actions::became_moderator_at.is_not_null()) .filter(community::visibility.eq(CommunityVisibility::Private)) .select(( person1_select(), community::all_columns, follower_community_actions .field(community_actions::follow_state) .nullable(), )) .limit(limit) .into_boxed(); // if param is false, only return items for communities where user is a mod if !all_communities { query = query.filter(person::id.eq(mod_id)); } if unread_only { query = query.filter( follower_community_actions .field(community_actions::follow_state) .eq(CommunityFollowerState::ApprovalRequired), ); } // Sorting by published let paginated_query = Self::paginate(query, &page_cursor, SortDirection::Asc, pool, None) .await? .then_order_by(key::followed_at); let conn = &mut get_conn(pool).await?; let mut res: Vec<_> = paginated_query .load::<(Person, Community, Option)>(conn) .await? .into_iter() .map(|(person, community, follow_state)| PendingFollowerView { person, community, is_new_instance: true, follow_state, }) .collect(); // For all returned communities, get the list of approved follower instances // TODO: This should be merged into the main query above as a subquery let community_ids: Vec<_> = res.iter().map(|r| r.community.id).collect(); let approved_follower_instances: HashMap<_, _> = community_actions::table .inner_join(person::table.on(community_actions::person_id.eq(person::id))) .filter(community_actions::community_id.eq_any(community_ids)) .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)) .group_by(community_actions::community_id) .select(( community_actions::community_id, sql::>("array_agg(distinct person.instance_id) instance_ids"), )) .load::<(CommunityId, Vec)>(conn) .await? .into_iter() .collect(); // Check if there is already an approved follower from the same instance. If not, frontends // should show a warning because a malicious admin could leak private community data. for r in &mut res { let instance_ids = approved_follower_instances.get(&r.community.id); if let Some(instance_ids) = instance_ids && instance_ids.contains(&r.person.instance_id) { r.is_new_instance = false; } } paginate_response(res, limit, page_cursor) } pub async fn count_approval_required( pool: &mut DbPool<'_>, mod_id: PersonId, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(community_actions::became_moderator_at.is_not_null()) .filter(community::visibility.eq(CommunityVisibility::Private)) .filter(person::id.eq(mod_id)) .filter( follower_community_actions .field(community_actions::follow_state) .eq(CommunityFollowerState::ApprovalRequired), ) .select(count(community_actions::community_id)) .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn check_private_community_action( pool: &mut DbPool<'_>, from_person_id: PersonId, community: &Community, ) -> LemmyResult<()> { if community.visibility != CommunityVisibility::Private { return Ok(()); } let conn = &mut get_conn(pool).await?; select(exists( Self::joins() .filter(community_actions::community_id.eq(community.id)) .filter(community_actions::person_id.eq(from_person_id)) .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotFound.into()) } pub async fn check_has_followers_from_instance( community_id: CommunityId, instance_id: InstanceId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( Self::joins() .filter(community::visibility.eq(CommunityVisibility::Private)) .filter(community_actions::community_id.eq(community_id)) .filter(aliases::person1.field(person::instance_id).eq(instance_id)) .filter(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotFound.into()) } } impl PaginationCursorConversion for PendingFollowerView { type PaginatedType = CommunityActions; fn to_cursor(&self) -> CursorData { // This needs a person and community CursorData::new_multi([self.person.id.0, self.community.id.0]) } async fn from_cursor( data: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let [person_id, community_id] = data.multi()?; CommunityActions::read(pool, CommunityId(community_id), PersonId(person_id)).await } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use crate::PendingFollowerView; use lemmy_db_schema::{ assert_length, source::{ community::{ CommunityActions, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, }, instance::Instance, person::PersonInsertForm, }, traits::Followable, }; use lemmy_db_schema_file::enums::CommunityVisibility; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use serial_test::serial; #[tokio::test] #[serial] async fn test_has_followers_from_instance() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); // insert local community let local_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let community_form = CommunityInsertForm { visibility: Some(CommunityVisibility::Private), ..CommunityInsertForm::new( local_instance.id, "test_community_3".to_string(), "nada".to_owned(), "pubkey".to_string(), ) }; let community = Community::create(pool, &community_form).await?; // insert remote user let remote_instance = Instance::read_or_create(pool, "other_domain.tld").await?; let person_form = PersonInsertForm::new("name".to_string(), "pubkey".to_string(), remote_instance.id); let person = Person::create(pool, &person_form).await?; // community has no follower from remote instance, returns error let has_followers = PendingFollowerView::check_has_followers_from_instance( community.id, remote_instance.id, pool, ) .await; assert!(has_followers.is_err()); // insert unapproved follower let mut follower_form = CommunityFollowerForm::new( community.id, person.id, CommunityFollowerState::ApprovalRequired, ); CommunityActions::follow(pool, &follower_form).await?; // still returns error let has_followers = PendingFollowerView::check_has_followers_from_instance( community.id, remote_instance.id, pool, ) .await; assert!(has_followers.is_err()); // mark follower as accepted follower_form.follow_state = CommunityFollowerState::Accepted; CommunityActions::follow(pool, &follower_form).await?; // now returns ok let has_followers = PendingFollowerView::check_has_followers_from_instance( community.id, remote_instance.id, pool, ) .await; assert!(has_followers.is_ok()); Instance::delete(pool, local_instance.id).await?; Instance::delete(pool, remote_instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_pending_followers() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); // insert local community let local_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let community_form = CommunityInsertForm { visibility: Some(CommunityVisibility::Private), ..CommunityInsertForm::new( local_instance.id, "test_community_3".to_string(), "nada".to_owned(), "pubkey".to_string(), ) }; let community = Community::create(pool, &community_form).await?; // insert local mod let mod_form = PersonInsertForm::new("name".to_string(), "pubkey".to_string(), local_instance.id); let mod_ = Person::create(pool, &mod_form).await?; let moderator_form = CommunityModeratorForm::new(community.id, mod_.id); CommunityActions::join(pool, &moderator_form).await?; // insert remote user let remote_instance = Instance::read_or_create(pool, "other_domain.tld").await?; let person_form = PersonInsertForm::new("name".to_string(), "pubkey".to_string(), remote_instance.id); let person = Person::create(pool, &person_form).await?; // check that counts are initially 0 let count = PendingFollowerView::count_approval_required(pool, mod_.id).await?; assert_eq!(0, count); let list = PendingFollowerView::list_approval_required(pool, mod_.id, false, true, None, None).await?; assert_length!(0, list); // user is not allowed to post let posting_allowed = PendingFollowerView::check_private_community_action(pool, person.id, &community).await; assert!(posting_allowed.is_err()); // send follow request let follower_form = CommunityFollowerForm::new( community.id, person.id, CommunityFollowerState::ApprovalRequired, ); CommunityActions::follow(pool, &follower_form).await?; // now there should be a pending follow let count = PendingFollowerView::count_approval_required(pool, mod_.id).await?; assert_eq!(1, count); let list = PendingFollowerView::list_approval_required(pool, mod_.id, false, true, None, None).await?; assert_length!(1, list); assert_eq!(person.id, list[0].person.id); assert_eq!(community.id, list[0].community.id); assert_eq!( Some(CommunityFollowerState::ApprovalRequired), list[0].follow_state ); assert!(list[0].is_new_instance); // approve the follow CommunityActions::follow_accepted(pool, community.id, person.id).await?; // now the user can post let posting_allowed = PendingFollowerView::check_private_community_action(pool, person.id, &community).await; assert!(posting_allowed.is_ok()); // check counts again let count = PendingFollowerView::count_approval_required(pool, mod_.id).await?; assert_eq!(0, count); let list = PendingFollowerView::list_approval_required(pool, mod_.id, false, true, None, None).await?; assert_length!(0, list); let list_all = PendingFollowerView::list_approval_required(pool, mod_.id, false, false, None, None).await?; assert_length!(1, list_all); assert_eq!(person.id, list_all[0].person.id); assert_eq!(community.id, list_all[0].community.id); assert_eq!( Some(CommunityFollowerState::Accepted), list_all[0].follow_state ); assert!(!list_all[0].is_new_instance); Instance::delete(pool, local_instance.id).await?; Instance::delete(pool, remote_instance.id).await?; Ok(()) } } ================================================ FILE: crates/db_views/community_follower_approval/src/lib.rs ================================================ #[cfg(feature = "full")] use diesel::Queryable; use lemmy_db_schema::source::{community::Community, person::Person}; use lemmy_db_schema_file::enums::CommunityFollowerState; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PendingFollowerView { pub person: Person, pub community: Community, pub is_new_instance: bool, pub follow_state: Option, } ================================================ FILE: crates/db_views/community_moderator/Cargo.toml ================================================ [package] name = "lemmy_db_views_community_moderator" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } ================================================ FILE: crates/db_views/community_moderator/src/impls.rs ================================================ use crate::{CommunityModeratorView, CommunityPersonBanView}; use diesel::{ ExpressionMethods, JoinOnDsl, OptionalExtension, QueryDsl, SelectableHelper, dsl::{exists, not}, select, }; use diesel_async::RunQueryDsl; use lemmy_db_schema::{ impls::local_user::LocalUserOptionHelper, newtypes::CommunityId, source::local_user::LocalUser, }; use lemmy_db_schema_file::{ PersonId, schema::{community, community_actions, person}, }; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl CommunityModeratorView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { community_actions::table .filter(community_actions::became_moderator_at.is_not_null()) .inner_join(community::table) .inner_join(person::table.on(person::id.eq(community_actions::person_id))) } pub async fn check_is_community_moderator( pool: &mut DbPool<'_>, community_id: CommunityId, person_id: PersonId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( Self::joins() .filter(community_actions::person_id.eq(person_id)) .filter(community_actions::community_id.eq(community_id)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotAModerator.into()) } pub async fn is_community_moderator_of_any( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; select(exists( Self::joins().filter(community_actions::person_id.eq(person_id)), )) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::NotAModerator.into()) } pub async fn for_community( pool: &mut DbPool<'_>, community_id: CommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins() .filter(community_actions::community_id.eq(community_id)) .select(Self::as_select()) .order_by(community_actions::became_moderator_at) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn top_mod_for_community( pool: &mut DbPool<'_>, community_id: CommunityId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins() .filter(community_actions::community_id.eq(community_id)) .select(person::id) .order_by(community_actions::became_moderator_at) .first(conn) .await .optional() .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn for_person( pool: &mut DbPool<'_>, person_id: PersonId, local_user: Option<&LocalUser>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let mut query = Self::joins() .filter(community_actions::person_id.eq(person_id)) .select(Self::as_select()) .into_boxed(); query = local_user.visible_communities_only(query); // only show deleted communities to creator if Some(person_id) != local_user.person_id() { query = query.filter(community::deleted.eq(false)); } // Show removed communities to admins only if !local_user.is_admin() { query = query .filter(community::removed.eq(false)) .filter(community::local_removed.eq(false)); } query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Finds all communities first mods / creators /// Ideally this should be a group by, but diesel doesn't support it yet pub async fn get_community_first_mods(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins() .select(Self::as_select()) // A hacky workaround instead of group_bys // https://stackoverflow.com/questions/24042359/how-to-join-only-one-row-in-joined-table-with-postgres .distinct_on(community_actions::community_id) .order_by(( community_actions::community_id, community_actions::became_moderator_at, )) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl CommunityPersonBanView { pub async fn check( pool: &mut DbPool<'_>, from_person_id: PersonId, from_community_id: CommunityId, ) -> LemmyResult<()> { let conn = &mut get_conn(pool).await?; let find_action = community_actions::table .find((from_person_id, from_community_id)) .filter(community_actions::received_ban_at.is_not_null()); select(not(exists(find_action))) .get_result::(conn) .await? .then_some(()) .ok_or(LemmyErrorType::PersonIsBannedFromCommunity.into()) } } ================================================ FILE: crates/db_views/community_moderator/src/lib.rs ================================================ #[cfg(feature = "full")] use diesel::{Queryable, Selectable}; use lemmy_db_schema::source::{community::Community, person::Person}; use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A community moderator. pub struct CommunityModeratorView { #[cfg_attr(feature = "full", diesel(embed))] pub community: Community, #[cfg_attr(feature = "full", diesel(embed))] pub moderator: Person, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] /// A community person ban. pub struct CommunityPersonBanView { #[cfg_attr(feature = "full", diesel(embed))] pub community: Community, #[cfg_attr(feature = "full", diesel(embed))] pub person: Person, } ================================================ FILE: crates/db_views/custom_emoji/Cargo.toml ================================================ [package] name = "lemmy_db_views_custom_emoji" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } lemmy_diesel_utils = { workspace = true } ================================================ FILE: crates/db_views/custom_emoji/src/api.rs ================================================ use crate::CustomEmojiView; use lemmy_db_schema::newtypes::CustomEmojiId; use lemmy_diesel_utils::dburl::DbUrl; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a custom emoji. pub struct CreateCustomEmoji { pub category: String, pub shortcode: String, pub image_url: DbUrl, pub alt_text: String, pub keywords: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A response for a custom emoji. pub struct CustomEmojiResponse { pub custom_emoji: CustomEmojiView, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete a custom emoji. pub struct DeleteCustomEmoji { pub id: CustomEmojiId, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edit a custom emoji. pub struct EditCustomEmoji { pub id: CustomEmojiId, pub category: Option, pub shortcode: Option, pub image_url: Option, pub alt_text: Option, pub keywords: Option>, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetches a list of custom emojis. pub struct ListCustomEmojis { pub category: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A response for custom emojis. pub struct ListCustomEmojisResponse { pub custom_emojis: Vec, } ================================================ FILE: crates/db_views/custom_emoji/src/impls.rs ================================================ use crate::CustomEmojiView; use diesel::{ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, dsl::Nullable}; use diesel_async::RunQueryDsl; use lemmy_db_schema::{ newtypes::CustomEmojiId, source::{custom_emoji::CustomEmoji, custom_emoji_keyword::CustomEmojiKeyword}, }; use lemmy_db_schema_file::schema::{custom_emoji, custom_emoji_keyword}; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use std::collections::HashMap; type SelectionType = ( ::AllColumns, Nullable<::AllColumns>, ); fn selection() -> SelectionType { ( custom_emoji::all_columns, custom_emoji_keyword::all_columns.nullable(), // (or all the columns if you want) ) } type CustomEmojiTuple = (CustomEmoji, Option); // TODO this type is a mess, it should not be using vectors in a view. impl CustomEmojiView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { custom_emoji::table.left_join( custom_emoji_keyword::table.on(custom_emoji_keyword::custom_emoji_id.eq(custom_emoji::id)), ) } pub async fn get(pool: &mut DbPool<'_>, emoji_id: CustomEmojiId) -> LemmyResult { let conn = &mut get_conn(pool).await?; let emojis = Self::joins() .filter(custom_emoji::id.eq(emoji_id)) .select(selection()) .load::(conn) .await?; if let Some(emoji) = CustomEmojiView::from_tuple_to_vec(emojis) .into_iter() .next() { Ok(emoji) } else { Err(LemmyErrorType::NotFound.into()) } } pub async fn list(pool: &mut DbPool<'_>, category: &Option) -> LemmyResult> { let conn = &mut get_conn(pool).await?; let mut query = Self::joins().into_boxed(); if let Some(category) = category { query = query.filter(custom_emoji::category.eq(category)) } let emojis = query .select(selection()) .order(custom_emoji::category) .then_order_by(custom_emoji::id) .load::(conn) .await?; Ok(CustomEmojiView::from_tuple_to_vec(emojis)) } fn from_tuple_to_vec(items: Vec) -> Vec { let mut result = Vec::new(); let mut hash: HashMap> = HashMap::new(); for (emoji, keyword) in &items { let emoji_id: CustomEmojiId = emoji.id; if let std::collections::hash_map::Entry::Vacant(e) = hash.entry(emoji_id) { e.insert(Vec::new()); result.push(CustomEmojiView { custom_emoji: emoji.clone(), keywords: Vec::new(), }) } if let Some(item_keyword) = &keyword && let Some(keywords) = hash.get_mut(&emoji_id) { keywords.push(item_keyword.clone()) } } for emoji in &mut result { if let Some(keywords) = hash.get_mut(&emoji.custom_emoji.id) { emoji.keywords.clone_from(keywords); } } result } } ================================================ FILE: crates/db_views/custom_emoji/src/lib.rs ================================================ #[cfg(feature = "full")] use diesel::Queryable; use lemmy_db_schema::source::{ custom_emoji::CustomEmoji, custom_emoji_keyword::CustomEmojiKeyword, }; use serde::{Deserialize, Serialize}; pub mod api; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A custom emoji view. pub struct CustomEmojiView { pub custom_emoji: CustomEmoji, pub keywords: Vec, } ================================================ FILE: crates/db_views/local_image/Cargo.toml ================================================ [package] name = "lemmy_db_views_local_image" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } url = { workspace = true } ================================================ FILE: crates/db_views/local_image/src/api.rs ================================================ use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct DeleteImageParams { pub filename: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ImageGetParams { pub file_type: Option, pub max_size: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ImageProxyParams { pub url: String, pub file_type: Option, pub max_size: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Get your user's image / media uploads. pub struct ListMedia { pub page_cursor: Option, pub limit: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct UploadImageResponse { pub image_url: Url, pub filename: String, } ================================================ FILE: crates/db_views/local_image/src/impls.rs ================================================ use crate::LocalImageView; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ source::images::{LocalImage, local_image_keys as key}, utils::limit_fetch, }; use lemmy_db_schema_file::{ PersonId, schema::{local_image, person, post}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl LocalImageView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { local_image::table .inner_join(person::table) .left_join(post::table) } pub async fn get_all_paged_by_person_id( pool: &mut DbPool<'_>, person_id: PersonId, cursor_data: Option, limit: Option, ) -> LemmyResult> { let limit = limit_fetch(limit, None)?; let query = Self::joins() .filter(local_image::person_id.eq(person_id)) .select(Self::as_select()) .limit(limit) .into_boxed(); let paginated_query = Self::paginate(query, &cursor_data, SortDirection::Asc, pool, None) .await? .then_order_by(key::pictrs_alias); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, cursor_data) } pub async fn get_all_by_person_id( pool: &mut DbPool<'_>, person_id: PersonId, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins() .filter(local_image::person_id.eq(person_id)) .select(Self::as_select()) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn get_all_paged( pool: &mut DbPool<'_>, cursor_data: Option, limit: Option, ) -> LemmyResult> { let limit = limit_fetch(limit, None)?; let query = Self::joins() .select(Self::as_select()) .limit(limit) .into_boxed(); let paginated_query = Self::paginate(query, &cursor_data, SortDirection::Asc, pool, None).await?; let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, cursor_data) } } impl PaginationCursorConversion for LocalImageView { type PaginatedType = LocalImage; fn to_cursor(&self) -> CursorData { // Use pictrs alias CursorData::new_plain(self.local_image.pictrs_alias.clone()) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; // This isn't an id, but a string let alias = cursor.plain(); let token = local_image::table .select(Self::PaginatedType::as_select()) .filter(local_image::pictrs_alias.eq(alias)) .first(conn) .await?; Ok(token) } } ================================================ FILE: crates/db_views/local_image/src/lib.rs ================================================ #[cfg(feature = "full")] use diesel::{Queryable, Selectable}; use lemmy_db_schema::source::{images::LocalImage, person::Person, post::Post}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A local image view. pub struct LocalImageView { #[cfg_attr(feature = "full", diesel(embed))] pub local_image: LocalImage, #[cfg_attr(feature = "full", diesel(embed))] pub person: Person, #[cfg_attr(feature = "full", diesel(embed))] pub post: Option, } ================================================ FILE: crates/db_views/local_user/Cargo.toml ================================================ [package] name = "lemmy_db_views_local_user" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "actix-web", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "i-love-jesus", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } actix-web = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_views/local_user/src/api.rs ================================================ use lemmy_db_schema::LocalUserSortType; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct AdminListUsers { pub banned_only: Option, pub page_cursor: Option, pub sort: Option, pub limit: Option, } ================================================ FILE: crates/db_views/local_user/src/impls.rs ================================================ use crate::LocalUserView; use actix_web::{FromRequest, HttpMessage, HttpRequest, dev::Payload}; use diesel::{ BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, QueryDsl, SelectableHelper, }; use diesel_async::RunQueryDsl; use i_love_jesus::asc_if; use lemmy_db_schema::{ LocalUserSortType, newtypes::{LocalUserId, OAuthProviderId}, source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm, person_keys}, }, }; use lemmy_db_schema_file::{ PersonId, aliases::creator_home_instance_actions, joins::creator_home_instance_actions_join, schema::{instance_actions, local_user, oauth_account, person}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, traits::Crud, utils::{ functions::{coalesce, lower}, now, }, }; use lemmy_utils::error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult}; use std::future::{Ready, ready}; impl LocalUserView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { local_user::table .inner_join(person::table) .left_join(creator_home_instance_actions_join()) } pub async fn read(pool: &mut DbPool<'_>, local_user_id: LocalUserId) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(local_user::id.eq(local_user_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read_person(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(person::id.eq(person_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read_from_name(pool: &mut DbPool<'_>, name: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(lower(person::name).eq(name.to_lowercase())) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn find_by_email_or_name( pool: &mut DbPool<'_>, name_or_email: &str, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter( lower(person::name) .eq(lower(name_or_email.to_lowercase())) .or(lower(coalesce(local_user::email, "")).eq(name_or_email.to_lowercase())), ) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn find_by_email(pool: &mut DbPool<'_>, from_email: &str) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(lower(coalesce(local_user::email, "")).eq(from_email.to_lowercase())) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn find_by_oauth_id( pool: &mut DbPool<'_>, oauth_provider_id: OAuthProviderId, oauth_user_id: &str, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .inner_join(oauth_account::table) .filter(oauth_account::oauth_provider_id.eq(oauth_provider_id)) .filter(oauth_account::oauth_user_id.eq(oauth_user_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn list_admins_with_emails(pool: &mut DbPool<'_>) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins() .filter(local_user::email.is_not_null()) .filter(local_user::admin.eq(true)) .select(Self::as_select()) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn create_test_user( pool: &mut DbPool<'_>, name: &str, bio: &str, admin: bool, ) -> LemmyResult { let instance_id = Instance::read_or_create(pool, "example.com").await?.id; let person_form = PersonInsertForm { display_name: Some(name.to_owned()), bio: Some(bio.to_owned()), ..PersonInsertForm::test_form(instance_id, name) }; let person = Person::create(pool, &person_form).await?; let user_form = match admin { true => LocalUserInsertForm::test_form_admin(person.id), false => LocalUserInsertForm::test_form(person.id), }; let local_user = LocalUser::create(pool, &user_form, vec![]).await?; LocalUserView::read(pool, local_user.id).await } } #[derive(Default)] pub struct LocalUserQuery { pub banned_only: Option, pub page_cursor: Option, pub limit: Option, pub sort: Option, } impl LocalUserQuery { // TODO: add filters and sorts pub async fn list(self, pool: &mut DbPool<'_>) -> LemmyResult> { let limit = self.limit.unwrap_or(i64::MAX); let mut query = LocalUserView::joins() .filter(person::deleted.eq(false)) .limit(limit) .select(LocalUserView::as_select()) .into_boxed(); if self.banned_only.unwrap_or_default() { let actions = creator_home_instance_actions; query = query.filter( actions .field(instance_actions::received_ban_at) .is_not_null() .and( actions .field(instance_actions::ban_expires_at) .is_null() .or( actions .field(instance_actions::ban_expires_at) .gt(now().nullable()), ), ), ); } // Only sort by ascending for Old let sort = self.sort.unwrap_or_default(); let sort_direction = asc_if(sort == LocalUserSortType::Old); let paginated_query = LocalUserView::paginate(query, &self.page_cursor, sort_direction, pool, None) .await? .then_order_by(person_keys::published_at) // Tie breaker .then_order_by(person_keys::id); let conn = &mut get_conn(pool).await?; let res = paginated_query.load::(conn).await?; paginate_response(res, limit, self.page_cursor) } } impl FromRequest for LocalUserView { type Error = LemmyError; type Future = Ready>; fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { ready(match req.extensions().get::() { Some(c) => Ok(c.clone()), None => Err(LemmyErrorType::IncorrectLogin.into()), }) } } impl PaginationCursorConversion for LocalUserView { type PaginatedType = Person; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.person.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Person::read(pool, PersonId(cursor.id()?)).await } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use lemmy_db_schema::{ assert_length, source::{ instance::{Instance, InstanceActions, InstanceBanForm}, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, }, traits::Bannable, }; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { alice: Person, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let alice_form = PersonInsertForm { local: Some(true), ..PersonInsertForm::test_form(instance.id, "alice") }; let alice = Person::create(pool, &alice_form).await?; let alice_local_user_form = LocalUserInsertForm::test_form(alice.id); LocalUser::create(pool, &alice_local_user_form, vec![]).await?; Ok(Data { alice }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.alice.instance_id).await?; Ok(()) } #[tokio::test] #[serial] async fn list_banned() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; InstanceActions::ban( pool, &InstanceBanForm::new(data.alice.id, data.alice.instance_id, None), ) .await?; let list = LocalUserQuery { banned_only: Some(true), ..Default::default() } .list(pool) .await?; assert_length!(1, list); assert_eq!(list[0].person.id, data.alice.id); cleanup(data, pool).await } } ================================================ FILE: crates/db_views/local_user/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::source::{local_user::LocalUser, person::Person}; use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::utils::queries::selects::{creator_home_ban_expires, creator_home_banned}, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A local user view. pub struct LocalUserView { #[cfg_attr(feature = "full", diesel(embed))] pub local_user: LocalUser, #[cfg_attr(feature = "full", diesel(embed))] pub person: Person, #[cfg_attr(feature = "full", diesel( select_expression = creator_home_banned() ) )] pub banned: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_home_ban_expires() ) )] pub ban_expires_at: Option>, } ================================================ FILE: crates/db_views/modlog/Cargo.toml ================================================ [package] name = "lemmy_db_views_modlog" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/modlog/src/api.rs ================================================ use lemmy_db_schema::{ ModlogKindFilter, newtypes::{CommentId, CommunityId, ModlogId, PostId}, }; use lemmy_db_schema_file::{PersonId, enums::ListingType}; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetches the modlog. pub struct GetModlog { /// Filter by the moderator. pub mod_person_id: Option, /// Filter by the community. pub community_id: Option, /// Filter by the modlog action type. pub type_: Option, /// Filter by listing type. When not using All, it will remove the non-community modlog entries, /// such as site bans, instance blocks, adding an admin, etc. pub listing_type: Option, /// Filter by the other / modded person. pub other_person_id: Option, /// Filter by post. Will include comments of that post. pub post_id: Option, /// Filter by comment. pub comment_id: Option, /// When `true` show all. When `false` or `None`, hide bulk actions (default). pub show_bulk: Option, /// Return only child entries triggered by this parent modlog action. pub bulk_action_parent_id: Option, pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/modlog/src/impls.rs ================================================ use crate::ModlogView; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, SelectableHelper, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ ModlogKindFilter, impls::local_user::LocalUserOptionHelper, newtypes::{CommentId, CommunityId, ModlogId, PostId}, source::{ local_user::LocalUser, modlog::{Modlog, modlog_keys as key}, }, utils::{ limit_fetch, queries::filters::{ filter_is_subscribed, filter_not_unlisted_or_is_subscribed, filter_suggested_communities, }, }, }; use lemmy_db_schema_file::{ PersonId, aliases, enums::ListingType, schema::{comment, community, community_actions, instance, modlog, person, post}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::LemmyResult; impl ModlogView { #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: Option) -> _ { // The query for the admin / mod person let moderator_join = person::table.on(modlog::mod_id.eq(person::id)); // The modded / other person let target_person = aliases::person1.field(person::id).nullable(); let target_person_join = aliases::person1.on(modlog::target_person_id.eq(target_person)); let community_actions_join = community_actions::table.on( community_actions::community_id .eq(community::id) .and(community_actions::person_id.nullable().eq(my_person_id)), ); modlog::table .inner_join(moderator_join) .left_join(target_person_join) .left_join(comment::table.on(comment::id.nullable().eq(modlog::target_comment_id))) .left_join(post::table.on(post::id.nullable().eq(modlog::target_post_id))) .left_join(community::table.on(community::id.nullable().eq(modlog::target_community_id))) .left_join(instance::table.on(instance::id.nullable().eq(modlog::target_instance_id))) .left_join(community_actions_join) } } impl PaginationCursorConversion for ModlogView { type PaginatedType = Modlog; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.modlog.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let query = modlog::table .select(Self::PaginatedType::as_select()) .filter(modlog::id.eq(cursor.id()?)); let token = query.first(conn).await?; Ok(token) } } #[derive(Default)] /// Querying / filtering the modlog. pub struct ModlogQuery<'a> { pub type_: Option, pub listing_type: Option, pub comment_id: Option, pub post_id: Option, pub community_id: Option, pub hide_modlog_names: Option, pub local_user: Option<&'a LocalUser>, pub mod_person_id: Option, pub target_person_id: Option, pub show_bulk: Option, pub bulk_action_parent_id: Option, pub page_cursor: Option, pub limit: Option, } impl ModlogQuery<'_> { pub async fn list(self, pool: &mut DbPool<'_>) -> LemmyResult> { let limit = limit_fetch(self.limit, None)?; let target_person = aliases::person1.field(person::id); let my_person_id = self.local_user.person_id(); let mut query = ModlogView::joins(my_person_id) .select(ModlogView::as_select()) .limit(limit) .into_boxed(); if let Some(mod_person_id) = self.mod_person_id { query = query.filter(person::id.eq(mod_person_id)); }; if let Some(target_person_id) = self.target_person_id { query = query.filter(target_person.eq(target_person_id)); }; if let Some(community_id) = self.community_id { query = query.filter(community::id.eq(community_id)) } if let Some(post_id) = self.post_id { query = query.filter(post::id.eq(post_id)) } if let Some(comment_id) = self.comment_id { query = query.filter(comment::id.eq(comment_id)) } // `show_bulk`: true => show all entries; false/None => hide bulk child entries. // When bulk_action_parent_id is provided the caller is looking into a bulk // action, so skip null guard if let Some(bulk_action_parent_id) = self.bulk_action_parent_id { query = query.filter(modlog::bulk_action_parent_id.eq(bulk_action_parent_id)) } else if !self.show_bulk.unwrap_or_default() { query = query.filter(modlog::bulk_action_parent_id.is_null()) } if let Some(type_) = self.type_ { query = match type_ { ModlogKindFilter::All => query, ModlogKindFilter::Other(kind) => query.filter(modlog::kind.eq(kind)), }; } query = match self.listing_type.unwrap_or(ListingType::All) { ListingType::All => query, ListingType::Subscribed => query.filter(filter_is_subscribed()), ListingType::Local => query .filter(community::local.eq(true)) .filter(filter_not_unlisted_or_is_subscribed()), ListingType::ModeratorView => { query.filter(community_actions::became_moderator_at.is_not_null()) } ListingType::Suggested => query.filter(filter_suggested_communities()), }; // Sorting by published let paginated_query = ModlogView::paginate(query, &self.page_cursor, SortDirection::Desc, pool, None) .await? .then_order_by(key::published_at) // Tie breaker .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query.load::(conn).await?; let hide_modlog_names = self.hide_modlog_names.unwrap_or_default(); // Map the query results to the enum let out = res .into_iter() .map(|u| u.hide_mod_name(hide_modlog_names)) .collect(); paginate_response(out, limit, self.page_cursor) } } impl ModlogView { /// Hides modlog names by setting the moderator to None. pub fn hide_mod_name(self, hide_modlog_names: bool) -> Self { if hide_modlog_names { Self { moderator: None, ..self } } else { self } } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use lemmy_db_schema::source::{ comment::{Comment, CommentInsertForm}, community::{Community, CommunityInsertForm}, instance::Instance, modlog::{Modlog, ModlogInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, }; use lemmy_db_schema_file::enums::ModlogKind; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { instance: Instance, timmy: Person, sara: Person, jessica: Person, community: Community, community_2: Community, post: Post, post_2: Post, comment: Comment, comment_2: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_rcv"); let timmy = Person::create(pool, &timmy_form).await?; let sara_form = PersonInsertForm::test_form(instance.id, "sara_rcv"); let sara = Person::create(pool, &sara_form).await?; let jessica_form = PersonInsertForm::test_form(instance.id, "jessica_mrv"); let jessica = Person::create(pool, &jessica_form).await?; let community_form = CommunityInsertForm::new( instance.id, "test community crv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let community_form_2 = CommunityInsertForm::new( instance.id, "test community crv 2".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community_2 = Community::create(pool, &community_form_2).await?; let post_form = PostInsertForm::new("A test post crv".into(), timmy.id, community.id); let post = Post::create(pool, &post_form).await?; let new_post_2 = PostInsertForm::new("A test post crv 2".into(), sara.id, community_2.id); let post_2 = Post::create(pool, &new_post_2).await?; // Timmy creates a comment let comment_form = CommentInsertForm::new(timmy.id, post.id, "A test comment rv".into()); let comment = Comment::create(pool, &comment_form, None).await?; // jessica creates a comment let comment_form_2 = CommentInsertForm::new(jessica.id, post_2.id, "A test comment rv 2".into()); let comment_2 = Comment::create(pool, &comment_form_2, None).await?; Ok(Data { instance, timmy, sara, jessica, community, community_2, post, post_2, comment, comment_2, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn admin_types() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let form = ModlogInsertForm::admin_allow_instance(data.timmy.id, data.instance.id, true, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_block_instance(data.timmy.id, data.instance.id, true, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_purge_comment( data.timmy.id, &data.comment, data.community.id, "reason", ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_purge_community(data.timmy.id, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_purge_person(data.timmy.id, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_purge_post(data.timmy.id, data.community.id, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_change_community_visibility(data.timmy.id, data.community.id); Modlog::create(pool, &[form]).await?; // A 2nd mod hide community, but to a different community, and with jessica let form = ModlogInsertForm::mod_change_community_visibility(data.jessica.id, data.community_2.id); Modlog::create(pool, &[form]).await?; let modlog = ModlogQuery::default().list(pool).await?.items; assert_eq!(8, modlog.len()); let v = &modlog[0]; assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind); assert_eq!( Some(data.community_2.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[1]; assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[2]; assert_eq!(ModlogKind::AdminPurgePost, v.modlog.kind); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[3]; assert_eq!(ModlogKind::AdminPurgePerson, v.modlog.kind); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[4]; assert_eq!(ModlogKind::AdminPurgeCommunity, v.modlog.kind); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[5]; assert_eq!(ModlogKind::AdminPurgeComment, v.modlog.kind); assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); // Make sure the report types are correct let v = &modlog[6]; // TODO: why index 2 again? assert_eq!(ModlogKind::AdminBlockInstance, v.modlog.kind); assert_eq!( Some(data.instance.id), v.target_instance.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[7]; assert_eq!(ModlogKind::AdminAllowInstance, v.modlog.kind); assert_eq!( Some(data.instance.id), v.target_instance.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); // Filter by admin let modlog_admin_filter = ModlogQuery { mod_person_id: Some(data.timmy.id), ..Default::default() } .list(pool) .await?; // Only one is jessica assert_eq!(7, modlog_admin_filter.len()); // Filter by community let modlog_community_filter = ModlogQuery { community_id: Some(data.community.id), ..Default::default() } .list(pool) .await?; // Should be 2, and not jessicas assert_eq!(3, modlog_community_filter.len()); // Filter by type let modlog_type_filter = ModlogQuery { type_: Some(ModlogKindFilter::Other( ModlogKind::ModChangeCommunityVisibility, )), ..Default::default() } .list(pool) .await?; // 2 of these, one is jessicas assert_eq!(2, modlog_type_filter.len()); let v = &modlog[0]; assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind); assert_eq!( Some(data.community_2.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[1]; assert_eq!(ModlogKind::ModChangeCommunityVisibility, v.modlog.kind); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn mod_types() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let form = ModlogInsertForm::admin_add(&data.timmy, data.jessica.id, false); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_add_to_community( data.timmy.id, data.community.id, data.jessica.id, false, ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_ban(&data.timmy, data.jessica.id, true, None, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_ban_from_community( data.timmy.id, data.community.id, data.jessica.id, true, None, "reason", ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_feature_post_community(data.timmy.id, &data.post, true); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_feature_post_site(&data.timmy, &data.post, true); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_lock_post(data.timmy.id, &data.post, true, "reason"); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_lock_comment( data.timmy.id, &data.comment, data.community.id, true, "reason", ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_remove_comment( data.timmy.id, &data.comment, data.community.id, true, "reason", None, ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::admin_remove_community( &data.timmy, data.community.id, None, true, "reason", ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_remove_post(data.timmy.id, &data.post, true, "reason", None); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_transfer_community(data.timmy.id, data.community.id, data.jessica.id); Modlog::create(pool, &[form]).await?; // A few extra ones to test different filters let form = ModlogInsertForm::mod_transfer_community(data.jessica.id, data.community_2.id, data.sara.id); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_remove_post(data.jessica.id, &data.post_2, true, "reason", None); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_remove_comment( data.jessica.id, &data.comment_2, data.community_2.id, true, "reason", None, ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_create_comment_warning( data.jessica.id, &data.comment, data.community.id, "reason", ); Modlog::create(pool, &[form]).await?; let form = ModlogInsertForm::mod_create_post_warning(data.jessica.id, &data.post_2, "reason"); Modlog::create(pool, &[form]).await?; // The all view let modlog = ModlogQuery::default().list(pool).await?; assert_eq!(17, modlog.len()); let v = &modlog[0]; assert_eq!(ModlogKind::ModWarnPost, v.modlog.kind); assert_eq!(Some(data.post_2.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!( Some(data.post_2.community_id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!( Some(data.post_2.creator_id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[1]; assert_eq!(ModlogKind::ModWarnComment, v.modlog.kind); assert_eq!( Some(data.comment.id), v.target_comment.as_ref().map(|a| a.id) ); assert_eq!( Some(data.comment.creator_id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[2]; assert_eq!(ModlogKind::ModRemoveComment, v.modlog.kind); assert_eq!( Some(data.comment_2.id), v.target_comment.as_ref().map(|a| a.id) ); assert_eq!( Some(data.jessica.id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[3]; assert_eq!(ModlogKind::ModRemovePost, v.modlog.kind); assert_eq!(Some(data.post_2.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!(Some(data.sara.id), v.target_person.as_ref().map(|a| a.id)); assert_eq!( Some(data.community_2.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[4]; assert_eq!(ModlogKind::ModTransferCommunity, v.modlog.kind); assert_eq!( Some(data.community_2.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.sara.id), v.target_person.as_ref().map(|a| a.id)); assert_eq!(Some(data.jessica.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[5]; assert_eq!(ModlogKind::ModTransferCommunity, v.modlog.kind); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!( Some(data.jessica.id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[6]; assert_eq!(ModlogKind::ModRemovePost, v.modlog.kind); assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!(Some(data.timmy.id), v.target_person.as_ref().map(|a| a.id)); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[7]; assert_eq!(ModlogKind::AdminRemoveCommunity, v.modlog.kind); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[8]; assert_eq!(ModlogKind::ModRemoveComment, v.modlog.kind); assert_eq!( Some(data.comment.id), v.target_comment.as_ref().map(|a| a.id) ); assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); let v = &modlog[9]; assert_eq!(ModlogKind::ModLockComment, v.modlog.kind); assert_eq!( Some(data.comment.id), v.target_comment.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[10]; assert_eq!(ModlogKind::ModLockPost, v.modlog.kind); assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[11]; assert_eq!(ModlogKind::AdminFeaturePostSite, v.modlog.kind); assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[12]; assert_eq!(ModlogKind::ModFeaturePostCommunity, v.modlog.kind); assert_eq!(Some(data.post.id), v.target_post.as_ref().map(|a| a.id)); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[13]; assert_eq!(ModlogKind::ModBanFromCommunity, v.modlog.kind); assert_eq!( Some(data.jessica.id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[14]; assert_eq!(ModlogKind::AdminBan, v.modlog.kind); assert_eq!( Some(data.jessica.id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[15]; assert_eq!(ModlogKind::ModAddToCommunity, v.modlog.kind); assert_eq!( Some(data.jessica.id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!( Some(data.community.id), v.target_community.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); let v = &modlog[16]; assert_eq!(ModlogKind::AdminAdd, v.modlog.kind); assert_eq!( Some(data.jessica.id), v.target_person.as_ref().map(|a| a.id) ); assert_eq!(Some(data.timmy.id), v.moderator.as_ref().map(|a| a.id)); // Filter by moderator let modlog_mod_timmy_filter = ModlogQuery { mod_person_id: Some(data.timmy.id), ..Default::default() } .list(pool) .await?; assert_eq!(12, modlog_mod_timmy_filter.len()); let modlog_mod_jessica_filter = ModlogQuery { mod_person_id: Some(data.jessica.id), ..Default::default() } .list(pool) .await?; assert_eq!(5, modlog_mod_jessica_filter.len()); // Filter by target_person // Gets a little complicated because things aren't directly linked, // you have to go into the item to see who created it. let modlog_modded_timmy_filter = ModlogQuery { target_person_id: Some(data.timmy.id), ..Default::default() } .list(pool) .await?; assert_eq!(5, modlog_modded_timmy_filter.len()); let modlog_modded_jessica_filter = ModlogQuery { target_person_id: Some(data.jessica.id), ..Default::default() } .list(pool) .await?; assert_eq!(6, modlog_modded_jessica_filter.len()); let modlog_modded_sara_filter = ModlogQuery { target_person_id: Some(data.sara.id), ..Default::default() } .list(pool) .await?; assert_eq!(3, modlog_modded_sara_filter.len()); // Filter by community let modlog_community_filter = ModlogQuery { community_id: Some(data.community.id), ..Default::default() } .list(pool) .await?; assert_eq!(11, modlog_community_filter.len()); let modlog_community_2_filter = ModlogQuery { community_id: Some(data.community_2.id), ..Default::default() } .list(pool) .await?; assert_eq!(4, modlog_community_2_filter.len()); // Filter by post let modlog_post_filter = ModlogQuery { post_id: Some(data.post.id), ..Default::default() } .list(pool) .await?; assert_eq!(7, modlog_post_filter.len()); let modlog_post_2_filter = ModlogQuery { post_id: Some(data.post_2.id), ..Default::default() } .list(pool) .await?; assert_eq!(3, modlog_post_2_filter.len()); // Filter by comment let modlog_comment_filter = ModlogQuery { comment_id: Some(data.comment.id), ..Default::default() } .list(pool) .await?; assert_eq!(3, modlog_comment_filter.len()); let modlog_comment_2_filter = ModlogQuery { comment_id: Some(data.comment_2.id), ..Default::default() } .list(pool) .await?; assert_eq!(1, modlog_comment_2_filter.len()); // Filter by type let modlog_type_filter = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemoveComment)), ..Default::default() } .list(pool) .await?; assert_eq!(2, modlog_type_filter.len()); // Assert that the types are correct assert_eq!( ModlogKind::ModRemoveComment, modlog_type_filter[0].modlog.kind, ); assert_eq!( ModlogKind::ModRemoveComment, modlog_type_filter[1].modlog.kind, ); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn hide_modlog_names() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let form = ModlogInsertForm::admin_allow_instance(data.timmy.id, data.instance.id, true, "reason"); Modlog::create(pool, &[form]).await?; let modlog = ModlogQuery::default().list(pool).await?; assert_eq!(1, modlog.len()); assert_eq!(ModlogKind::AdminAllowInstance, modlog[0].modlog.kind); assert_eq!( Some(data.timmy.id), modlog[0].moderator.as_ref().map(|a| a.id) ); // Filter out the names let modlog_hide_names_filter = ModlogQuery { hide_modlog_names: Some(true), ..Default::default() } .list(pool) .await?; assert_eq!(1, modlog_hide_names_filter.len()); assert_eq!( ModlogKind::AdminAllowInstance, modlog_hide_names_filter[0].modlog.kind ); assert!(modlog_hide_names_filter[0].moderator.is_none()); cleanup(data, pool).await?; Ok(()) } /// Verifies that a single (non-bulk) modlog entry has bulk_action_parent_id == None by default. #[tokio::test] #[serial] async fn individual_modlog_is_not_bulk() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let form = ModlogInsertForm::mod_remove_post(data.timmy.id, &data.post, true, "reason", None); Modlog::create(pool, &[form]).await?; let modlog = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), ..Default::default() } .list(pool) .await? .items; assert_eq!(1, modlog.len()); assert!(modlog[0].modlog.bulk_action_parent_id.is_none()); cleanup(data, pool).await?; Ok(()) } /// Verifies bulk entries are linked to their parent and can be queried by parent ID or show_bulk. #[tokio::test] #[serial] async fn bulk_modlog_has_parent_id() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Create a ban entry to serve as the parent let ban_form = ModlogInsertForm::admin_ban(&data.timmy, data.sara.id, true, None, "banning sara"); let ban_action = Modlog::create(pool, &[ban_form]).await?; let parent_id = ban_action[0].id; // Create two bulk post removals linked to the ban let post_form_1 = ModlogInsertForm::mod_remove_post( data.timmy.id, &data.post, true, "bulk remove", Some(parent_id), ); let post_form_2 = ModlogInsertForm::mod_remove_post( data.timmy.id, &data.post_2, true, "bulk remove", Some(parent_id), ); Modlog::create(pool, &[post_form_1, post_form_2]).await?; // Create one individual (non-bulk) post removal for mixed-dataset tests let individual_form = ModlogInsertForm::mod_remove_post(data.timmy.id, &data.post, true, "individual remove", None); Modlog::create(pool, &[individual_form]).await?; // show_bulk: Some(true) now includes bulk and non-bulk (show all) let all_with_show_true = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), show_bulk: Some(true), ..Default::default() } .list(pool) .await? .items; // parent-linked two bulk + one individual = 3 total assert_eq!(3, all_with_show_true.len()); // bulk_action_parent_id filter returns only children of that ban let children = ModlogQuery { bulk_action_parent_id: Some(parent_id), ..Default::default() } .list(pool) .await? .items; assert_eq!(2, children.len()); // show_bulk: Some(false) returns only the non-bulk entry let non_bulk = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), show_bulk: Some(false), ..Default::default() } .list(pool) .await? .items; assert_eq!(1, non_bulk.len()); assert!(non_bulk[0].modlog.bulk_action_parent_id.is_none()); // show_bulk: None behaves like false (hide bulk) and returns only the non-bulk entry let none_behaviour = ModlogQuery { type_: Some(ModlogKindFilter::Other(ModlogKind::ModRemovePost)), ..Default::default() } .list(pool) .await? .items; assert_eq!(1, none_behaviour.len()); cleanup(data, pool).await?; Ok(()) } /// Verifies that bulk_action_parent_id filter isolates children of one parent from another. #[tokio::test] #[serial] async fn bulk_action_parent_id_isolation() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Two separate ban entries as independent parents let ban_form_a = ModlogInsertForm::admin_ban(&data.timmy, data.sara.id, true, None, "ban sara"); let ban_a = Modlog::create(pool, &[ban_form_a]).await?; let parent_a_id = ban_a[0].id; let ban_form_b = ModlogInsertForm::admin_ban(&data.timmy, data.jessica.id, true, None, "ban jessica"); let ban_b = Modlog::create(pool, &[ban_form_b]).await?; let parent_b_id = ban_b[0].id; // Two post removals linked to parent A let post_form_1 = ModlogInsertForm::mod_remove_post( data.timmy.id, &data.post, true, "bulk A", Some(parent_a_id), ); let post_form_2 = ModlogInsertForm::mod_remove_post( data.timmy.id, &data.post_2, true, "bulk A", Some(parent_a_id), ); Modlog::create(pool, &[post_form_1, post_form_2]).await?; // Two comment removals linked to parent B let comment_form_1 = ModlogInsertForm::mod_remove_comment( data.timmy.id, &data.comment, data.community.id, true, "bulk B", Some(parent_b_id), ); let comment_form_2 = ModlogInsertForm::mod_remove_comment( data.timmy.id, &data.comment_2, data.community.id, true, "bulk B", Some(parent_b_id), ); Modlog::create(pool, &[comment_form_1, comment_form_2]).await?; // Filter by parent A let children_of_a = ModlogQuery { bulk_action_parent_id: Some(parent_a_id), ..Default::default() } .list(pool) .await? .items; assert_eq!(2, children_of_a.len()); assert!( children_of_a .iter() .all(|e| e.modlog.bulk_action_parent_id == Some(parent_a_id)) ); // Filter by parent B let children_of_b = ModlogQuery { bulk_action_parent_id: Some(parent_b_id), ..Default::default() } .list(pool) .await? .items; assert_eq!(2, children_of_b.len()); assert!( children_of_b .iter() .all(|e| e.modlog.bulk_action_parent_id == Some(parent_b_id)) ); cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/modlog/src/lib.rs ================================================ use lemmy_db_schema::source::{ comment::Comment, community::Community, instance::Instance, modlog::Modlog, person::Person, post::Post, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{NullableExpressionMethods, Queryable, Selectable, dsl::Nullable}, lemmy_db_schema::{Person1AliasAllColumnsTuple, utils::queries::selects::person1_select}, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export, optional_fields))] #[skip_serializing_none] pub struct ModlogView { #[cfg_attr(feature = "full", diesel(embed))] pub modlog: Modlog, #[cfg_attr(feature = "full", diesel(embed))] pub moderator: Option, #[cfg_attr(feature = "full", diesel( select_expression_type = Nullable, select_expression = person1_select().nullable() ) )] pub target_person: Option, #[cfg_attr(feature = "full", diesel(embed))] pub target_instance: Option, #[cfg_attr(feature = "full", diesel(embed))] pub target_community: Option, #[cfg_attr(feature = "full", diesel(embed))] pub target_post: Option, #[cfg_attr(feature = "full", diesel(embed))] pub target_comment: Option, } ================================================ FILE: crates/db_views/notification/Cargo.toml ================================================ [package] name = "lemmy_db_views_notification" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_db_views_private_message/full", "lemmy_db_views_post/full", "lemmy_db_views_comment/full", "lemmy_db_views_modlog/full", "lemmy_db_views_notification_sql", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_db_views_private_message = { workspace = true } lemmy_db_views_post = { workspace = true } lemmy_db_views_comment = { workspace = true } lemmy_db_views_modlog = { workspace = true } lemmy_db_views_notification_sql = { workspace = true, optional = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } serde_with = { workspace = true } chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_views/notification/src/api.rs ================================================ use lemmy_db_schema::newtypes::NotificationId; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Mark a comment reply as read. pub struct MarkNotificationAsRead { pub notification_id: NotificationId, pub read: bool, } ================================================ FILE: crates/db_views/notification/src/impls.rs ================================================ use crate::{CommentView, NotificationData, NotificationView, NotificationViewInternal}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgExpressionMethods, QueryDsl, SelectableHelper, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ NotificationTypeFilter, newtypes::NotificationId, source::{ notification::{Notification, notification_keys}, person::Person, }, utils::{limit_fetch, queries::filters::filter_blocked}, }; use lemmy_db_schema_file::{ PersonId, schema::{notification, person}, }; use lemmy_db_views_modlog::ModlogView; use lemmy_db_views_notification_sql::notification_joins; use lemmy_db_views_post::PostView; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl NotificationView { /// Gets the number of unread mentions pub async fn get_unread_count( pool: &mut DbPool<'_>, my_person: &Person, show_bot_accounts: bool, ) -> LemmyResult { use diesel::dsl::count; let conn = &mut get_conn(pool).await?; let unread_filter = notification::read.eq(false); let mut query = notification_joins(my_person.id, my_person.instance_id) // Filter for your user .filter(notification::recipient_id.eq(my_person.id)) // Filter unreads .filter(unread_filter) // Don't count replies from blocked users .filter(filter_blocked()) .select(count(notification::id)) .into_boxed(); // These filters need to be kept in sync with the filters in queries().list() if !show_bot_accounts { query = query.filter(person::bot_account.is_distinct_from(true)); } query .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read( pool: &mut DbPool<'_>, id: NotificationId, my_person: &Person, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = notification_joins(my_person.id, my_person.instance_id) .filter(notification::id.eq(id)) .select(NotificationViewInternal::as_select()) .get_result::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; // TODO: should pass this in as param let hide_modlog_names = true; map_to_enum(res, hide_modlog_names, my_person).ok_or(LemmyErrorType::NotFound.into()) } } impl PaginationCursorConversion for NotificationView { type PaginatedType = Notification; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.notification.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let query = notification::table .select(Self::PaginatedType::as_select()) .filter(notification::id.eq(cursor.id()?)); let token = query.first(conn).await?; Ok(token) } } #[derive(Default)] pub struct NotificationQuery { pub type_: Option, pub unread_only: Option, pub show_bot_accounts: Option, pub hide_modlog_names: Option, pub creator_id: Option, pub page_cursor: Option, pub limit: Option, pub no_limit: Option, } impl NotificationQuery { pub fn list( self, pool: &mut DbPool<'_>, my_person: &Person, ) -> impl Future>> { Box::pin(async move { let limit = limit_fetch(self.limit, self.no_limit)?; let mut query = notification_joins(my_person.id, my_person.instance_id) .select(NotificationViewInternal::as_select()) .limit(limit) .into_boxed(); // Filters if self.unread_only.unwrap_or_default() { query = query // The recipient filter (IE only show replies to you) .filter(notification::recipient_id.eq(my_person.id)) .filter(notification::read.eq(false)); } else { // A special case for private messages: show messages FROM you also. // Use a not-null checks to catch the others query = query.filter( notification::recipient_id.eq(my_person.id).or( notification::private_message_id.is_not_null().and( notification::recipient_id .eq(my_person.id) .or(person::id.eq(my_person.id)), ), ), ); } if !self.show_bot_accounts.unwrap_or_default() { query = query.filter(person::bot_account.is_distinct_from(true)); }; // Dont show replies from blocked users or instances query = query.filter(filter_blocked()); if let Some(type_) = self.type_ { query = match type_ { NotificationTypeFilter::All => query, NotificationTypeFilter::Other(kind) => query.filter(notification::kind.eq(kind)), } } if let Some(creator_id) = self.creator_id { query = query.filter(notification::creator_id.eq(creator_id)); } // Sorting by published let paginated_query = Box::pin(NotificationView::paginate( query, &self.page_cursor, SortDirection::Desc, pool, None, )) .await? .then_order_by(notification_keys::published_at) // Tie breaker .then_order_by(notification_keys::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await?; let hide_modlog_names = self.hide_modlog_names.unwrap_or_default(); let res = res .into_iter() .filter_map(|r| map_to_enum(r, hide_modlog_names, my_person)) .collect(); paginate_response(res, limit, self.page_cursor) }) } } fn map_to_enum( v: NotificationViewInternal, hide_modlog_name: bool, my_person: &Person, ) -> Option { let data = if let (Some(modlog), Some(creator)) = (v.modlog.clone(), v.creator.clone()) { let m = ModlogView { modlog, moderator: Some(creator), target_person: Some(v.recipient), target_community: v.community, target_post: v.post, target_comment: v.comment, target_instance: v.instance, }; let m = m.hide_mod_name(hide_modlog_name); NotificationData::ModAction(m) } else if let (Some(comment), Some(post), Some(community), Some(creator)) = ( v.comment.clone(), v.post.clone(), v.community.clone(), v.creator.clone(), ) { NotificationData::Comment(CommentView { comment, post, community, creator, community_actions: v.community_actions, person_actions: v.person_actions, comment_actions: v.comment_actions, tags: v.tags, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, creator_is_admin: v.creator_is_admin, can_mod: v.can_mod, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_is_moderator: v.creator_is_moderator, }) } else if let (Some(post), Some(community), Some(creator)) = (v.post.clone(), v.community.clone(), v.creator.clone()) { NotificationData::Post(PostView { post, community, creator, image_details: v.image_details, community_actions: v.community_actions, post_actions: v.post_actions, person_actions: v.person_actions, tags: v.tags, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, creator_is_admin: v.creator_is_admin, can_mod: v.can_mod, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_is_moderator: v.creator_is_moderator, }) } else if let (Some(mut private_message), Some(creator)) = (v.private_message.clone(), v.creator.clone()) { private_message.clear_deleted_by_recipient(Some(my_person)); NotificationData::PrivateMessage(PrivateMessageView { private_message, creator, recipient: v.recipient, }) } else { return None; }; let notification = if hide_modlog_name { // Set the creator_id to zero if you're hiding modlog names. // The mod view hiding is above. Notification { creator_id: PersonId(0), ..v.notification } } else { v.notification }; Some(NotificationView { notification, data }) } ================================================ FILE: crates/db_views/notification/src/lib.rs ================================================ use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use lemmy_db_schema::source::{ comment::{Comment, CommentActions}, community::{Community, CommunityActions}, community_tag::CommunityTagsView, images::ImageDetails, instance::Instance, modlog::Modlog, person::{Person, PersonActions}, post::{Post, PostActions}, private_message::PrivateMessage, }; use lemmy_db_schema::{NotificationTypeFilter, source::notification::Notification}; use lemmy_db_schema_file::PersonId; use lemmy_db_views_comment::CommentView; use lemmy_db_views_modlog::ModlogView; use lemmy_db_views_post::PostView; use lemmy_db_views_private_message::PrivateMessageView; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::{ Person1AliasAllColumnsTuple, utils::queries::selects::{ CreatorLocalHomeBanExpiresType, creator_is_admin, creator_is_moderator, creator_local_home_ban_expires, creator_local_home_banned, local_user_can_mod, }, utils::queries::selects::{ creator_ban_expires_from_community, creator_banned_from_community, person1_select, post_community_tags_fragment, }, }, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[cfg(test)] #[expect(clippy::indexing_slicing)] pub mod tests; #[cfg(feature = "full")] #[derive(Clone, Debug, Queryable, Selectable)] #[diesel(check_for_backend(diesel::pg::Pg))] struct NotificationViewInternal { #[diesel(embed)] notification: Notification, #[diesel(embed)] private_message: Option, #[diesel(embed)] comment: Option, #[diesel(embed)] post: Option, #[diesel(embed)] community: Option, #[diesel(embed)] instance: Option, #[diesel(embed)] creator: Option, #[diesel( select_expression_type = Person1AliasAllColumnsTuple, select_expression = person1_select() )] recipient: Person, #[diesel(embed)] image_details: Option, #[diesel(embed)] community_actions: Option, #[diesel(embed)] post_actions: Option, #[diesel(embed)] person_actions: Option, #[diesel(embed)] comment_actions: Option, #[diesel(embed)] modlog: Option, #[diesel(select_expression = post_community_tags_fragment())] tags: CommunityTagsView, #[diesel(select_expression = creator_is_admin())] creator_is_admin: bool, #[diesel(select_expression = local_user_can_mod())] can_mod: bool, #[diesel(select_expression = creator_local_home_banned())] creator_banned: bool, #[diesel( select_expression_type = CreatorLocalHomeBanExpiresType, select_expression = creator_local_home_ban_expires() )] pub creator_ban_expires_at: Option>, #[diesel(select_expression = creator_is_moderator())] creator_is_moderator: bool, #[diesel(select_expression = creator_banned_from_community())] creator_banned_from_community: bool, #[diesel(select_expression = creator_ban_expires_from_community())] pub creator_community_ban_expires_at: Option>, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] pub struct NotificationView { pub notification: Notification, pub data: NotificationData, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] #[serde(tag = "type_", rename_all = "snake_case")] pub enum NotificationData { Comment(CommentView), Post(PostView), PrivateMessage(PrivateMessageView), ModAction(ModlogView), } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Get your inbox (replies, comment mentions, post mentions, and messages) pub struct ListNotifications { pub type_: Option, pub unread_only: Option, pub creator_id: Option, pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/notification/src/tests.rs ================================================ use crate::{NotificationData, NotificationView, impls::NotificationQuery}; use lemmy_db_schema::{ assert_length, source::{ comment::{Comment, CommentInsertForm}, community::{Community, CommunityInsertForm}, instance::Instance, modlog::{Modlog, ModlogInsertForm}, notification::{Notification, NotificationInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, private_message::{PrivateMessage, PrivateMessageInsertForm}, }, }; use lemmy_db_schema_file::enums::NotificationType; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { alice: Person, bob: Person, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let alice_form = PersonInsertForm::test_form(instance.id, "alice2"); let alice = Person::create(pool, &alice_form).await?; let bob_form = PersonInsertForm::test_form(instance.id, "bob2"); let bob = Person::create(pool, &bob_form).await?; Ok(Data { alice, bob }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.bob.instance_id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_private_message() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let count = NotificationView::get_unread_count(pool, &data.alice, false).await?; assert_eq!(0, count); let notifs = NotificationQuery::default().list(pool, &data.alice).await?; assert_length!(0, notifs); let form = &PrivateMessageInsertForm::new(data.bob.id, data.alice.id, "my message".to_string()); let pm = PrivateMessage::create(pool, form).await?; let form = NotificationInsertForm::new_private_message(&pm); Notification::create(pool, &[form]).await?; let count = NotificationView::get_unread_count(pool, &data.alice, false).await?; assert_eq!(1, count); let notifs = NotificationQuery::default().list(pool, &data.alice).await?; assert_length!(1, notifs); assert_eq!(Some(pm.id), notifs[0].notification.private_message_id); assert_eq!(pm.recipient_id, notifs[0].notification.recipient_id); assert!(!notifs[0].notification.read); let NotificationData::PrivateMessage(notif_pm) = ¬ifs[0].data else { panic!(); }; assert_eq!(pm, notif_pm.private_message); cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_post() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let count = NotificationView::get_unread_count(pool, &data.alice, false).await?; assert_eq!(0, count); let notifs = NotificationQuery::default().list(pool, &data.alice).await?; assert_length!(0, notifs); let community_form = CommunityInsertForm::new( data.alice.instance_id, "comm".to_string(), "title".to_string(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let post_form = PostInsertForm::new("title".to_string(), data.bob.id, community.id); let post = Post::create(pool, &post_form).await?; let notif_form = NotificationInsertForm::new_post(&post, data.alice.id, NotificationType::Subscribed); Notification::create(pool, &[notif_form]).await?; let count = NotificationView::get_unread_count(pool, &data.alice, false).await?; assert_eq!(1, count); let notifs1 = NotificationQuery::default().list(pool, &data.alice).await?; assert_length!(1, notifs1); assert_eq!(Some(post.id), notifs1[0].notification.post_id); assert!(!notifs1[0].notification.read); let NotificationData::Post(notif_post) = ¬ifs1[0].data else { panic!(); }; assert_eq!(post, notif_post.post); Notification::mark_read_by_id_and_person(pool, notifs1[0].notification.id, data.alice.id, true) .await?; let count = NotificationView::get_unread_count(pool, &data.alice, false).await?; assert_eq!(0, count); // create a notification entry for removed post let mod_remove_post_form = ModlogInsertForm::mod_remove_post(data.bob.id, &post, true, "reason", None); let mod_remove_post = &Modlog::create(pool, &[mod_remove_post_form]).await?[0]; let notif_form = NotificationInsertForm::new_mod_action(mod_remove_post.id, data.alice.id, data.bob.id); Notification::create(pool, &[notif_form]).await?; let count = NotificationView::get_unread_count(pool, &data.alice, false).await?; assert_eq!(1, count); let notifs2 = NotificationQuery { unread_only: Some(true), ..Default::default() } .list(pool, &data.alice) .await?; assert_length!(1, notifs2); assert_eq!(Some(mod_remove_post.id), notifs2[0].notification.modlog_id); assert!(!notifs2[0].notification.read); let NotificationData::ModAction(notif_remove_post) = ¬ifs2[0].data else { panic!(); }; assert_eq!(mod_remove_post, ¬if_remove_post.modlog); Notification::delete(pool, notifs1[0].notification.id).await?; Notification::delete(pool, notifs2[0].notification.id).await?; cleanup(data, pool).await } #[tokio::test] #[serial] async fn test_modlog() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // create a community and post let form = CommunityInsertForm::new( data.alice.instance_id, "test".to_string(), "test".to_string(), String::new(), ); let community = Community::create(pool, &form).await?; let form = PostInsertForm { ..PostInsertForm::new("123".to_string(), data.bob.id, community.id) }; let post = Post::create(pool, &form).await?; let form = CommentInsertForm { removed: Some(true), ..CommentInsertForm::new(data.bob.id, post.id, String::new()) }; let comment = Comment::create(pool, &form, None).await?; // remove the comment and check notifs let form = ModlogInsertForm::mod_remove_comment( data.alice.id, &comment, community.id, true, "rule 1", None, ); let modlog = &Modlog::create(pool, &[form]).await?[0]; let form = NotificationInsertForm::new_mod_action(modlog.id, data.bob.id, data.alice.id); let notification = &Notification::create(pool, &[form]).await?[0]; let notifs = NotificationQuery::default().list(pool, &data.bob).await?; assert_length!(1, notifs); let NotificationData::ModAction(m) = ¬ifs[0].data else { panic!(); }; assert_eq!(notification, ¬ifs[0].notification); assert_eq!(modlog, &m.modlog); assert_eq!(Some(data.alice.id), m.moderator.as_ref().map(|m| m.id)); assert_eq!(Some(data.bob.id), m.target_person.as_ref().map(|p| p.id)); assert_eq!(Some(comment.id), m.target_comment.as_ref().map(|c| c.id)); cleanup(data, pool).await } ================================================ FILE: crates/db_views/notification_sql/Cargo.toml ================================================ [package] name = "lemmy_db_views_notification_sql" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true publish = false [lib] doctest = false test = false [lints] workspace = true [dependencies] lemmy_db_schema_file = { workspace = true, features = ["full"] } diesel = { workspace = true } ================================================ FILE: crates/db_views/notification_sql/src/lib.rs ================================================ use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, dsl::not, }; use lemmy_db_schema_file::{ InstanceId, PersonId, aliases, joins::{ creator_community_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, creator_local_user_admin_join, image_details_join, my_comment_actions_join, my_community_actions_join, my_instance_communities_actions_join, my_instance_persons_actions_join_1, my_local_user_admin_join, my_person_actions_join, my_post_actions_join, }, schema::{comment, community, instance, modlog, notification, person, post, private_message}, }; #[diesel::dsl::auto_type(no_type_alias)] pub fn notification_joins(person_id: PersonId, instance_id: InstanceId) -> _ { let item_creator_join = person::table.on(notification::creator_id.eq(person::id)); // No need to join on `modlog::target_person_id` as it is identical to // `notification::recipient_id`. let recipient_person = aliases::person1.field(person::id); let recipient_join = aliases::person1.on(notification::recipient_id.eq(recipient_person)); let comment_join = comment::table.on( notification::comment_id .eq(comment::id.nullable()) // Filter out the deleted / removed .and(not(comment::deleted)) .and(not(comment::removed)) .or(modlog::target_comment_id.eq(comment::id.nullable())), ); let post_join = post::table.on( notification::post_id .eq(post::id.nullable()) .or(comment::post_id.eq(post::id)) // Filter out the deleted / removed .and(not(post::deleted)) .and(not(post::removed)) .or(modlog::target_post_id.eq(post::id.nullable())), ); let community_join = community::table.on( post::community_id .eq(community::id) .or(modlog::target_community_id.eq(community::id.nullable())), ); let private_message_join = private_message::table.on( notification::private_message_id .eq(private_message::id.nullable()) // Filter out the deleted / removed .and(not(private_message::deleted)) // Also hide messages deleted by the recipient, but only for them .and(not( private_message::deleted_by_recipient.and(recipient_person.eq(person_id)), )) .and(not(private_message::removed)), ); let instance_join = instance::table.on(modlog::target_instance_id.eq(instance::id.nullable())); let my_community_actions_join: my_community_actions_join = my_community_actions_join(Some(person_id)); let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(person_id)); let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(Some(person_id)); let my_instance_communities_actions_join: my_instance_communities_actions_join = my_instance_communities_actions_join(Some(person_id)); let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 = my_instance_persons_actions_join_1(Some(person_id)); let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(person_id)); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(instance_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(Some(person_id)); // Note: avoid adding any more joins here as it will significantly slow down compilation. notification::table .left_join(modlog::table) .left_join(comment_join) .left_join(post_join) .left_join(community_join) .left_join(instance_join) .left_join(image_details_join()) .inner_join(item_creator_join) .inner_join(recipient_join) // The private message join must come after recipient, as it uses it to filter out deleted by // recipient. .left_join(private_message_join) .left_join(creator_community_actions_join()) .left_join(creator_local_user_admin_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(my_local_user_admin_join) .left_join(my_community_actions_join) .left_join(my_instance_communities_actions_join) .left_join(my_instance_persons_actions_join_1) .left_join(my_post_actions_join) .left_join(my_person_actions_join) .left_join(my_comment_actions_join) } ================================================ FILE: crates/db_views/person/Cargo.toml ================================================ [package] name = "lemmy_db_views_person" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_db_views_community_moderator/full", "lemmy_diesel_utils/full", "lemmy_db_views_community/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_views_community_moderator/ts-rs", "lemmy_db_views_community/ts-rs", ] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_db_views_community_moderator = { workspace = true } lemmy_diesel_utils = { workspace = true } lemmy_db_views_community = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } chrono = { workspace = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_views/person/src/api.rs ================================================ use crate::PersonView; use lemmy_db_schema::source::site::Site; use lemmy_db_schema_file::PersonId; use lemmy_db_views_community::MultiCommunityView; use lemmy_db_views_community_moderator::CommunityModeratorView; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Adds an admin to a site. pub struct AddAdmin { pub person_id: PersonId, pub added: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The response of current admins. pub struct AddAdminResponse { pub admins: Vec, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Ban a person from the site. pub struct BanPerson { pub person_id: PersonId, pub ban: bool, /// Optionally remove or restore all their data. Useful for new troll accounts. /// If ban is true, then this means remove. If ban is false, it means restore. pub remove_or_restore_data: Option, pub reason: String, /// A time that the ban will expire, in unix epoch seconds. /// /// An i64 unix timestamp is used for a simpler API client implementation. pub expires_at: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Block a person. pub struct BlockPerson { pub person_id: PersonId, pub block: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A person response for actions done to a person. pub struct PersonResponse { pub person_view: PersonView, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets a person's details. /// /// Either person_id, or username are required. pub struct GetPersonDetails { pub person_id: Option, /// Example: dessalines , or dessalines@xyz.tld pub username: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A person's details response. pub struct GetPersonDetailsResponse { pub person_view: PersonView, pub site: Option, pub moderates: Vec, pub multi_communities_created: Vec, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Purges a person from the database. This will delete all content attached to that person. pub struct PurgePerson { pub person_id: PersonId, pub reason: String, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Make a note for a person. /// /// An empty string deletes the note. pub struct NotePerson { pub person_id: PersonId, pub note: String, } ================================================ FILE: crates/db_views/person/src/impls.rs ================================================ use crate::PersonView; use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; use lemmy_db_schema::source::person::Person; use lemmy_db_schema_file::{ InstanceId, PersonId, joins::{ creator_home_instance_actions_join, creator_local_instance_actions_join, my_person_actions_join, }, schema::{local_user, person}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{CursorData, PaginationCursorConversion}, traits::Crud, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl PaginationCursorConversion for PersonView { type PaginatedType = Person; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.person.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Person::read(pool, PersonId(cursor.id()?)).await } } impl PersonView { #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: Option, local_instance_id: InstanceId) -> _ { let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id); person::table .left_join(local_user::table) .left_join(my_person_actions_join) .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) } pub async fn read( pool: &mut DbPool<'_>, person_id: PersonId, my_person_id: Option, local_instance_id: InstanceId, is_admin: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut query = Self::joins(my_person_id, local_instance_id) .filter(person::id.eq(person_id)) .select(Self::as_select()) .into_boxed(); if !is_admin { query = query.filter(person::deleted.eq(false)) } query .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn list_admins( my_person_id: Option, local_instance_id: InstanceId, pool: &mut DbPool<'_>, ) -> LemmyResult> { let conn = &mut get_conn(pool).await?; Self::joins(my_person_id, local_instance_id) .filter(person::deleted.eq(false)) .filter(local_user::admin) // Order by admin created date (ie old) .then_order_by(person::published_at.asc()) // Tie breaker .then_order_by(person::id) .select(Self::as_select()) .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use lemmy_db_schema::{ assert_length, source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, person::{Person, PersonActions, PersonInsertForm, PersonNoteForm, PersonUpdateForm}, }, }; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { alice: Person, alice_local_user: LocalUser, bob: Person, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let alice_form = PersonInsertForm { local: Some(true), ..PersonInsertForm::test_form(instance.id, "alice") }; let alice = Person::create(pool, &alice_form).await?; let alice_local_user_form = LocalUserInsertForm::test_form(alice.id); let alice_local_user = LocalUser::create(pool, &alice_local_user_form, vec![]).await?; let bob_form = PersonInsertForm { bot_account: Some(true), local: Some(false), ..PersonInsertForm::test_form(instance.id, "bob") }; let bob = Person::create(pool, &bob_form).await?; Ok(Data { alice, alice_local_user, bob, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.bob.instance_id).await?; Ok(()) } #[tokio::test] #[serial] async fn exclude_deleted() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; Person::update( pool, data.alice.id, &PersonUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; let read = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, false).await; assert!(read.is_err()); // only admin can view deleted users let read = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, true).await; assert!(read.is_ok()); cleanup(data, pool).await } #[tokio::test] #[serial] async fn list_admins() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; LocalUser::update( pool, data.alice_local_user.id, &LocalUserUpdateForm { admin: Some(true), ..Default::default() }, ) .await?; let list = PersonView::list_admins(None, data.alice.instance_id, pool).await?; assert_length!(1, list); assert_eq!(list[0].person.id, data.alice.id); let is_admin = PersonView::read(pool, data.alice.id, None, data.alice.instance_id, false) .await? .is_admin; assert!(is_admin); let is_admin = PersonView::read(pool, data.bob.id, None, data.alice.instance_id, false) .await? .is_admin; assert!(!is_admin); cleanup(data, pool).await } #[tokio::test] #[serial] async fn note() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let note_str = "Bob hates cats."; let note_form = PersonNoteForm::new(data.alice.id, data.bob.id, note_str.to_string()); let inserted_note = PersonActions::note(pool, ¬e_form).await?; assert_eq!(Some(note_str.to_string()), inserted_note.note); let read = PersonView::read( pool, data.bob.id, Some(data.alice.id), data.alice.instance_id, false, ) .await?; assert!( read .person_actions .is_some_and(|t| t.note == Some(note_str.to_string()) && t.noted_at.is_some()) ); cleanup(data, pool).await } } ================================================ FILE: crates/db_views/person/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::source::person::{Person, PersonActions}; use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] use { diesel::{NullableExpressionMethods, Queryable, Selectable, helper_types::Nullable}, lemmy_db_schema::utils::queries::selects::{ CreatorLocalHomeBanExpiresType, creator_local_home_ban_expires, creator_local_home_banned, }, lemmy_db_schema_file::schema::local_user, lemmy_diesel_utils::utils::functions::coalesce, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A person view. pub struct PersonView { #[cfg_attr(feature = "full", diesel(embed))] pub person: Person, #[cfg_attr(feature = "full", diesel( select_expression_type = coalesce, bool>, select_expression = coalesce(local_user::admin.nullable(), false) ) )] pub is_admin: bool, #[cfg_attr(feature = "full", diesel(embed))] pub person_actions: Option, #[cfg_attr(feature = "full", diesel( select_expression = creator_local_home_banned() ) )] pub banned: bool, #[cfg_attr(feature = "full", diesel( select_expression_type = CreatorLocalHomeBanExpiresType, select_expression = creator_local_home_ban_expires() ) )] pub ban_expires_at: Option>, } ================================================ FILE: crates/db_views/person_content_combined/Cargo.toml ================================================ [package] name = "lemmy_db_views_person_content_combined" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_views_post_comment_combined/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs", "lemmy_db_views_post_comment_combined/ts-rs", ] [dependencies] lemmy_db_views_post_comment_combined = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } derive-new = { workspace = true } serde_with = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/person_content_combined/src/api.rs ================================================ use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets your hidden posts. pub struct ListPersonHidden { pub page_cursor: Option, pub limit: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets your read posts. pub struct ListPersonRead { pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/person_content_combined/src/impls.rs ================================================ use crate::LocalUserView; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, SelectableHelper, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ self, PersonContentType, impls::local_user::LocalUserOptionHelper, source::combined::person_content::{PersonContentCombined, person_content_combined_keys as key}, traits::InternalToCombinedView, utils::limit_fetch, }; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::{CommunityFollowerState, CommunityVisibility}, joins::{ community_join, creator_community_actions_join, creator_community_instance_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, creator_local_user_admin_join, image_details_join, my_comment_actions_join, my_community_actions_join, my_local_user_admin_join, my_person_actions_join, my_post_actions_join, }, schema::{comment, community, community_actions, person, person_content_combined, post}, }; use lemmy_db_views_post_comment_combined::{ PostCommentCombinedView, PostCommentCombinedViewInternal, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] struct PostCommentCombinedViewWrapper(PostCommentCombinedView); impl PaginationCursorConversion for PostCommentCombinedViewWrapper { type PaginatedType = PersonContentCombined; fn to_cursor(&self) -> CursorData { let (prefix, id) = match &self.0 { PostCommentCombinedView::Comment(v) => ('C', v.comment.id.0), PostCommentCombinedView::Post(v) => ('P', v.post.id.0), }; CursorData::new_with_prefix(prefix, id) } async fn from_cursor( data: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut query = person_content_combined::table .select(Self::PaginatedType::as_select()) .into_boxed(); let (prefix, id) = data.id_and_prefix()?; query = match prefix { 'C' => query.filter(person_content_combined::comment_id.eq(id)), 'P' => query.filter(person_content_combined::post_id.eq(id)), _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()), }; let token = query.first(conn).await?; Ok(token) } } #[derive(derive_new::new)] pub struct PersonContentCombinedQuery { pub creator_id: PersonId, #[new(default)] pub type_: Option, #[new(default)] pub page_cursor: Option, #[new(default)] pub limit: Option, #[new(default)] pub no_limit: Option, } impl PersonContentCombinedQuery { #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: Option, local_instance_id: InstanceId) -> _ { let comment_join = comment::table.on(person_content_combined::comment_id.eq(comment::id.nullable())); let post_join = post::table.on( person_content_combined::post_id .eq(post::id.nullable()) .or(comment::post_id.eq(post::id)), ); let item_creator_join = person::table.on(person_content_combined::creator_id.eq(person::id)); let my_community_actions_join: my_community_actions_join = my_community_actions_join(my_person_id); let my_post_actions_join: my_post_actions_join = my_post_actions_join(my_person_id); let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(my_person_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id); let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); person_content_combined::table .left_join(comment_join) .inner_join(post_join) .inner_join(item_creator_join) .inner_join(community_join()) .left_join(image_details_join()) .left_join(creator_community_actions_join()) .left_join(creator_local_user_admin_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_community_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(my_local_user_admin_join) .left_join(my_community_actions_join) .left_join(my_post_actions_join) .left_join(my_person_actions_join) .left_join(my_comment_actions_join) } pub async fn list( self, pool: &mut DbPool<'_>, user: Option<&LocalUserView>, local_instance_id: InstanceId, ) -> LemmyResult> { let my_local_user = user.as_ref().map(|u| &u.local_user); let my_person_id = my_local_user.person_id(); let limit = limit_fetch(self.limit, self.no_limit)?; // Notes: since the post_id and comment_id are optional columns, // many joins must use an OR condition. // For example, the creator must be the person table joined to either: // - post.creator_id // - comment.creator_id let mut query = Self::joins(my_person_id, local_instance_id) // The creator id filter .filter(person_content_combined::creator_id.eq(self.creator_id)) .select(PostCommentCombinedViewInternal::as_select()) .limit(limit) .into_boxed(); if let Some(type_) = self.type_ { query = match type_ { PersonContentType::All => query, PersonContentType::Comments => { query.filter(person_content_combined::comment_id.is_not_null()) } PersonContentType::Posts => query.filter(person_content_combined::post_id.is_not_null()), } } // Check permissions to view private community content. // Specifically, if the community is private then only accepted followers may view its // content, otherwise it is filtered out. Admins can view private community content // without restriction. if !my_local_user.is_admin() { query = query.filter( community::visibility .ne(CommunityVisibility::Private) .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), ); } // Sorting by published let paginated_query = PostCommentCombinedViewWrapper::paginate( query, &self.page_cursor, SortDirection::Desc, pool, None, ) .await? .then_order_by(key::published_at) // Tie breaker .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await?; // Map the query results to the enum let out = res .into_iter() .filter_map(InternalToCombinedView::map_to_enum) .map(PostCommentCombinedViewWrapper) .collect(); let res = paginate_response(out, limit, self.page_cursor)?; Ok(PagedResponse { items: res.items.into_iter().map(|i| i.0).collect(), next_page: res.next_page, prev_page: res.prev_page, }) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::impls::PersonContentCombinedQuery; use lemmy_db_schema::{ source::{ comment::{Comment, CommentInsertForm}, community::{Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm}, instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, }, traits::Followable, }; use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post_comment_combined::PostCommentCombinedView; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { instance: Instance, private_community: Community, timmy: Person, timmy_view: LocalUserView, sara: Person, timmy_post: Post, timmy_post_2: Post, sara_post: Post, timmy_comment: Comment, sara_comment: Comment, sara_comment_2: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); let timmy = Person::create(pool, &timmy_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id); let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let timmy_view = LocalUserView { local_user: timmy_local_user, person: timmy.clone(), banned: false, ban_expires_at: None, }; let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); let sara = Person::create(pool, &sara_form).await?; let community_form = CommunityInsertForm::new( instance.id, "test community pcv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let private_community_form = CommunityInsertForm { visibility: Some(CommunityVisibility::Private), ..CommunityInsertForm::new( instance.id, "private community pcv".to_string(), "nada".to_owned(), "pubkey".to_string(), ) }; let private_community = Community::create(pool, &private_community_form).await?; let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); let timmy_post = Post::create(pool, &timmy_post_form).await?; let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community.id); let sara_post = Post::create(pool, &sara_post_form).await?; let timmy_private_comm_post_form = PostInsertForm::new( "timmy private post prv 2".into(), timmy.id, private_community.id, ); let timmy_private_comm_post = Post::create(pool, &timmy_private_comm_post_form).await?; let timmy_comment_form = CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; let sara_comment_form = CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; let sara_comment_form_2 = CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; let timmy_private_comm_comment_form = CommentInsertForm::new( timmy.id, timmy_private_comm_post.id, "timmy private comment prv".into(), ); let _timmy_private_comm_comment = Comment::create(pool, &timmy_private_comm_comment_form, None).await?; Ok(Data { instance, private_community, timmy, timmy_view, sara, timmy_post, timmy_post_2, sara_post, timmy_comment, sara_comment, sara_comment_2, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn combined() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Do a batch read of timmy let timmy_content = PersonContentCombinedQuery::new(data.timmy.id) .list(pool, None, data.instance.id) .await?; assert_eq!(3, timmy_content.len()); // Make sure the types are correct if let PostCommentCombinedView::Comment(v) = &timmy_content[0] { assert_eq!(data.timmy_comment.id, v.comment.id); assert_eq!(data.timmy.id, v.creator.id); } else { panic!("wrong type"); } if let PostCommentCombinedView::Post(v) = &timmy_content[1] { assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); } else { panic!("wrong type"); } if let PostCommentCombinedView::Post(v) = &timmy_content[2] { assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); } else { panic!("wrong type"); } // Do a batch read of sara let sara_content = PersonContentCombinedQuery::new(data.sara.id) .list(pool, None, data.instance.id) .await?; assert_eq!(3, sara_content.len()); // Make sure the report types are correct if let PostCommentCombinedView::Comment(v) = &sara_content[0] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.sara.id, v.creator.id); // This one was to timmy_post_2 assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); } else { panic!("wrong type"); } if let PostCommentCombinedView::Comment(v) = &sara_content[1] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara.id, v.creator.id); assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); } else { panic!("wrong type"); } if let PostCommentCombinedView::Post(v) = &sara_content[2] { assert_eq!(data.sara_post.id, v.post.id); assert_eq!(data.sara.id, v.post.creator_id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn private_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Make sure timmy can't see private content let timmy_content = PersonContentCombinedQuery::new(data.timmy.id) .list(pool, Some(&data.timmy_view), data.instance.id) .await?; assert_eq!(3, timmy_content.len()); // Approve timmy to the community let follow_form = CommunityFollowerForm::new( data.private_community.id, data.timmy.id, CommunityFollowerState::ApprovalRequired, ); CommunityActions::follow(pool, &follow_form).await?; CommunityActions::approve_private_community_follower( pool, data.private_community.id, data.timmy.id, data.sara.id, CommunityFollowerState::Accepted, ) .await?; // Make sure timmy can now see the content let timmy_content_after_approved = PersonContentCombinedQuery::new(data.timmy.id) .list(pool, Some(&data.timmy_view), data.instance.id) .await?; assert_eq!(5, timmy_content_after_approved.len()); cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/person_content_combined/src/lib.rs ================================================ use lemmy_db_schema::PersonContentType; use lemmy_db_schema_file::PersonId; #[cfg(feature = "full")] use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets a person's content (posts and comments) /// /// Either person_id, or username are required. pub struct ListPersonContent { pub type_: Option, pub person_id: Option, /// Example: dessalines , or dessalines@xyz.tld pub username: Option, pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/person_liked_combined/Cargo.toml ================================================ [package] name = "lemmy_db_views_person_liked_combined" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", "lemmy_db_views_post_comment_combined/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_views_post_comment_combined/ts-rs", ] [dependencies] lemmy_db_views_post_comment_combined = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/person_liked_combined/src/impls.rs ================================================ use crate::{LocalUserView, Serialize}; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, SelectableHelper, dsl::not, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ LikeType, PersonContentType, source::combined::person_liked::{PersonLikedCombined, person_liked_combined_keys as key}, traits::InternalToCombinedView, utils::limit_fetch, }; use lemmy_db_schema_file::{ InstanceId, PersonId, joins::{ community_join, creator_community_actions_join, creator_community_instance_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, creator_local_user_admin_join, image_details_join, my_comment_actions_join, my_community_actions_join, my_local_user_admin_join, my_person_actions_join, my_post_actions_join, }, schema::{comment, person, person_liked_combined, post}, }; use lemmy_db_views_post_comment_combined::{ PostCommentCombinedView, PostCommentCombinedViewInternal, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::Deserialize; #[derive(Default)] pub struct PersonLikedCombinedQuery { pub type_: Option, pub like_type: Option, pub page_cursor: Option, pub limit: Option, pub no_limit: Option, } #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] struct PostCommentCombinedViewWrapper(PostCommentCombinedView); impl PaginationCursorConversion for PostCommentCombinedViewWrapper { type PaginatedType = PersonLikedCombined; fn to_cursor(&self) -> CursorData { let (prefix, id) = match &self.0 { PostCommentCombinedView::Comment(v) => ('C', v.comment.id.0), PostCommentCombinedView::Post(v) => ('P', v.post.id.0), }; CursorData::new_with_prefix(prefix, id) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let (prefix, id) = cursor.id_and_prefix()?; let mut query = person_liked_combined::table .select(Self::PaginatedType::as_select()) .into_boxed(); query = match prefix { 'C' => query.filter(person_liked_combined::comment_id.eq(id)), 'P' => query.filter(person_liked_combined::post_id.eq(id)), _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()), }; let token = query.first(conn).await?; Ok(token) } } impl PersonLikedCombinedQuery { #[diesel::dsl::auto_type(no_type_alias)] pub(crate) fn joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ { let comment_join = comment::table.on(person_liked_combined::comment_id.eq(comment::id.nullable())); let post_join = post::table.on( person_liked_combined::post_id .eq(post::id.nullable()) .or(comment::post_id.eq(post::id)), ); let item_creator_join = person::table.on(person_liked_combined::creator_id.eq(person::id)); let my_community_actions_join: my_community_actions_join = my_community_actions_join(Some(my_person_id)); let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(my_person_id)); let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(Some(my_person_id)); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(Some(my_person_id)); let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(my_person_id)); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); person_liked_combined::table .left_join(comment_join) .inner_join(post_join) .inner_join(community_join()) .inner_join(item_creator_join) .left_join(image_details_join()) .left_join(creator_community_actions_join()) .left_join(creator_local_user_admin_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_community_instance_actions_join()) .left_join(creator_local_instance_actions_join) // The my_'s have to come last to avoid stack overflows .left_join(my_post_actions_join) .left_join(my_person_actions_join) .left_join(my_comment_actions_join) .left_join(my_community_actions_join) .left_join(my_local_user_admin_join) } pub async fn list( self, pool: &mut DbPool<'_>, user: &LocalUserView, ) -> LemmyResult> { let my_person_id = user.local_user.person_id; let local_instance_id = user.person.instance_id; let limit = limit_fetch(self.limit, self.no_limit)?; let mut query = Self::joins(my_person_id, local_instance_id) .filter(person_liked_combined::person_id.eq(my_person_id)) .select(PostCommentCombinedViewInternal::as_select()) .limit(limit) .into_boxed(); if let Some(type_) = self.type_ { query = match type_ { PersonContentType::All => query, PersonContentType::Comments => { query.filter(person_liked_combined::comment_id.is_not_null()) } PersonContentType::Posts => query.filter(person_liked_combined::post_id.is_not_null()), } } if let Some(like_type) = self.like_type { query = match like_type { LikeType::All => query, LikeType::LikedOnly => query.filter(person_liked_combined::vote_is_upvote), LikeType::DislikedOnly => query.filter(not(person_liked_combined::vote_is_upvote)), } } // Sorting by liked desc let paginated_query = PostCommentCombinedViewWrapper::paginate( query, &self.page_cursor, SortDirection::Desc, pool, None, ) .await? .then_order_by(key::voted_at) // Tie breaker .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await?; // Map the query results to the enum let out = res .into_iter() .filter_map(InternalToCombinedView::map_to_enum) .map(PostCommentCombinedViewWrapper) .collect(); let res = paginate_response(out, limit, self.page_cursor)?; Ok(PagedResponse { items: res.items.into_iter().map(|i| i.0).collect(), next_page: res.next_page, prev_page: res.prev_page, }) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::{LocalUserView, impls::PersonLikedCombinedQuery}; use lemmy_db_schema::{ LikeType, source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm}, community::{Community, CommunityInsertForm}, instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm}, }, traits::Likeable, }; use lemmy_db_views_post_comment_combined::PostCommentCombinedView; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { instance: Instance, timmy: Person, timmy_view: LocalUserView, sara: Person, timmy_post: Post, sara_comment: Comment, sara_comment_2: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); let timmy = Person::create(pool, &timmy_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id); let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let timmy_view = LocalUserView { local_user: timmy_local_user, person: timmy.clone(), banned: false, ban_expires_at: None, }; let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); let sara = Person::create(pool, &sara_form).await?; let community_form = CommunityInsertForm::new( instance.id, "test community pcv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); let timmy_post = Post::create(pool, &timmy_post_form).await?; let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community.id); let _sara_post = Post::create(pool, &sara_post_form).await?; let timmy_comment_form = CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); let _timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; let sara_comment_form = CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; let sara_comment_form_2 = CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; Ok(Data { instance, timmy, timmy_view, sara, timmy_post, sara_comment, sara_comment_2, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_combined() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Do a batch read of timmy liked let timmy_liked = PersonLikedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(0, timmy_liked.len()); // Like a few things let like_sara_comment_2 = CommentLikeForm::new(data.sara_comment_2.id, data.timmy.id, Some(true)); CommentActions::like(pool, &like_sara_comment_2).await?; let dislike_sara_comment = CommentLikeForm::new(data.sara_comment.id, data.timmy.id, Some(false)); CommentActions::like(pool, &dislike_sara_comment).await?; let post_like_form = PostLikeForm::new(data.timmy_post.id, data.timmy.id, Some(true)); PostActions::like(pool, &post_like_form).await?; let timmy_liked_all = PersonLikedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(3, timmy_liked_all.len()); // Make sure the types and order are correct if let PostCommentCombinedView::Post(v) = &timmy_liked_all[0] { assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); assert_eq!( Some(true), v.post_actions.as_ref().and_then(|l| l.vote_is_upvote) ); } else { panic!("wrong type"); } if let PostCommentCombinedView::Comment(v) = &timmy_liked_all[1] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); assert_eq!( Some(false), v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote) ); } else { panic!("wrong type"); } if let PostCommentCombinedView::Comment(v) = &timmy_liked_all[2] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); assert_eq!( Some(true), v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote) ); } else { panic!("wrong type"); } let timmy_disliked = PersonLikedCombinedQuery { like_type: Some(LikeType::DislikedOnly), ..PersonLikedCombinedQuery::default() } .list(pool, &data.timmy_view) .await?; assert_eq!(1, timmy_disliked.len()); if let PostCommentCombinedView::Comment(v) = &timmy_disliked[0] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); assert_eq!( Some(false), v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote) ); } else { panic!("wrong type"); } // Try doing the opposite of the previous comment/post like or dislike, // to verify person_like_combined update on conflict triggers are working. let like_sara_comment = CommentLikeForm::new(data.sara_comment.id, data.timmy.id, Some(true)); CommentActions::like(pool, &like_sara_comment).await?; let post_dislike_form = PostLikeForm::new(data.timmy_post.id, data.timmy.id, Some(false)); PostActions::like(pool, &post_dislike_form).await?; let timmy_likes_opposite = PersonLikedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(3, timmy_likes_opposite.len()); if let PostCommentCombinedView::Post(v) = &timmy_likes_opposite[0] { assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); assert_eq!( Some(false), v.post_actions.as_ref().and_then(|l| l.vote_is_upvote) ); } else { panic!("wrong type"); } if let PostCommentCombinedView::Comment(v) = &timmy_likes_opposite[1] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); assert_eq!( Some(true), v.comment_actions.as_ref().and_then(|l| l.vote_is_upvote) ); } else { panic!("wrong type"); } // Try unliking 2 things let form = CommentLikeForm::new(data.sara_comment.id, data.timmy.id, None); CommentActions::like(pool, &form).await?; let form = PostLikeForm::new(data.timmy_post.id, data.timmy.id, None); PostActions::like(pool, &form).await?; let timmy_likes_removed = PersonLikedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(1, timmy_likes_removed.len()); if let PostCommentCombinedView::Comment(v) = &timmy_likes_removed[0] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/person_liked_combined/src/lib.rs ================================================ use lemmy_db_schema::{LikeType, PersonContentType}; #[cfg(feature = "full")] use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets your liked / disliked posts pub struct ListPersonLiked { pub type_: Option, pub like_type: Option, pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/person_saved_combined/Cargo.toml ================================================ [package] name = "lemmy_db_views_person_saved_combined" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", "lemmy_db_views_post_comment_combined/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_views_post_comment_combined/ts-rs", ] [dependencies] lemmy_db_views_post_comment_combined = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } serde_with = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/person_saved_combined/src/impls.rs ================================================ use crate::LocalUserView; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, SelectableHelper, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ PersonContentType, source::combined::person_saved::{PersonSavedCombined, person_saved_combined_keys as key}, traits::InternalToCombinedView, utils::limit_fetch, }; use lemmy_db_schema_file::{ InstanceId, PersonId, joins::{ community_join, creator_community_actions_join, creator_community_instance_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, creator_local_user_admin_join, image_details_join, my_comment_actions_join, my_community_actions_join, my_local_user_admin_join, my_person_actions_join, my_post_actions_join, }, schema::{comment, person, person_saved_combined, post}, }; use lemmy_db_views_post_comment_combined::{ PostCommentCombinedView, PostCommentCombinedViewInternal, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; #[derive(Default)] pub struct PersonSavedCombinedQuery { pub type_: Option, pub page_cursor: Option, pub limit: Option, pub no_limit: Option, } #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] struct PostCommentCombinedViewWrapper(PostCommentCombinedView); impl PaginationCursorConversion for PostCommentCombinedViewWrapper { type PaginatedType = PersonSavedCombined; fn to_cursor(&self) -> CursorData { let (prefix, id) = match &self.0 { PostCommentCombinedView::Comment(v) => ('C', v.comment.id.0), PostCommentCombinedView::Post(v) => ('P', v.post.id.0), }; CursorData::new_with_prefix(prefix, id) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let (prefix, id) = cursor.id_and_prefix()?; let mut query = person_saved_combined::table .select(Self::PaginatedType::as_select()) .into_boxed(); query = match prefix { 'C' => query.filter(person_saved_combined::comment_id.eq(id)), 'P' => query.filter(person_saved_combined::post_id.eq(id)), _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()), }; let token = query.first(conn).await?; Ok(token) } } impl PersonSavedCombinedQuery { #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ { let comment_join = comment::table.on(person_saved_combined::comment_id.eq(comment::id.nullable())); let post_join = post::table.on( person_saved_combined::post_id .eq(post::id.nullable()) .or(comment::post_id.eq(post::id)), ); let item_creator_join = person::table.on(person_saved_combined::creator_id.eq(person::id)); let my_community_actions_join: my_community_actions_join = my_community_actions_join(Some(my_person_id)); let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(my_person_id)); let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(Some(my_person_id)); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(Some(my_person_id)); let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(my_person_id)); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); person_saved_combined::table .left_join(comment_join) .inner_join(post_join) .inner_join(item_creator_join) .inner_join(community_join()) .left_join(image_details_join()) .left_join(creator_community_actions_join()) .left_join(creator_local_user_admin_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_community_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(my_community_actions_join) .left_join(my_local_user_admin_join) .left_join(my_post_actions_join) .left_join(my_person_actions_join) .left_join(my_comment_actions_join) } pub async fn list( self, pool: &mut DbPool<'_>, user: &LocalUserView, ) -> LemmyResult> { let my_person_id = user.local_user.person_id; let local_instance_id = user.person.instance_id; let limit = limit_fetch(self.limit, self.no_limit)?; let mut query = Self::joins(my_person_id, local_instance_id) .filter(person_saved_combined::person_id.eq(my_person_id)) .select(PostCommentCombinedViewInternal::as_select()) .limit(limit) .into_boxed(); if let Some(type_) = self.type_ { query = match type_ { PersonContentType::All => query, PersonContentType::Comments => { query.filter(person_saved_combined::comment_id.is_not_null()) } PersonContentType::Posts => query.filter(person_saved_combined::post_id.is_not_null()), } } // Sorting by saved desc let paginated_query = PostCommentCombinedViewWrapper::paginate( query, &self.page_cursor, SortDirection::Desc, pool, None, ) .await? .then_order_by(key::saved_at) // Tie breaker .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await?; // Map the query results to the enum let out = res .into_iter() .filter_map(InternalToCombinedView::map_to_enum) .map(PostCommentCombinedViewWrapper) .collect(); let res = paginate_response(out, limit, self.page_cursor)?; Ok(PagedResponse { items: res.items.into_iter().map(|i| i.0).collect(), next_page: res.next_page, prev_page: res.prev_page, }) } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use super::*; use crate::{LocalUserView, impls::PersonSavedCombinedQuery}; use lemmy_db_schema::{ source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentSavedForm}, community::{Community, CommunityInsertForm}, instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostSavedForm}, }, traits::Saveable, }; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { instance: Instance, timmy: Person, timmy_view: LocalUserView, sara: Person, timmy_post: Post, sara_comment: Comment, sara_comment_2: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); let timmy = Person::create(pool, &timmy_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id); let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let timmy_view = LocalUserView { local_user: timmy_local_user, person: timmy.clone(), banned: false, ban_expires_at: None, }; let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); let sara = Person::create(pool, &sara_form).await?; let community_form = CommunityInsertForm::new( instance.id, "test community pcv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &community_form).await?; let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); let timmy_post = Post::create(pool, &timmy_post_form).await?; let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community.id); let _sara_post = Post::create(pool, &sara_post_form).await?; let timmy_comment_form = CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); let _timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; let sara_comment_form = CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; let sara_comment_form_2 = CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; Ok(Data { instance, timmy, timmy_view, sara, timmy_post, sara_comment, sara_comment_2, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn test_combined() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Do a batch read of timmy saved let timmy_saved = PersonSavedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(0, timmy_saved.len()); // Save a few things let save_sara_comment_2 = CommentSavedForm::new(data.timmy_view.person.id, data.sara_comment_2.id); CommentActions::save(pool, &save_sara_comment_2).await?; let save_sara_comment = CommentSavedForm::new(data.timmy_view.person.id, data.sara_comment.id); CommentActions::save(pool, &save_sara_comment).await?; let post_save_form = PostSavedForm::new(data.timmy_post.id, data.timmy.id); PostActions::save(pool, &post_save_form).await?; let timmy_saved = PersonSavedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(3, timmy_saved.len()); // Make sure the types and order are correct if let PostCommentCombinedView::Post(v) = &timmy_saved[0] { assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.timmy.id, v.post.creator_id); } else { panic!("wrong type"); } if let PostCommentCombinedView::Comment(v) = &timmy_saved[1] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); } else { panic!("wrong type"); } if let PostCommentCombinedView::Comment(v) = &timmy_saved[2] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); } else { panic!("wrong type"); } // Try unsaving 2 things CommentActions::unsave(pool, &save_sara_comment).await?; PostActions::unsave(pool, &post_save_form).await?; let timmy_saved = PersonSavedCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_eq!(1, timmy_saved.len()); if let PostCommentCombinedView::Comment(v) = &timmy_saved[0] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.sara.id, v.comment.creator_id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/person_saved_combined/src/lib.rs ================================================ use lemmy_db_schema::PersonContentType; #[cfg(feature = "full")] use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets your saved posts and comments pub struct ListPersonSaved { pub type_: Option, pub page_cursor: Option, pub limit: Option, } ================================================ FILE: crates/db_views/post/Cargo.toml ================================================ [package] name = "lemmy_db_views_post" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } chrono = { workspace = true } tracing = { workspace = true } lemmy_diesel_utils = { workspace = true } [dev-dependencies] lemmy_db_views_local_user = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } url = { workspace = true } test-context = "0.5.5" diesel-uplete.workspace = true ================================================ FILE: crates/db_views/post/src/api.rs ================================================ use crate::PostView; use lemmy_db_schema::{ PostFeatureType, newtypes::{CommunityId, CommunityTagId, LanguageId, MultiCommunityId, PostId}, }; use lemmy_db_schema_file::enums::{ListingType, PostNotificationsMode, PostSortType}; use lemmy_diesel_utils::{dburl::DbUrl, pagination::PaginationCursor}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a post. pub struct CreatePost { pub name: String, pub community_id: CommunityId, pub url: Option, /// An optional body for the post in markdown. pub body: Option, /// An optional alt_text, usable for image posts. pub alt_text: Option, /// A honeypot to catch bots. Should be None. pub honeypot: Option, pub nsfw: Option, pub language_id: Option, /// Instead of fetching a thumbnail, use a custom one. pub custom_thumbnail: Option, pub tags: Option>, /// Time when this post should be scheduled. Null means publish immediately. pub scheduled_publish_time_at: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Like a post. pub struct CreatePostLike { pub post_id: PostId, /// True means Upvote, False means Downvote, and None means remove vote. pub is_upvote: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete a post. pub struct DeletePost { pub post_id: PostId, pub deleted: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edit a post. pub struct EditPost { pub post_id: PostId, pub name: Option, pub url: Option, /// An optional body for the post in markdown. pub body: Option, /// An optional alt_text, usable for image posts. pub alt_text: Option, pub nsfw: Option, pub language_id: Option, /// Instead of fetching a thumbnail, use a custom one. pub custom_thumbnail: Option, /// Time when this post should be scheduled. Null means publish immediately. pub scheduled_publish_time_at: Option, pub tags: Option>, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Mods can change some metadata for posts pub struct ModEditPost { pub post_id: PostId, pub nsfw: Option, pub tags: Option>, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Feature a post (stickies / pins to the top). pub struct FeaturePost { pub post_id: PostId, pub featured: bool, pub feature_type: PostFeatureType, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Change notification settings for a post pub struct EditPostNotifications { pub post_id: PostId, pub mode: PostNotificationsMode, } #[skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Get a list of posts. pub struct GetPosts { pub type_: Option, pub sort: Option, /// Filter to within a given time range, in seconds. /// IE 60 would give results for the past minute. /// Use Zero to override the local_site and local_user time_range. pub time_range_seconds: Option, pub community_id: Option, pub community_name: Option, pub multi_community_id: Option, pub multi_community_name: Option, pub show_hidden: Option, /// If true, then show the read posts (even if your user setting is to hide them) pub show_read: Option, /// If true, then show the nsfw posts (even if your user setting is to hide them) pub show_nsfw: Option, /// If false, then show posts with media attached (even if your user setting is to hide them) pub hide_media: Option, /// Whether to automatically mark fetched posts as read. pub mark_as_read: Option, /// If true, then only show posts with no comments pub no_comments_only: Option, pub page_cursor: Option, /// For backwards compat with API v3 (not available on API v4) #[serde(skip)] pub page: Option, pub limit: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Get metadata for a given site. pub struct GetSiteMetadata { pub url: String, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The site metadata response. pub struct GetSiteMetadataResponse { pub metadata: LinkMetadata, } #[skip_serializing_none] #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Default, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Site metadata, from its opengraph tags. pub struct LinkMetadata { #[serde(flatten)] pub opengraph_data: OpenGraphData, pub content_type: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Hide a post from list views pub struct HidePost { pub post_id: PostId, pub hide: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// List post likes. Admins-only. pub struct ListPostLikes { pub post_id: PostId, pub page_cursor: Option, pub limit: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Lock a post (prevent new comments). pub struct LockPost { pub post_id: PostId, pub locked: bool, pub reason: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Mark a post as read. pub struct MarkPostAsRead { pub post_id: PostId, pub read: bool, } #[skip_serializing_none] #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Default, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Site metadata, from its opengraph tags. pub struct OpenGraphData { pub title: Option, pub description: Option, pub image: Option, pub image_width: Option, pub image_height: Option, pub embed_video_url: Option, pub video_width: Option, pub video_height: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PostResponse { pub post_view: PostView, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Purges a post from the database. This will delete all content attached to that post. pub struct PurgePost { pub post_id: PostId, pub reason: String, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Remove a post (only doable by mods). pub struct RemovePost { pub post_id: PostId, pub removed: bool, pub reason: String, /// Setting this will override whatever `removed` was set to, /// leave as null or unset to act just on the post itself. pub remove_children: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Save / bookmark a post. pub struct SavePost { pub post_id: PostId, pub save: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Mark several posts as read. pub struct MarkManyPostsAsRead { pub post_ids: Vec, pub read: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Creates a warning against a post and notifies the user. pub struct CreatePostWarning { pub post_id: PostId, pub reason: String, } ================================================ FILE: crates/db_views/post/src/db_perf/mod.rs ================================================ mod series; use crate::{db_perf::series::ValuesFromSeries, impls::PostQuery}; use diesel::{ ExpressionMethods, IntoSql, dsl::{self, sql}, sql_types, }; use diesel_async::{RunQueryDsl, SimpleAsyncConnection}; use lemmy_db_schema::source::{ community::{Community, CommunityInsertForm}, instance::Instance, person::{Person, PersonInsertForm}, site::Site, }; use lemmy_db_schema_file::{enums::PostSortType, schema::post}; use lemmy_diesel_utils::{ connection::{build_db_pool, get_conn}, traits::Crud, utils::now, }; use lemmy_utils::error::LemmyResult; use serial_test::serial; use std::{fmt::Display, num::NonZeroU32, str::FromStr}; use url::Url; #[derive(Debug)] struct CmdArgs { communities: NonZeroU32, people: NonZeroU32, posts: NonZeroU32, read_post_pages: u32, explain_insertions: bool, } fn get_option(suffix: &str, default: T) -> Result { let name = format!("LEMMY_{suffix}"); if let Some(value) = std::env::var_os(&name) { value.to_string_lossy().parse() } else { println!("🔧 using default env var {name}={default}"); Ok(default) } } #[tokio::test] #[serial] async fn db_perf() -> LemmyResult<()> { let args = CmdArgs { communities: get_option("COMMUNITIES", 3.try_into()?)?, people: get_option("PEOPLE", 3.try_into()?)?, posts: get_option("POSTS", 100000.try_into()?)?, read_post_pages: get_option("READ_POST_PAGES", 0)?, explain_insertions: get_option("EXPLAIN_INSERTIONS", false)?, }; let pool = &build_db_pool()?; let pool = &mut pool.into(); let conn = &mut get_conn(pool).await?; if args.explain_insertions { // log_nested_statements is enabled to log trigger execution conn .batch_execute( "SET auto_explain.log_min_duration = 0; SET auto_explain.log_nested_statements = on;", ) .await?; } let instance = Instance::read_or_create(&mut conn.into(), "reddit.com").await?; println!("🫃 creating {} people", args.people); let mut person_ids = vec![]; for i in 0..args.people.get() { let form = PersonInsertForm::test_form(instance.id, &format!("p{i}")); person_ids.push(Person::create(&mut conn.into(), &form).await?.id); } println!("🌍 creating {} communities", args.communities); let mut community_ids = vec![]; for i in 0..args.communities.get() { let form = CommunityInsertForm::new( instance.id, format!("c{i}"), i.to_string(), "pubkey".to_string(), ); community_ids.push(Community::create(&mut conn.into(), &form).await?.id); } let post_batches = args.people.get() * args.communities.get(); let posts_per_batch = args.posts.get() / post_batches; let num_posts: usize = (post_batches * posts_per_batch).try_into()?; println!( "📜 creating {} posts ({} featured in community)", num_posts, post_batches ); let mut num_inserted_posts = 0; // TODO: progress bar for person_id in &person_ids { for community_id in &community_ids { let n = dsl::insert_into(post::table) .values(ValuesFromSeries { start: 1, stop: posts_per_batch.into(), selection: ( "AAAAAAAAAAA".into_sql::(), person_id.into_sql::(), community_id.into_sql::(), series::current_value.eq(1), now() - sql::("make_interval(secs => ") .bind::(series::current_value) .sql(")"), ), }) .into_columns(( post::name, post::creator_id, post::community_id, post::featured_community, post::published_at, )) .execute(conn) .await?; num_inserted_posts += n; } } // Make sure the println above shows the correct amount assert_eq!(num_inserted_posts, num_posts); // Manually trigger and wait for a statistics update to ensure consistent and high amount of // accuracy in the statistics used for query planning println!("🧮 updating database statistics"); conn.batch_execute("ANALYZE;").await?; // Enable auto_explain conn .batch_execute( "SET auto_explain.log_min_duration = 0; SET auto_explain.log_nested_statements = off;", ) .await?; // TODO: show execution duration stats let mut page_cursor = None; for page_num in 1..=args.read_post_pages { println!( "👀 getting page {page_num} of posts (pagination cursor used: {})", page_cursor.is_some() ); // TODO: include local_user let post_views = PostQuery { community_id: community_ids.as_slice().first().cloned(), sort: Some(PostSortType::New), limit: Some(20), page_cursor, ..Default::default() } .list(&site()?, &mut conn.into()) .await?; if let Some(cursor) = post_views.next_page { println!("👀 getting pagination cursor data for next page"); page_cursor = Some(cursor); } else { println!("👀 reached empty page"); break; } } // Delete everything, which might prevent problems if this is not run using scripts/db_perf.sh Instance::delete(&mut conn.into(), instance.id).await?; if let Ok(path) = std::env::var("PGDATA") { println!("🪵 query plans written in {path}/log"); } Ok(()) } fn site() -> LemmyResult { Ok(Site { id: Default::default(), name: String::new(), sidebar: None, published_at: Default::default(), updated_at: None, icon: None, banner: None, summary: None, ap_id: Url::parse("http://example.com")?.into(), last_refreshed_at: Default::default(), inbox_url: Url::parse("http://example.com")?.into(), private_key: None, public_key: String::new(), instance_id: Default::default(), content_warning: None, }) } ================================================ FILE: crates/db_views/post/src/db_perf/series.rs ================================================ use diesel::{ AppearsOnTable, Expression, Insertable, QueryId, SelectableExpression, dsl, expression::{ValidGrouping, is_aggregate}, pg::Pg, query_builder::{AsQuery, AstPass, QueryFragment}, result::Error, sql_types, }; /// Gererates a series of rows for insertion. /// /// An inclusive range is created from `start` and `stop`. A row for each number is generated using /// `selection`, which can be a tuple. [`current_value`] is an expression that gets the current /// value. /// /// For example, if there's a `numbers` table with a `number` column, this inserts all numbers from /// 1 to 10 in a single statement: /// /// ``` /// dsl::insert_into(numbers::table) /// .values(ValuesFromSeries { /// start: 1, /// stop: 10, /// selection: series::current_value, /// }) /// .into_columns(numbers::number) /// ``` #[derive(QueryId)] pub struct ValuesFromSeries { pub start: i64, pub stop: i64, pub selection: S, } impl> QueryFragment for ValuesFromSeries { fn walk_ast<'b>(&'b self, mut out: AstPass<'_, 'b, Pg>) -> Result<(), Error> { self.selection.walk_ast(out.reborrow())?; out.push_sql(" FROM generate_series("); out.push_bind_param::(&self.start)?; out.push_sql(", "); out.push_bind_param::(&self.stop)?; out.push_sql(")"); Ok(()) } } impl Expression for ValuesFromSeries { type SqlType = S::SqlType; } impl> AppearsOnTable for ValuesFromSeries {} impl> SelectableExpression for ValuesFromSeries {} impl> Insertable for ValuesFromSeries where dsl::select: AsQuery + Insertable, { type Values = as Insertable>::Values; fn values(self) -> Self::Values { dsl::select(self).values() } } impl> ValidGrouping<()> for ValuesFromSeries { type IsAggregate = is_aggregate::No; } #[expect(non_camel_case_types)] #[derive(QueryId, Clone, Copy, Debug)] pub struct current_value; impl QueryFragment for current_value { fn walk_ast<'b>(&'b self, mut out: AstPass<'_, 'b, Pg>) -> Result<(), Error> { out.push_identifier("generate_series")?; Ok(()) } } impl Expression for current_value { type SqlType = sql_types::BigInt; } impl AppearsOnTable for current_value {} impl SelectableExpression for current_value {} impl ValidGrouping<()> for current_value { type IsAggregate = is_aggregate::No; } ================================================ FILE: crates/db_views/post/src/impls.rs ================================================ use crate::PostView; use diesel::{ self, BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, PgTextExpressionMethods, QueryDsl, SelectableHelper, TextExpressionMethods, debug_query, dsl::{exists, not}, pg::Pg, query_builder::AsQuery, }; use diesel_async::RunQueryDsl; use i_love_jesus::{SortDirection, asc_if}; use lemmy_db_schema::{ impls::local_user::LocalUserOptionHelper, newtypes::{CommunityId, MultiCommunityId, PostId}, source::{ community::CommunityActions, local_user::LocalUser, person::Person, post::{Post, PostActions, post_actions_keys as pa_key, post_keys as key}, site::Site, }, utils::{ limit_fetch, queries::filters::{ filter_blocked, filter_is_subscribed, filter_not_unlisted_or_is_subscribed, filter_suggested_communities, }, }, }; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::{CommunityFollowerState, CommunityVisibility, ListingType, PostSortType}, joins::{ creator_community_actions_join, creator_community_instance_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, image_details_join, my_community_actions_join, my_instance_communities_actions_join, my_instance_persons_actions_join_1, my_local_user_admin_join, my_person_actions_join, my_post_actions_join, }, schema::{ community, community_actions, local_user_language, multi_community_entry, person, post, post_actions, }, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, traits::Crud, utils::{CoalesceKey, Commented, now, seconds_to_pg_interval}, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use tracing::debug; impl PaginationCursorConversion for PostView { type PaginatedType = Post; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.post.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Post::read(pool, PostId(cursor.id()?)).await } } /// This dummy struct is necessary to allow pagination using PostAction keys struct PostViewDummy(PostActions); impl PaginationCursorConversion for PostViewDummy { type PaginatedType = PostActions; fn to_cursor(&self) -> CursorData { CursorData::new_multi([self.0.post_id.0, self.0.person_id.0]) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let [post_id, person_id] = cursor.multi()?; PostActions::read(pool, PostId(post_id), PersonId(person_id)).await } } impl PostView { // TODO while we can abstract the joins into a function, the selects are currently impossible to // do, because they rely on a few types that aren't yet publicly exported in diesel: // https://github.com/diesel-rs/diesel/issues/4462 #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: Option, local_instance_id: InstanceId) -> _ { let my_community_actions_join: my_community_actions_join = my_community_actions_join(my_person_id); let my_post_actions_join: my_post_actions_join = my_post_actions_join(my_person_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id); let my_instance_communities_actions_join: my_instance_communities_actions_join = my_instance_communities_actions_join(my_person_id); let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 = my_instance_persons_actions_join_1(my_person_id); let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); post::table .inner_join(person::table) .inner_join(community::table) .left_join(image_details_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_community_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(creator_community_actions_join()) .left_join(my_community_actions_join) .left_join(my_person_actions_join) .left_join(my_post_actions_join) .left_join(my_instance_communities_actions_join) .left_join(my_instance_persons_actions_join_1) .left_join(my_local_user_admin_join) } #[diesel::dsl::auto_type(no_type_alias)] /// This uses the post_actions table as the base, for faster filtering for some queries fn post_action_joins(my_person_id: Option, local_instance_id: InstanceId) -> _ { let community_join = community::table.on(post::community_id.eq(community::id)); let my_community_actions_join: my_community_actions_join = my_community_actions_join(my_person_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id); let my_instance_communities_actions_join: my_instance_communities_actions_join = my_instance_communities_actions_join(my_person_id); let my_instance_persons_actions_join_1: my_instance_persons_actions_join_1 = my_instance_persons_actions_join_1(my_person_id); let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); post_actions::table .inner_join(post::table) .inner_join(person::table) .inner_join(community_join) .left_join(image_details_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_community_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(creator_community_actions_join()) .left_join(my_community_actions_join) .left_join(my_person_actions_join) .left_join(my_instance_communities_actions_join) .left_join(my_instance_persons_actions_join_1) .left_join(my_local_user_admin_join) } pub async fn read( pool: &mut DbPool<'_>, post_id: PostId, my_local_user: Option<&'_ LocalUser>, local_instance_id: InstanceId, is_mod_or_admin: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let my_person_id = my_local_user.person_id(); let mut query = Self::joins(my_person_id, local_instance_id) .filter(post::id.eq(post_id)) .select(Self::as_select()) .into_boxed(); // Hide deleted and removed for non-admins or mods if !is_mod_or_admin { query = query .filter( community::removed .eq(false) .or(post::creator_id.nullable().eq(my_person_id)), ) .filter( post::removed .eq(false) .or(post::creator_id.nullable().eq(my_person_id)), ) .filter( community::deleted .eq(false) .or(post::creator_id.nullable().eq(my_person_id)), ) // Posts deleted by the creator are still visible if they have any comments. If there // are no comments only the creator can see it. .filter( post::deleted .eq(false) .or(post::creator_id.nullable().eq(my_person_id)) .or(post::comments.gt(0)), ) // private communities can only by browsed by accepted followers .filter( community::visibility .ne(CommunityVisibility::Private) .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), ); } query = my_local_user.visible_communities_only(query); Commented::new(query) .text("PostView::read") .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// List all the read posts for your person, ordered by the read date. pub async fn list_read( pool: &mut DbPool<'_>, my_person: &Person, page_cursor: Option, limit: Option, no_limit: Option, ) -> LemmyResult> { let limit = limit_fetch(limit, no_limit)?; let query = PostView::post_action_joins(Some(my_person.id), my_person.instance_id) .filter(post_actions::person_id.eq(my_person.id)) .filter(post_actions::read_at.is_not_null()) .filter(filter_blocked()) .limit(limit) .select(PostView::as_select()) .into_boxed(); // Sorting by the read date let paginated_query = PostViewDummy::paginate(query, &page_cursor, SortDirection::Desc, pool, None) .await? .then_order_by(pa_key::read_at) // Tie breaker .then_order_by(pa_key::post_id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, page_cursor) } /// List all the hidden posts for your person, ordered by the hide date. pub async fn list_hidden( pool: &mut DbPool<'_>, my_person: &Person, page_cursor: Option, limit: Option, no_limit: Option, ) -> LemmyResult> { let limit = limit_fetch(limit, no_limit)?; let query = PostView::post_action_joins(Some(my_person.id), my_person.instance_id) .filter(post_actions::person_id.eq(my_person.id)) .filter(post_actions::hidden_at.is_not_null()) .filter(filter_blocked()) .limit(limit) .select(PostView::as_select()) .into_boxed(); // Sorting by the hidden date let paginated_query = PostViewDummy::paginate(query, &page_cursor, SortDirection::Desc, pool, None) .await? .then_order_by(pa_key::hidden_at) // Tie breaker .then_order_by(pa_key::post_id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, page_cursor) } } #[derive(Clone, Default)] pub struct PostQuery<'a> { pub listing_type: Option, pub sort: Option, pub time_range_seconds: Option, pub community_id: Option, pub multi_community_id: Option, pub local_user: Option<&'a LocalUser>, pub show_hidden: Option, pub show_read: Option, pub show_nsfw: Option, pub hide_media: Option, pub no_comments_only: Option, pub keyword_blocks: Option>, pub page_cursor: Option, /// For backwards compat with API v3 (not available on API v4). pub page: Option, pub limit: Option, } impl PostQuery<'_> { async fn prefetch_cursor_before_data( &self, site: &Site, pool: &mut DbPool<'_>, ) -> LemmyResult> { // first get one page for the most popular community to get an upper bound for the page end for // the real query. the reason this is needed is that when fetching posts for a single // community PostgreSQL can optimize the query to use an index on e.g. (=, >=, >=, >=) and // fetch only LIMIT rows but for the followed-communities query it has to query the index on // (IN, >=, >=, >=) which it currently can't do at all (as of PG 16). see the discussion // here: https://github.com/LemmyNet/lemmy/issues/2877#issuecomment-1673597190 // // the results are correct no matter which community we fetch these for, since it basically // covers the "worst case" of the whole page consisting of posts from one community // but using the largest community decreases the pagination-frame so make the real query more // efficient. // If its a subscribed type, you need to prefetch both the largest community, and the upper // bound post for the cursor. Ok(if self.listing_type == Some(ListingType::Subscribed) { if let Some(person_id) = self.local_user.person_id() { let largest_subscribed = CommunityActions::fetch_largest_subscribed_community(pool, person_id).await?; let upper_bound_results: Vec = self .clone() .list_inner(site, None, largest_subscribed, pool) .await? .items; let limit = limit_fetch(self.limit, None)?; // take last element of array. if this query returned less than LIMIT elements, // the heuristic is invalid since we can't guarantee the full query will return >= LIMIT // results (return original query) let len: i64 = upper_bound_results.len().try_into()?; if len < limit { None } else { if self .page_cursor .clone() .and_then(|c| c.is_back().ok()) .unwrap_or_default() { // for backward pagination, get first element instead upper_bound_results.into_iter().next() } else { upper_bound_results.into_iter().next_back() } .map(|pv| pv.post) } } else { None } } else { None }) } async fn list_inner( self, site: &Site, cursor_before_data: Option, largest_subscribed_for_prefetch: Option, pool: &mut DbPool<'_>, ) -> LemmyResult> { let o = self; let limit = limit_fetch(o.limit, None)?; let my_person_id = o.local_user.person_id(); let my_local_user_id = o.local_user.local_user_id(); let mut query = PostView::joins(my_person_id, site.instance_id) .select(PostView::as_select()) .limit(limit) .into_boxed(); if let Some(page) = o.page { query = query.offset(limit * (page - 1)); } // hide posts from deleted communities query = query.filter(community::deleted.eq(false)); // only creator can see deleted posts and unpublished scheduled posts if let Some(person_id) = my_person_id { query = query.filter(post::deleted.eq(false).or(post::creator_id.eq(person_id))); query = query.filter( post::scheduled_publish_time_at .is_null() .or(post::creator_id.eq(person_id)), ); } else { query = query .filter(post::deleted.eq(false)) .filter(post::scheduled_publish_time_at.is_null()); } match (o.community_id, o.multi_community_id) { (Some(id), None) => { query = query.filter(post::community_id.eq(id)); } (None, Some(id)) => { let communities = multi_community_entry::table .filter(multi_community_entry::multi_community_id.eq(id)) .select(multi_community_entry::community_id); query = query.filter(post::community_id.eq_any(communities)) } (Some(_), Some(_)) => { return Err(LemmyErrorType::CannotCombineCommunityIdAndMultiCommunityId.into()); } (None, None) => { if let (Some(ListingType::Subscribed), Some(id)) = (o.listing_type, largest_subscribed_for_prefetch) { query = query.filter(post::community_id.eq(id)); } } } match o.listing_type.unwrap_or_default() { // TODO we might have much better performance by using post::community_id.eq_any() ListingType::Subscribed => query = query.filter(filter_is_subscribed()), ListingType::Local => { query = query .filter(community::local.eq(true)) .filter(filter_not_unlisted_or_is_subscribed()); } ListingType::All => query = query.filter(filter_not_unlisted_or_is_subscribed()), ListingType::ModeratorView => { query = query.filter(community_actions::became_moderator_at.is_not_null()); } ListingType::Suggested => query = query.filter(filter_suggested_communities()), } if !o.show_nsfw.unwrap_or(o.local_user.show_nsfw(site)) { query = query .filter(post::nsfw.eq(false)) .filter(community::nsfw.eq(false)); }; if !o.local_user.show_bot_accounts() { query = query.filter(person::bot_account.eq(false)); }; // Filter to show only posts with no comments if o.no_comments_only.unwrap_or_default() { query = query.filter(post::comments.eq(0)); }; if !o.show_read.unwrap_or(o.local_user.show_read_posts()) { query = query.filter(post_actions::read_at.is_null()); } // Hide the hidden posts if !o.show_hidden.unwrap_or_default() { query = query.filter(post_actions::hidden_at.is_null()); } if o.hide_media.unwrap_or(o.local_user.hide_media()) { query = query.filter(not( post::url_content_type.is_not_null().and( post::url_content_type .like("image/%") .or(post::url_content_type.like("video/%")), ), )); } query = o.local_user.visible_communities_only(query); query = query.filter( post::federation_pending .eq(false) .or(post::creator_id.nullable().eq(my_person_id)), ); if !o.local_user.is_admin() { query = query .filter( community::visibility .ne(CommunityVisibility::Private) .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)), ) // only show removed posts to admin .filter(community::removed.eq(false)) .filter(community::local_removed.eq(false)) .filter(post::removed.eq(false)); } // Dont filter blocks or missing languages for moderator view type if o.listing_type.unwrap_or_default() != ListingType::ModeratorView { // Filter out the rows with missing languages if user is logged in if o.local_user.is_some() { query = query.filter(exists( local_user_language::table.filter( post::language_id.eq(local_user_language::language_id).and( local_user_language::local_user_id .nullable() .eq(my_local_user_id), ), ), )); } query = query.filter(filter_blocked()); if let Some(keyword_blocks) = o.keyword_blocks { for keyword in keyword_blocks { let pattern = format!("%{}%", keyword); query = query.filter(post::name.not_ilike(pattern.clone())); query = query.filter(post::url.is_null().or(post::url.not_ilike(pattern.clone()))); query = query.filter( post::body .is_null() .or(post::body.not_ilike(pattern.clone())), ); } } } // Filter by the time range if let Some(time_range_seconds) = o.time_range_seconds { query = query.filter(post::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds))); } // Only sort by ascending for Old let sort = o.sort.unwrap_or(PostSortType::Hot); let sort_direction = asc_if(sort == PostSortType::Old); let mut pq = PostView::paginate( query, &o.page_cursor, sort_direction, pool, cursor_before_data, ) .await?; // featured posts first // Don't do for new / old sorts if sort != PostSortType::New && sort != PostSortType::Old { pq = if o.community_id.is_none() || largest_subscribed_for_prefetch.is_some() { pq.then_order_by(key::featured_local) } else { pq.then_order_by(key::featured_community) }; } // then use the main sort pq = match sort { PostSortType::Active => pq.then_order_by(key::hot_rank_active), PostSortType::Hot => pq.then_order_by(key::hot_rank), PostSortType::Scaled => pq.then_order_by(key::scaled_rank), PostSortType::Controversial => pq.then_order_by(key::controversy_rank), PostSortType::New | PostSortType::Old => pq.then_order_by(key::published_at), PostSortType::NewComments => { pq.then_order_by(CoalesceKey(key::newest_comment_time_at, key::published_at)) } PostSortType::MostComments => pq.then_order_by(key::comments), PostSortType::Top => pq.then_order_by(key::score), }; // use publish as fallback. especially useful for hot rank which reaches zero after some days. // necessary because old posts can be fetched over federation and inserted with high post id pq = match sort { // A second time-based sort would not be very useful PostSortType::New | PostSortType::Old | PostSortType::NewComments => pq, _ => pq.then_order_by(key::published_at), }; // finally use unique post id as tie breaker pq = pq.then_order_by(key::id); // Convert to as_query to be able to use in commented. let query = pq.as_query(); debug!("Post View Query: {:?}", debug_query::(&query)); let conn = &mut get_conn(pool).await?; let res = Commented::new(query) .text("PostQuery::list") .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, o.page_cursor) } pub async fn list( &self, site: &Site, pool: &mut DbPool<'_>, ) -> LemmyResult> { let cursor_before_data = self.prefetch_cursor_before_data(site, pool).await?; self .clone() .list_inner(site, cursor_before_data, None, pool) .await } } ================================================ FILE: crates/db_views/post/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::source::{ community::{Community, CommunityActions}, community_tag::CommunityTagsView, images::ImageDetails, person::{Person, PersonActions}, post::{Post, PostActions}, }; use serde::{Deserialize, Serialize}; #[cfg(test)] mod db_perf; #[cfg(test)] mod test; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::utils::queries::selects::post_select_remove_deletes, lemmy_db_schema::utils::queries::selects::{ CreatorLocalHomeBanExpiresType, creator_ban_expires_from_community, creator_banned_from_community, creator_is_moderator, creator_local_home_ban_expires, creator_local_home_community_banned, local_user_can_mod_post, post_community_tags_fragment, post_creator_is_admin, }, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A post view. pub struct PostView { #[cfg_attr(feature = "full", diesel( select_expression = post_select_remove_deletes() ) )] pub post: Post, #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel(embed))] pub community: Community, #[cfg_attr(feature = "full", diesel(embed))] pub image_details: Option, #[cfg_attr(feature = "full", diesel(embed))] pub community_actions: Option, #[cfg_attr(feature = "full", diesel(embed))] pub person_actions: Option, #[cfg_attr(feature = "full", diesel(embed))] pub post_actions: Option, #[cfg_attr(feature = "full", diesel( select_expression = post_creator_is_admin() ) )] pub creator_is_admin: bool, #[cfg_attr(feature = "full", diesel( select_expression = post_community_tags_fragment() ) )] pub tags: CommunityTagsView, #[cfg_attr(feature = "full", diesel( select_expression = local_user_can_mod_post() ) )] pub can_mod: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_local_home_community_banned() ) )] pub creator_banned: bool, #[cfg_attr(feature = "full", diesel( select_expression_type = CreatorLocalHomeBanExpiresType, select_expression = creator_local_home_ban_expires() ) )] pub creator_ban_expires_at: Option>, #[cfg_attr(feature = "full", diesel( select_expression = creator_is_moderator() ) )] pub creator_is_moderator: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_banned_from_community() ) )] pub creator_banned_from_community: bool, #[cfg_attr(feature = "full", diesel( select_expression = creator_ban_expires_from_community() ) )] pub creator_community_ban_expires_at: Option>, } ================================================ FILE: crates/db_views/post/src/test.rs ================================================ #![expect(clippy::indexing_slicing, clippy::expect_used, clippy::unreachable)] use crate::{PostView, impls::PostQuery}; use chrono::{DateTime, Days, Utc}; use diesel_async::SimpleAsyncConnection; use diesel_uplete::UpleteCount; use lemmy_db_schema::{ impls::actor_language::UNDETERMINED_ID, newtypes::{LanguageId, PostId}, source::{ actor_language::LocalUserLanguage, comment::{Comment, CommentInsertForm}, community::{ Community, CommunityActions, CommunityBlockForm, CommunityFollowerForm, CommunityInsertForm, CommunityModeratorForm, CommunityPersonBanForm, CommunityUpdateForm, }, community_tag::{CommunityTag, CommunityTagInsertForm, PostCommunityTag}, instance::{ Instance, InstanceActions, InstanceBanForm, InstanceCommunitiesBlockForm, InstancePersonsBlockForm, }, keyword_block::LocalUserKeywordBlock, language::Language, local_site::{LocalSite, LocalSiteUpdateForm}, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, multi_community::{MultiCommunity, MultiCommunityInsertForm}, person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonNoteForm}, post::{Post, PostActions, PostHideForm, PostInsertForm, PostLikeForm, PostUpdateForm}, site::Site, }, test_data::TestData, traits::{Bannable, Blockable, Followable, Likeable}, }; use lemmy_db_schema_file::enums::{ CommunityFollowerState, CommunityVisibility, ListingType, PostSortType, TagColor, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{ connection::{ActualDbPool, DbPool, build_db_pool, get_conn}, pagination::PaginationCursor, traits::Crud, }; use lemmy_utils::error::{LemmyError, LemmyErrorType, LemmyResult}; use pretty_assertions::assert_eq; use serial_test::serial; use std::{ collections::HashSet, time::{Duration, Instant}, }; use test_context::{AsyncTestContext, test_context}; use url::Url; const POST_BY_BLOCKED_PERSON: &str = "post by blocked person"; const POST_BY_BOT: &str = "post by bot"; const POST: &str = "post"; const POST_WITH_TAGS: &str = "post with tags"; const POST_KEYWORD_BLOCKED: &str = "blocked_keyword"; fn names(post_views: &[PostView]) -> Vec<&str> { post_views.iter().map(|i| i.post.name.as_str()).collect() } struct Data { pool: ActualDbPool, instance: Instance, tegan: LocalUserView, john: LocalUserView, bot: LocalUserView, community: Community, post: Post, bot_post: Post, post_with_tags: Post, tag_1: CommunityTag, tag_2: CommunityTag, site: Site, } impl Data { fn pool(&self) -> ActualDbPool { self.pool.clone() } pub fn pool2(&self) -> DbPool<'_> { DbPool::Pool(&self.pool) } fn default_post_query(&self) -> PostQuery<'_> { PostQuery { sort: Some(PostSortType::New), local_user: Some(&self.tegan.local_user), ..Default::default() } } async fn setup_inner() -> LemmyResult { let actual_pool = build_db_pool()?; let pool = &mut (&actual_pool).into(); let data = TestData::create(pool).await?; let tegan_person_form = PersonInsertForm::test_form(data.instance.id, "tegan"); let inserted_tegan_person = Person::create(pool, &tegan_person_form).await?; let tegan_local_user_form = LocalUserInsertForm { admin: Some(true), ..LocalUserInsertForm::test_form(inserted_tegan_person.id) }; let inserted_tegan_local_user = LocalUser::create(pool, &tegan_local_user_form, vec![]).await?; let bot_person_form = PersonInsertForm { bot_account: Some(true), ..PersonInsertForm::test_form(data.instance.id, "mybot") }; let inserted_bot_person = Person::create(pool, &bot_person_form).await?; let inserted_bot_local_user = LocalUser::create( pool, &LocalUserInsertForm::test_form(inserted_bot_person.id), vec![], ) .await?; let new_community = CommunityInsertForm::new( data.instance.id, "test_community_3".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community = Community::create(pool, &new_community).await?; // Test a person block, make sure the post query doesn't include their post let john_person_form = PersonInsertForm::test_form(data.instance.id, "john"); let inserted_john_person = Person::create(pool, &john_person_form).await?; let inserted_john_local_user = LocalUser::create( pool, &LocalUserInsertForm::test_form(inserted_john_person.id), vec![], ) .await?; let post_from_blocked_person = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new( POST_BY_BLOCKED_PERSON.to_string(), inserted_john_person.id, community.id, ) }; Post::create(pool, &post_from_blocked_person).await?; // block that person let person_block = PersonBlockForm::new(inserted_tegan_person.id, inserted_john_person.id); PersonActions::block(pool, &person_block).await?; LocalUserKeywordBlock::update( pool, vec![POST_KEYWORD_BLOCKED.to_string()], inserted_tegan_local_user.id, ) .await?; // Two community post tags let tag_1 = CommunityTag::create( pool, &CommunityTagInsertForm { ap_id: Url::parse(&format!("{}/tags/test_tag1", community.ap_id))?.into(), name: "Test Tag 1".into(), display_name: None, summary: None, community_id: community.id, deleted: Some(false), color: Some(TagColor::Color01), }, ) .await?; let tag_2 = CommunityTag::create( pool, &CommunityTagInsertForm { ap_id: Url::parse(&format!("{}/tags/test_tag2", community.ap_id))?.into(), name: "Test Tag 2".into(), display_name: None, summary: None, community_id: community.id, deleted: Some(false), color: Some(TagColor::Color02), }, ) .await?; // A sample post let new_post = PostInsertForm { language_id: Some(LanguageId(47)), ..PostInsertForm::new(POST.to_string(), inserted_tegan_person.id, community.id) }; let post = Post::create(pool, &new_post).await?; let new_bot_post = PostInsertForm::new( POST_BY_BOT.to_string(), inserted_bot_person.id, community.id, ); let bot_post = Post::create(pool, &new_bot_post).await?; // A sample post with tags let new_post = PostInsertForm { language_id: Some(LanguageId(47)), ..PostInsertForm::new( POST_WITH_TAGS.to_string(), inserted_tegan_person.id, community.id, ) }; let post_with_tags = Post::create(pool, &new_post).await?; PostCommunityTag::update(pool, &post_with_tags, &[tag_1.id, tag_2.id]).await?; let tegan = LocalUserView { local_user: inserted_tegan_local_user, person: inserted_tegan_person, banned: false, ban_expires_at: None, }; let john = LocalUserView { local_user: inserted_john_local_user, person: inserted_john_person, banned: false, ban_expires_at: None, }; let bot = LocalUserView { local_user: inserted_bot_local_user, person: inserted_bot_person, banned: false, ban_expires_at: None, }; Ok(Data { pool: actual_pool, instance: data.instance, tegan, john, bot, community, post, bot_post, post_with_tags, tag_1, tag_2, site: data.site, }) } async fn teardown_inner(data: Data) -> LemmyResult<()> { let pool = &mut data.pool2(); let num_deleted = Post::delete(pool, data.post.id).await?; Community::delete(pool, data.community.id).await?; Person::delete(pool, data.tegan.person.id).await?; Person::delete(pool, data.bot.person.id).await?; Person::delete(pool, data.john.person.id).await?; Site::delete(pool, data.site.id).await?; Instance::delete(pool, data.instance.id).await?; assert_eq!(1, num_deleted); Ok(()) } } impl AsyncTestContext for Data { async fn setup() -> Self { Data::setup_inner().await.expect("setup failed") } async fn teardown(self) { Data::teardown_inner(self).await.expect("teardown failed") } } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_with_person(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let local_user_form = LocalUserUpdateForm { show_bot_accounts: Some(false), ..Default::default() }; LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?; data.tegan.local_user.show_bot_accounts = false; let mut read_post_listing = PostQuery { community_id: Some(data.community.id), ..data.default_post_query() } .list(&data.site, pool) .await? .items; // remove tags post read_post_listing.remove(0); let post_listing_single_with_person = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await?; assert_eq!( vec![post_listing_single_with_person.clone()], read_post_listing ); assert_eq!(data.post.id, post_listing_single_with_person.post.id); let local_user_form = LocalUserUpdateForm { show_bot_accounts: Some(true), ..Default::default() }; LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?; data.tegan.local_user.show_bot_accounts = true; let post_listings_with_bots = PostQuery { community_id: Some(data.community.id), ..data.default_post_query() } .list(&data.site, pool) .await?; // should include bot post which has "undetermined" language assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_with_bots) ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_no_person(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let read_post_listing_multiple_no_person = PostQuery { community_id: Some(data.community.id), local_user: None, ..data.default_post_query() } .list(&data.site, pool) .await?; let read_post_listing_single_no_person = PostView::read(pool, data.post.id, None, data.instance.id, false).await?; // Should be 2 posts, with the bot post, and the blocked assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON], names(&read_post_listing_multiple_no_person) ); assert!( read_post_listing_multiple_no_person .get(2) .is_some_and(|x| x.post.id == data.post.id) ); assert_eq!(false, read_post_listing_single_no_person.can_mod); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_block_community(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let community_block = CommunityBlockForm::new(data.community.id, data.tegan.person.id); CommunityActions::block(pool, &community_block).await?; let read_post_listings_with_person_after_block = PostQuery { community_id: Some(data.community.id), ..data.default_post_query() } .list(&data.site, pool) .await?; // Should be 0 posts after the community block assert_eq!(read_post_listings_with_person_after_block.items, vec![]); CommunityActions::unblock(pool, &community_block).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_like(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let post_like_form = PostLikeForm::new(data.post.id, data.tegan.person.id, Some(true)); let inserted_post_like = PostActions::like(pool, &post_like_form).await?; assert_eq!( (data.post.id, data.tegan.person.id, Some(true)), ( inserted_post_like.post_id, inserted_post_like.person_id, inserted_post_like.vote_is_upvote, ) ); let post_listing_single_with_person = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await?; assert_eq!( (true, true, 1, 1, 1), ( post_listing_single_with_person .post_actions .is_some_and(|t| t.vote_is_upvote == Some(true)), // Make sure person actions is none so you don't get a voted_at for your own user post_listing_single_with_person.person_actions.is_none(), post_listing_single_with_person.post.score, post_listing_single_with_person.post.upvotes, post_listing_single_with_person.creator.post_score, ) ); let local_user_form = LocalUserUpdateForm { show_bot_accounts: Some(false), ..Default::default() }; LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?; data.tegan.local_user.show_bot_accounts = false; let mut read_post_listing = PostQuery { community_id: Some(data.community.id), ..data.default_post_query() } .list(&data.site, pool) .await? .items; read_post_listing.remove(0); assert_eq!( post_listing_single_with_person.post.id, read_post_listing[0].post.id ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn person_note(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let note_str = "Tegan loves cats."; let note_form = PersonNoteForm::new( data.john.person.id, data.tegan.person.id, note_str.to_string(), ); let inserted_note = PersonActions::note(pool, ¬e_form).await?; assert_eq!(Some(note_str.to_string()), inserted_note.note); let post_listing = PostView::read( pool, data.post.id, Some(&data.john.local_user), data.instance.id, false, ) .await?; assert!( post_listing .person_actions .is_some_and(|t| t.note == Some(note_str.to_string()) && t.noted_at.is_some()) ); let note_removed = PersonActions::delete_note(pool, data.john.person.id, data.tegan.person.id).await?; let post_listing = PostView::read( pool, data.post.id, Some(&data.john.local_user), data.instance.id, false, ) .await?; assert_eq!(UpleteCount::only_deleted(1), note_removed); assert!(post_listing.person_actions.is_none()); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_person_vote_totals(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Create a 2nd bot post, to do multiple votes let bot_post_2 = PostInsertForm::new( "Bot post 2".to_string(), data.bot.person.id, data.community.id, ); let bot_post_2 = Post::create(pool, &bot_post_2).await?; let post_like_form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, Some(true)); let inserted_post_like = PostActions::like(pool, &post_like_form).await?; assert_eq!( (data.bot_post.id, data.tegan.person.id, Some(true)), ( inserted_post_like.post_id, inserted_post_like.person_id, inserted_post_like.vote_is_upvote, ) ); let inserted_person_like = PersonActions::like( pool, data.tegan.person.id, data.bot.person.id, None, Some(true), ) .await?; assert_eq!( (data.tegan.person.id, data.bot.person.id, Some(1), Some(0),), ( inserted_person_like.person_id, inserted_person_like.target_id, inserted_person_like.upvotes, inserted_person_like.downvotes, ) ); let post_listing = PostView::read( pool, data.bot_post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await?; assert_eq!( (true, true, true, 1, 1, 1), ( post_listing .post_actions .is_some_and(|t| t.vote_is_upvote == Some(true)), post_listing .person_actions .as_ref() .is_some_and(|t| t.upvotes == Some(1)), post_listing .person_actions .as_ref() .is_some_and(|t| t.downvotes == Some(0)), post_listing.post.score, post_listing.post.upvotes, post_listing.creator.post_score, ) ); // Do a 2nd like to another post let post_2_like_form = PostLikeForm::new(bot_post_2.id, data.tegan.person.id, Some(true)); PostActions::like(pool, &post_2_like_form).await?; let inserted_person_like_2 = PersonActions::like( pool, data.tegan.person.id, data.bot.person.id, None, Some(true), ) .await?; assert_eq!( (data.tegan.person.id, data.bot.person.id, Some(2), Some(0),), ( inserted_person_like_2.person_id, inserted_person_like_2.target_id, inserted_person_like_2.upvotes, inserted_person_like_2.downvotes, ) ); // Remove the like let form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, None); PostActions::like(pool, &form).await?; let person_like_removed = PersonActions::like( pool, data.tegan.person.id, data.bot.person.id, Some(true), None, ) .await?; assert_eq!( (data.tegan.person.id, data.bot.person.id, Some(1), Some(0),), ( person_like_removed.person_id, person_like_removed.target_id, person_like_removed.upvotes, person_like_removed.downvotes, ) ); // Now do a downvote let post_like_form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, Some(false)); PostActions::like(pool, &post_like_form).await?; let inserted_person_dislike = PersonActions::like( pool, data.tegan.person.id, data.bot.person.id, None, Some(false), ) .await?; assert_eq!( (data.tegan.person.id, data.bot.person.id, Some(1), Some(1),), ( inserted_person_dislike.person_id, inserted_person_dislike.target_id, inserted_person_dislike.upvotes, inserted_person_dislike.downvotes, ) ); let post_listing = PostView::read( pool, data.bot_post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await?; assert_eq!( (true, true, true, -1, 1, 0), ( post_listing .post_actions .is_some_and(|t| t.vote_is_upvote == Some(false)), post_listing .person_actions .as_ref() .is_some_and(|t| t.upvotes == Some(1)), post_listing .person_actions .as_ref() .is_some_and(|t| t.downvotes == Some(1)), post_listing.post.score, post_listing.post.downvotes, post_listing.creator.post_score, ) ); let form = PostLikeForm::new(data.bot_post.id, data.tegan.person.id, None); PostActions::like(pool, &form).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_read_only(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Mark the bot post, then the tags post as read PostActions::mark_as_read(pool, data.tegan.person.id, &[data.bot_post.id]).await?; PostActions::mark_as_read(pool, data.tegan.person.id, &[data.post_with_tags.id]).await?; let read_read_post_listing = PostView::list_read(pool, &data.tegan.person, None, None, None).await?; // This should be ordered from most recently read assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT], names(&read_read_post_listing) ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn creator_info(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let community_id = data.community.id; let tegan_listings = PostQuery { community_id: Some(community_id), ..data.default_post_query() } .list(&data.site, pool) .await? .into_iter() .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod)) .collect::>(); // Tegan is an admin, so can_mod should be always true let expected_post_listing = vec![ ("tegan".to_owned(), false, true), ("mybot".to_owned(), false, true), ("tegan".to_owned(), false, true), ]; assert_eq!(expected_post_listing, tegan_listings); // Have john become a moderator, then the bot let john_mod_form = CommunityModeratorForm::new(community_id, data.john.person.id); CommunityActions::join(pool, &john_mod_form).await?; let bot_mod_form = CommunityModeratorForm::new(community_id, data.bot.person.id); CommunityActions::join(pool, &bot_mod_form).await?; let john_listings = PostQuery { sort: Some(PostSortType::New), local_user: Some(&data.john.local_user), ..Default::default() } .list(&data.site, pool) .await? .into_iter() .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod)) .collect::>(); // John is a mod, so he can_mod the bots (and his own) posts, but not tegans. let expected_post_listing = vec![ ("tegan".to_owned(), false, false), ("mybot".to_owned(), true, true), ("tegan".to_owned(), false, false), ("john".to_owned(), true, true), ]; assert_eq!(expected_post_listing, john_listings); // Bot is also a mod, but was added after john, so can't mod anything let bot_listings = PostQuery { sort: Some(PostSortType::New), local_user: Some(&data.bot.local_user), ..Default::default() } .list(&data.site, pool) .await? .into_iter() .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod)) .collect::>(); let expected_post_listing = vec![ ("tegan".to_owned(), false, false), ("mybot".to_owned(), true, true), ("tegan".to_owned(), false, false), ("john".to_owned(), true, false), ]; assert_eq!(expected_post_listing, bot_listings); // Make the bot leave the mod team, and make sure it can_mod is false. CommunityActions::leave(pool, &bot_mod_form).await?; let bot_listings = PostQuery { sort: Some(PostSortType::New), local_user: Some(&data.bot.local_user), ..Default::default() } .list(&data.site, pool) .await? .into_iter() .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod)) .collect::>(); let expected_post_listing = vec![ ("tegan".to_owned(), false, false), ("mybot".to_owned(), false, false), ("tegan".to_owned(), false, false), ("john".to_owned(), true, false), ]; assert_eq!(expected_post_listing, bot_listings); // Have tegan the administrator become a moderator let tegan_mod_form = CommunityModeratorForm::new(community_id, data.tegan.person.id); CommunityActions::join(pool, &tegan_mod_form).await?; let john_listings = PostQuery { sort: Some(PostSortType::New), local_user: Some(&data.john.local_user), ..Default::default() } .list(&data.site, pool) .await? .into_iter() .map(|p| (p.creator.name, p.creator_is_moderator, p.can_mod)) .collect::>(); // John is a mod, so he still can_mod the bots (and his own) posts. Tegan is a lower mod and // admin, john can't mod their posts. let expected_post_listing = vec![ ("tegan".to_owned(), true, false), ("mybot".to_owned(), false, true), ("tegan".to_owned(), true, false), ("john".to_owned(), true, true), ]; assert_eq!(expected_post_listing, john_listings); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_person_language(data: &mut Data) -> LemmyResult<()> { const EL_POSTO: &str = "el posto"; let pool = &data.pool(); let pool = &mut pool.into(); let spanish_id = Language::read_id_from_code(pool, "es").await?; let french_id = Language::read_id_from_code(pool, "fr").await?; let post_spanish = PostInsertForm { language_id: Some(spanish_id), ..PostInsertForm::new( EL_POSTO.to_string(), data.tegan.person.id, data.community.id, ) }; Post::create(pool, &post_spanish).await?; let post_listings_all = data.default_post_query().list(&data.site, pool).await?; // no language filters specified, all posts should be returned assert_eq!( vec![EL_POSTO, POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_all) ); LocalUserLanguage::update(pool, vec![french_id], data.tegan.local_user.id).await?; let post_listing_french = data.default_post_query().list(&data.site, pool).await?; // only one post in french and one undetermined should be returned assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listing_french)); assert_eq!( Some(french_id), post_listing_french.get(1).map(|p| p.post.language_id) ); LocalUserLanguage::update( pool, vec![french_id, UNDETERMINED_ID], data.tegan.local_user.id, ) .await?; let post_listings_french_und = data .default_post_query() .list(&data.site, pool) .await? .into_iter() .map(|p| (p.post.name, p.post.language_id)) .collect::>(); let expected_post_listings_french_und = vec![ (POST_WITH_TAGS.to_owned(), french_id), (POST_BY_BOT.to_owned(), UNDETERMINED_ID), (POST.to_owned(), french_id), ]; // french post and undetermined language post should be returned assert_eq!(expected_post_listings_french_und, post_listings_french_und); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_removed(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Remove the post Post::update( pool, data.bot_post.id, &PostUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; // Make sure you don't see the removed post in the results data.tegan.local_user.admin = false; let post_listings_no_admin = data.default_post_query().list(&data.site, pool).await?; assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_no_admin)); // Removed bot post is shown to admins data.tegan.local_user.admin = true; let post_listings_is_admin = data.default_post_query().list(&data.site, pool).await?; assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_is_admin) ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_deleted(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Delete the post Post::update( pool, data.post.id, &PostUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; // Deleted post is only shown to creator for (local_user, expect_contains_deleted) in [ (None, false), (Some(&data.john.local_user), false), (Some(&data.tegan.local_user), true), ] { let contains_deleted = PostQuery { local_user, ..data.default_post_query() } .list(&data.site, pool) .await? .iter() .any(|p| p.post.id == data.post.id); assert_eq!(expect_contains_deleted, contains_deleted); } Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_hidden_community(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); Community::update( pool, data.community.id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::Unlisted), ..Default::default() }, ) .await?; let posts = PostQuery::default().list(&data.site, pool).await?; assert!(posts.is_empty()); let posts = data.default_post_query().list(&data.site, pool).await?; assert!(posts.is_empty()); // Follow the community let form = CommunityFollowerForm::new( data.community.id, data.tegan.person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &form).await?; let posts = data.default_post_query().list(&data.site, pool).await?; assert!(!posts.is_empty()); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_instance_block_communities(data: &mut Data) -> LemmyResult<()> { const POST_FROM_BLOCKED_INSTANCE_COMMS: &str = "post on blocked instance"; const HOWARD_POST: &str = "howard post"; const POST_LISTING_WITH_BLOCKED: [&str; 5] = [ HOWARD_POST, POST_FROM_BLOCKED_INSTANCE_COMMS, POST_WITH_TAGS, POST_BY_BOT, POST, ]; let pool = &data.pool(); let pool = &mut pool.into(); let blocked_instance_comms = Instance::read_or_create(pool, "another_domain.tld").await?; let community_form = CommunityInsertForm::new( blocked_instance_comms.id, "test_community_4".to_string(), "none".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &community_form).await?; let post_form = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new( POST_FROM_BLOCKED_INSTANCE_COMMS.to_string(), data.bot.person.id, inserted_community.id, ) }; let post_from_blocked_instance = Post::create(pool, &post_form).await?; // Create a person on that comm-blocked instance, // have them create a post from a non-instance-comm blocked community. // Make sure others can see it. let howard_form = PersonInsertForm::test_form(blocked_instance_comms.id, "howard"); let howard = Person::create(pool, &howard_form).await?; let howard_post_form = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new(HOWARD_POST.to_string(), howard.id, data.community.id) }; let _post_from_blocked_instance_user = Post::create(pool, &howard_post_form).await?; // no instance block, should return all posts let post_listings_all = data.default_post_query().list(&data.site, pool).await?; assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_all)); // block the instance communities let block_form = InstanceCommunitiesBlockForm::new(data.tegan.person.id, blocked_instance_comms.id); InstanceActions::block_communities(pool, &block_form).await?; // now posts from communities on that instance should be hidden let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; assert_eq!( vec![HOWARD_POST, POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_blocked) ); assert!( post_listings_blocked .iter() .all(|p| p.post.id != post_from_blocked_instance.id) ); // Follow community from the blocked instance to see posts anyway let follow_form = CommunityFollowerForm::new( inserted_community.id, data.tegan.person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &follow_form).await?; let post_listings_bypass = data.default_post_query().list(&data.site, pool).await?; assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_bypass)); CommunityActions::unfollow(pool, data.tegan.person.id, inserted_community.id).await?; // after unblocking it should return all posts again InstanceActions::unblock_communities(pool, &block_form).await?; let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_blocked)); Instance::delete(pool, blocked_instance_comms.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_instance_block_persons(data: &mut Data) -> LemmyResult<()> { const POST_FROM_BLOCKED_INSTANCE_USERS: &str = "post from blocked instance user"; const POST_TO_UNBLOCKED_COMM: &str = "post to unblocked comm"; const POST_LISTING_WITH_BLOCKED: [&str; 5] = [ POST_TO_UNBLOCKED_COMM, POST_FROM_BLOCKED_INSTANCE_USERS, POST_WITH_TAGS, POST_BY_BOT, POST, ]; let pool = &data.pool(); let pool = &mut pool.into(); let blocked_instance_persons = Instance::read_or_create(pool, "another_domain.tld").await?; let howard_form = PersonInsertForm::test_form(blocked_instance_persons.id, "howard"); let howard = Person::create(pool, &howard_form).await?; let community_form = CommunityInsertForm::new( blocked_instance_persons.id, "test_community_8".to_string(), "none".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &community_form).await?; // Create a post from the blocked user on a safe community let blocked_post_form = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new( POST_FROM_BLOCKED_INSTANCE_USERS.to_string(), howard.id, data.community.id, ) }; let post_from_blocked_instance = Post::create(pool, &blocked_post_form).await?; // Also create a post from an unblocked user let unblocked_post_form = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new( POST_TO_UNBLOCKED_COMM.to_string(), data.bot.person.id, inserted_community.id, ) }; let _post_to_unblocked_comm = Post::create(pool, &unblocked_post_form).await?; // no instance block, should return all posts let post_listings_all = data.default_post_query().list(&data.site, pool).await?; assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_all)); // block the instance communities let block_form = InstancePersonsBlockForm::new(data.tegan.person.id, blocked_instance_persons.id); InstanceActions::block_persons(pool, &block_form).await?; // now posts from users on that instance should be hidden let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; assert_eq!( vec![POST_TO_UNBLOCKED_COMM, POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_blocked) ); assert!( post_listings_blocked .iter() .all(|p| p.post.id != post_from_blocked_instance.id) ); // after unblocking it should return all posts again InstanceActions::unblock_persons(pool, &block_form).await?; let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; assert_eq!(POST_LISTING_WITH_BLOCKED, *names(&post_listings_blocked)); Instance::delete(pool, blocked_instance_persons.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn pagination_includes_each_post_once(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let community_form = CommunityInsertForm::new( data.instance.id, "yes".to_string(), "yes".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &community_form).await?; let mut inserted_post_ids = HashSet::new(); // Create 150 posts with varying non-correlating values for publish date, number of comments, // and featured for i in 0..45 { let post_form = PostInsertForm { featured_local: Some((i % 2) == 0), featured_community: Some((i % 2) == 0), published_at: Some(Utc::now() - Duration::from_secs(i)), ..PostInsertForm::new( "keep Christ in Christmas".to_owned(), data.tegan.person.id, inserted_community.id, ) }; let inserted_post = Post::create(pool, &post_form).await?; inserted_post_ids.insert(inserted_post.id); } let options = PostQuery { community_id: Some(inserted_community.id), sort: Some(PostSortType::Hot), limit: Some(3), ..Default::default() }; let mut listed_post_ids_forward = vec![]; let mut page_cursor = None; let mut page_cursor_back = None; loop { let post_listings = PostQuery { page_cursor, ..options.clone() } .list(&data.site, pool) .await?; listed_post_ids_forward.extend(post_listings.iter().map(|p| p.post.id)); if post_listings.next_page.is_none() { break; } page_cursor = post_listings.next_page; page_cursor_back = post_listings.prev_page; } // unsorted comparison with hashset assert_eq!( inserted_post_ids, listed_post_ids_forward.iter().cloned().collect() ); // By going backwards from the last page we dont see the last page again, so remove those items listed_post_ids_forward.truncate(listed_post_ids_forward.len() - 3); // Check that backward pagination matches forward pagination loop { let post_listings = PostQuery { page_cursor: page_cursor_back, ..options.clone() } .list(&data.site, pool) .await?; let listed_post_ids = post_listings.iter().map(|p| p.post.id).collect::>(); let index = listed_post_ids_forward.len() - listed_post_ids.len(); assert_eq!( listed_post_ids_forward.get(index..), listed_post_ids.get(..) ); listed_post_ids_forward.truncate(index); if let Some(cursor) = post_listings.prev_page { page_cursor_back = Some(cursor); } else { break; } } Community::delete(pool, inserted_community.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] /// Test that last and first partial pages only have one cursor. async fn pagination_hidden_cursors(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let community_form = CommunityInsertForm::new( data.instance.id, "yes".to_string(), "yes".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &community_form).await?; let page_size: usize = 5; // Create 2 pages with 5 and 4 posts respectively for i in 0..9 { let post_form = PostInsertForm { featured_local: Some((i % 2) == 0), featured_community: Some((i % 2) == 0), published_at: Some(Utc::now() - Duration::from_secs(i)), ..PostInsertForm::new( "keep Christ in Christmas".to_owned(), data.tegan.person.id, inserted_community.id, ) }; Post::create(pool, &post_form).await?; } let options = PostQuery { community_id: Some(inserted_community.id), sort: Some(PostSortType::Hot), limit: Some(page_size.try_into()?), ..Default::default() }; let mut get_page = async |cursor: &Option| { PostQuery { page_cursor: cursor.clone(), ..options.clone() } .list(&data.site, pool) .await }; let first_page = get_page(&None).await?; assert_eq!(first_page.items.len(), page_size); assert!(first_page.prev_page.is_none()); // without request cursor, no back cursor assert!(first_page.next_page.is_some()); let last_page = get_page(&first_page.next_page).await?; assert_eq!(last_page.items.len(), page_size - 1); assert!(last_page.prev_page.is_some()); assert!(last_page.next_page.is_none()); // Get first page with both cursors let first_page2 = get_page(&last_page.prev_page).await?; assert_eq!(first_page2.items.len(), page_size); assert!(first_page2.prev_page.is_some()); assert_eq!(first_page2.next_page, first_page.next_page); let pool = &data.pool; let pool = &mut pool.into(); // Mark first post as deleted let first_post_view = first_page.items.first().expect("first post"); let post_update_form = PostUpdateForm { deleted: Some(true), ..Default::default() }; Post::update(pool, first_post_view.post.id, &post_update_form).await?; let partial_first_page = get_page(&last_page.prev_page).await?; assert_eq!(partial_first_page.items.len(), page_size - 1); assert!(partial_first_page.prev_page.is_none()); assert!(partial_first_page.next_page.is_some()); // Cursor works for item marked as deleted let removed_item_page = get_page(&first_page2.prev_page).await?; assert_eq!(removed_item_page.items.len(), 0); assert!(removed_item_page.prev_page.is_none()); assert!(removed_item_page.next_page.is_some()); // recovery cursor let recovered_page = get_page(&removed_item_page.next_page).await?; assert_eq!(recovered_page.items.len(), page_size); assert!(recovered_page.prev_page.is_some()); assert!(recovered_page.next_page.is_some()); // Delete first post from the database Post::delete(pool, first_post_view.post.id).await?; let partial_first_page = get_page(&last_page.prev_page).await?; assert_eq!(partial_first_page.items.len(), page_size - 1); assert!(partial_first_page.prev_page.is_none()); assert!(partial_first_page.next_page.is_some()); // Cursor doesn't work for item that no longer exists let removed_item_page = get_page(&first_page2.prev_page).await; if let Err(LemmyError { error_type, cause: _, caller: _, }) = removed_item_page { assert_eq!(error_type, LemmyErrorType::NotFound); } else { unreachable!(); } Community::delete(pool, inserted_community.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] /// Test paging past the last and first page. async fn pagination_recovery_cursors(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let community_form = CommunityInsertForm::new( data.instance.id, "yes".to_string(), "yes".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &community_form).await?; let page_size: usize = 5; // Create 2 pages with 5 posts each for i in 0..10 { let post_form = PostInsertForm { featured_local: Some((i % 2) == 0), featured_community: Some((i % 2) == 0), published_at: Some(Utc::now() - Duration::from_secs(i)), ..PostInsertForm::new( "keep Christ in Christmas".to_owned(), data.tegan.person.id, inserted_community.id, ) }; Post::create(pool, &post_form).await?; } let options = PostQuery { community_id: Some(inserted_community.id), sort: Some(PostSortType::Hot), limit: Some(page_size.try_into()?), ..Default::default() }; let mut get_page = async |cursor: &Option| { PostQuery { page_cursor: cursor.clone(), ..options.clone() } .list(&data.site, pool) .await }; let first_page = get_page(&None).await?; assert_eq!(first_page.items.len(), page_size); assert!(first_page.prev_page.is_none()); // without request cursor, no back cursor assert!(first_page.next_page.is_some()); let last_page = get_page(&first_page.next_page).await?; assert_eq!(last_page.items.len(), page_size); assert!(last_page.prev_page.is_some()); assert!(last_page.next_page.is_some()); // full page, has cursor // Get the first page with both cursors let first_page2 = get_page(&last_page.prev_page).await?; assert_eq!(first_page.items.len(), page_size); assert!(first_page2.prev_page.is_some()); // full page, has cursor assert!(first_page2.next_page.is_some()); assert_eq!(first_page2.next_page, first_page.next_page); assert_eq!( first_page2 .items .into_iter() .map(|pv| pv.post.id) .collect::>(), first_page .items .clone() .into_iter() .map(|pv| pv.post.id) .collect::>() ); let beyond_first_page = get_page(&first_page2.prev_page).await?; assert_eq!(beyond_first_page.items.len(), 0); assert!(beyond_first_page.prev_page.is_none()); assert!(beyond_first_page.next_page.is_some()); let recovered_first_page = get_page(&beyond_first_page.next_page).await?; assert_eq!(recovered_first_page.items.len(), page_size); assert!(recovered_first_page.prev_page.is_some()); // full page, has cursor assert!(recovered_first_page.next_page.is_some()); assert_eq!(recovered_first_page.next_page, first_page2.next_page); assert_eq!(recovered_first_page.prev_page, first_page2.prev_page); assert_eq!( recovered_first_page .items .into_iter() .map(|pv| pv.post.id) .collect::>(), first_page .items .into_iter() .map(|pv| pv.post.id) .collect::>() ); let beyond_last_page = get_page(&last_page.next_page).await?; assert_eq!(beyond_last_page.items.len(), 0); assert!(beyond_last_page.prev_page.is_some()); assert!(beyond_last_page.next_page.is_none()); let recovered_last_page = get_page(&beyond_last_page.prev_page).await?; assert_eq!(recovered_last_page.items.len(), page_size); assert!(recovered_last_page.prev_page.is_some()); assert!(recovered_last_page.next_page.is_some()); // full page, has cursor assert_eq!(recovered_last_page.next_page, last_page.next_page); assert_eq!(recovered_last_page.prev_page, last_page.prev_page); assert_eq!( recovered_last_page .items .into_iter() .map(|pv| pv.post.id) .collect::>(), last_page .items .into_iter() .map(|pv| pv.post.id) .collect::>() ); Community::delete(pool, inserted_community.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_hide_read(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Make sure local user hides read posts let local_user_form = LocalUserUpdateForm { show_read_posts: Some(false), ..Default::default() }; LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?; data.tegan.local_user.show_read_posts = false; // Mark a post as read PostActions::mark_as_read(pool, data.tegan.person.id, &[data.bot_post.id]).await?; // Make sure you don't see the read post in the results let post_listings_hide_read = data.default_post_query().list(&data.site, pool).await?; assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_hide_read)); // Test with the show_read override as true let post_listings_show_read_true = PostQuery { show_read: Some(true), ..data.default_post_query() } .list(&data.site, pool) .await?; assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_show_read_true) ); // Test with the show_read override as false let post_listings_show_read_false = PostQuery { show_read: Some(false), ..data.default_post_query() } .list(&data.site, pool) .await?; assert_eq!( vec![POST_WITH_TAGS, POST], names(&post_listings_show_read_false) ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_hide_hidden(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Mark a post as hidden let hide_form = PostHideForm::new(data.bot_post.id, data.tegan.person.id); PostActions::hide(pool, &hide_form).await?; // Make sure you don't see the hidden post in the results let post_listings_hide_hidden = data.default_post_query().list(&data.site, pool).await?; assert_eq!( vec![POST_WITH_TAGS, POST], names(&post_listings_hide_hidden) ); // Make sure it does come back with the show_hidden option let post_listings_show_hidden = PostQuery { sort: Some(PostSortType::New), local_user: Some(&data.tegan.local_user), show_hidden: Some(true), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_show_hidden) ); // Make sure that hidden field is true. assert!(&post_listings_show_hidden.get(1).is_some_and(|p| { p.post_actions .as_ref() .is_some_and(|a| a.hidden_at.is_some()) })); // Make sure only that one comes back for list_hidden let list_hidden = PostView::list_hidden(pool, &data.tegan.person, None, None, None).await?; assert_eq!(vec![POST_BY_BOT], names(&list_hidden)); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_hide_nsfw(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Mark a post as nsfw let update_form = PostUpdateForm { nsfw: Some(true), ..Default::default() }; Post::update(pool, data.post_with_tags.id, &update_form).await?; // Make sure you don't see the nsfw post in the regular results let post_listings_hide_nsfw = data.default_post_query().list(&data.site, pool).await?; assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_hide_nsfw)); // Make sure it does come back with the show_nsfw option let post_listings_show_nsfw = PostQuery { sort: Some(PostSortType::New), show_nsfw: Some(true), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_show_nsfw) ); // Make sure that nsfw field is true. assert!( &post_listings_show_nsfw .first() .ok_or(LemmyErrorType::NotFound)? .post .nsfw ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn local_only_instance(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); Community::update( pool, data.community.id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::LocalOnlyPrivate), ..Default::default() }, ) .await?; let unauthenticated_query = PostQuery { ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(0, unauthenticated_query.len()); let authenticated_query = PostQuery { local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(3, authenticated_query.len()); let unauthenticated_post = PostView::read(pool, data.post.id, None, data.instance.id, false).await; assert!(unauthenticated_post.is_err()); let authenticated_post = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await; assert!(authenticated_post.is_ok()); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_local_user_banned_from_community(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Test that post view shows if local user is blocked from community let banned_from_comm_person = PersonInsertForm::test_form(data.instance.id, "jill"); let inserted_banned_from_comm_person = Person::create(pool, &banned_from_comm_person).await?; let inserted_banned_from_comm_local_user = LocalUser::create( pool, &LocalUserInsertForm::test_form(inserted_banned_from_comm_person.id), vec![], ) .await?; CommunityActions::ban( pool, &CommunityPersonBanForm::new(data.community.id, inserted_banned_from_comm_person.id), ) .await?; let post_view = PostView::read( pool, data.post.id, Some(&inserted_banned_from_comm_local_user), data.instance.id, false, ) .await?; assert!( post_view .community_actions .is_some_and(|x| x.received_ban_at.is_some()) ); Person::delete(pool, inserted_banned_from_comm_person.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_local_user_not_banned_from_community(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let post_view = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await?; assert!(post_view.community_actions.is_none()); Ok(()) } /// Use microseconds for date checks /// /// Necessary because postgres uses micros, but rust uses nanos fn micros(dt: DateTime) -> i64 { dt.timestamp_micros() } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_creator_banned(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let banned_person_form = PersonInsertForm::test_form(data.instance.id, "jill"); let banned_person = Person::create(pool, &banned_person_form).await?; let post_form = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new( "banned person post".to_string(), banned_person.id, data.community.id, ) }; let banned_post = Post::create(pool, &post_form).await?; let expires_at = Utc::now().checked_add_days(Days::new(1)); InstanceActions::ban( pool, &InstanceBanForm::new(banned_person.id, data.instance.id, expires_at), ) .await?; // Let john read their post let post_view = PostView::read( pool, banned_post.id, Some(&data.john.local_user), data.instance.id, false, ) .await?; assert!(post_view.creator_banned); // Make sure the expires at is correct assert_eq!( expires_at.map(micros), post_view.creator_ban_expires_at.map(micros) ); Person::delete(pool, banned_person.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_creator_community_banned(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let banned_person_form = PersonInsertForm::test_form(data.instance.id, "jarvis"); let banned_person = Person::create(pool, &banned_person_form).await?; let post_form = PostInsertForm { language_id: Some(LanguageId(1)), ..PostInsertForm::new( "banned jarvis post".to_string(), banned_person.id, data.community.id, ) }; let banned_post = Post::create(pool, &post_form).await?; let expires_at = Utc::now().checked_add_days(Days::new(1)); CommunityActions::ban( pool, &CommunityPersonBanForm { ban_expires_at: Some(expires_at), ..CommunityPersonBanForm::new(data.community.id, banned_person.id) }, ) .await?; // Let john read their post let post_view = PostView::read( pool, banned_post.id, Some(&data.john.local_user), data.instance.id, false, ) .await?; assert!(post_view.creator_banned_from_community); assert!(!post_view.creator_banned); // Make sure the expires at is correct assert_eq!( expires_at.map(micros), post_view.creator_community_ban_expires_at.map(micros) ); Person::delete(pool, banned_person.id).await?; Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn speed_check(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Make sure the post_view query is less than this time let duration_max = Duration::from_millis(120); // Create some dummy posts let num_posts = 1000; for x in 1..num_posts { let name = format!("post_{x}"); let url = Some(Url::parse(&format!("https://google.com/{name}"))?.into()); let post_form = PostInsertForm { url, ..PostInsertForm::new(name, data.tegan.person.id, data.community.id) }; Post::create(pool, &post_form).await?; } // Manually trigger and wait for a statistics update to ensure consistent and high amount of // accuracy in the statistics used for query planning println!("🧮 updating database statistics"); let conn = &mut get_conn(pool).await?; conn.batch_execute("ANALYZE;").await?; // Time how fast the query took let now = Instant::now(); PostQuery { sort: Some(PostSortType::Active), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; let elapsed = now.elapsed(); println!("Elapsed: {:.0?}", elapsed); assert!( elapsed.lt(&duration_max), "Query took {:.0?}, longer than the max of {:.0?}", elapsed, duration_max ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_no_comments_only(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Create a comment for a post let comment_form = CommentInsertForm::new(data.tegan.person.id, data.post.id, "a comment".to_owned()); Comment::create(pool, &comment_form, None).await?; // Make sure it doesnt come back with the no_comments option let post_listings_no_comments = PostQuery { sort: Some(PostSortType::New), no_comments_only: Some(true), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!( vec![POST_WITH_TAGS, POST_BY_BOT], names(&post_listings_no_comments) ); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_private_community(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Mark community as private Community::update( pool, data.community.id, &CommunityUpdateForm { visibility: Some(CommunityVisibility::Private), ..Default::default() }, ) .await?; // No posts returned without auth let read_post_listing = PostQuery { community_id: Some(data.community.id), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(0, read_post_listing.len()); let post_view = PostView::read(pool, data.post.id, None, data.instance.id, false).await; assert!(post_view.is_err()); // No posts returned for non-follower who is not admin data.tegan.local_user.admin = false; let read_post_listing = PostQuery { community_id: Some(data.community.id), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(0, read_post_listing.len()); let post_view = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await; assert!(post_view.is_err()); // Admin can view content without following data.tegan.local_user.admin = true; let read_post_listing = PostQuery { community_id: Some(data.community.id), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(3, read_post_listing.len()); let post_view = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, true, ) .await; assert!(post_view.is_ok()); data.tegan.local_user.admin = false; // User can view after following let follow_form = CommunityFollowerForm::new( data.community.id, data.tegan.person.id, CommunityFollowerState::Accepted, ); CommunityActions::follow(pool, &follow_form).await?; let read_post_listing = PostQuery { community_id: Some(data.community.id), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(3, read_post_listing.len()); let post_view = PostView::read( pool, data.post.id, Some(&data.tegan.local_user), data.instance.id, true, ) .await; assert!(post_view.is_ok()); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listings_hide_media(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // Make one post an image post Post::update( pool, data.bot_post.id, &PostUpdateForm { url_content_type: Some(Some(String::from("image/png"))), ..Default::default() }, ) .await?; // Make sure all the posts are returned when `hide_media` is unset let hide_media_listing = PostQuery { community_id: Some(data.community.id), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(3, hide_media_listing.len()); // Ensure the `hide_media` user setting is set let local_user_form = LocalUserUpdateForm { hide_media: Some(true), ..Default::default() }; LocalUser::update(pool, data.tegan.local_user.id, &local_user_form).await?; data.tegan.local_user.hide_media = true; // Ensure you don't see the image post let hide_media_listing = PostQuery { community_id: Some(data.community.id), local_user: Some(&data.tegan.local_user), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(2, hide_media_listing.len()); // Make sure the `hide_media` override works let hide_media_listing = PostQuery { community_id: Some(data.community.id), local_user: Some(&data.tegan.local_user), hide_media: Some(false), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(3, hide_media_listing.len()); Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_with_blocked_keywords(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); let name_blocked = format!("post_{POST_KEYWORD_BLOCKED}"); let name_blocked2 = format!("post2_{POST_KEYWORD_BLOCKED}2"); let url = Some(Url::parse(&format!("https://google.com/{POST_KEYWORD_BLOCKED}"))?.into()); let body = format!("post body with {POST_KEYWORD_BLOCKED}"); let name_not_blocked = "post_with_name_not_blocked".to_string(); let name_not_blocked2 = "post_with_name_not_blocked2".to_string(); let post_name_blocked = PostInsertForm::new( name_blocked.clone(), data.tegan.person.id, data.community.id, ); let post_body_blocked = PostInsertForm { body: Some(body), ..PostInsertForm::new( name_not_blocked.clone(), data.tegan.person.id, data.community.id, ) }; let post_url_blocked = PostInsertForm { url, ..PostInsertForm::new( name_not_blocked2.clone(), data.tegan.person.id, data.community.id, ) }; let post_name_blocked_but_not_body_and_url = PostInsertForm { body: Some("Some body".to_string()), url: Some(Url::parse("https://google.com")?.into()), ..PostInsertForm::new( name_blocked2.clone(), data.tegan.person.id, data.community.id, ) }; Post::create(pool, &post_name_blocked).await?; Post::create(pool, &post_body_blocked).await?; Post::create(pool, &post_url_blocked).await?; Post::create(pool, &post_name_blocked_but_not_body_and_url).await?; let keyword_blocks = Some(LocalUserKeywordBlock::read(pool, data.tegan.local_user.id).await?); let post_listings = PostQuery { local_user: Some(&data.tegan.local_user), keyword_blocks, ..Default::default() } .list(&data.site, pool) .await?; // Should not have any of the posts assert!(!names(&post_listings).contains(&name_blocked.as_str())); assert!(!names(&post_listings).contains(&name_blocked2.as_str())); assert!(!names(&post_listings).contains(&name_not_blocked.as_str())); assert!(!names(&post_listings).contains(&name_not_blocked2.as_str())); // Should contain not blocked posts assert!(names(&post_listings).contains(&POST_BY_BOT)); assert!(names(&post_listings).contains(&POST)); 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(); let post_view = PostView::read( pool, data.post_with_tags.id, Some(&data.tegan.local_user), data.instance.id, false, ) .await?; assert_eq!(2, post_view.tags.0.len()); assert_eq!(data.tag_1.name, post_view.tags.0[0].name); assert_eq!(data.tag_2.name, post_view.tags.0[1].name); assert_eq!(data.tag_1.color, post_view.tags.0[0].color); assert_eq!(data.tag_2.color, post_view.tags.0[1].color); 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 Ok(()) } #[test_context(Data)] #[tokio::test] #[serial] async fn post_listing_multi_community(data: &mut Data) -> LemmyResult<()> { let pool = &data.pool(); let pool = &mut pool.into(); // create two more communities with one post each let form = CommunityInsertForm::new( data.instance.id, "test_community_4".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community_1 = Community::create(pool, &form).await?; let form = PostInsertForm::new(POST.to_string(), data.tegan.person.id, community_1.id); let post_1 = Post::create(pool, &form).await?; let form = CommunityInsertForm::new( data.instance.id, "test_community_5".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let community_2 = Community::create(pool, &form).await?; let form = PostInsertForm::new(POST.to_string(), data.tegan.person.id, community_2.id); let post_2 = Post::create(pool, &form).await?; let form = MultiCommunityInsertForm::new( data.tegan.person.id, data.tegan.person.instance_id, "test multi".to_string(), String::new(), ); let multi = MultiCommunity::create(pool, &form).await?; MultiCommunity::update_entries(pool, multi.id, &vec![community_1.id, community_2.id]).await?; let listing = PostQuery { multi_community_id: Some(multi.id), ..Default::default() } .list(&data.site, pool) .await?; let listing_communities = listing .iter() .map(|l| l.community.id) .collect::>(); assert_eq!( HashSet::from([community_1.id, community_2.id]), listing_communities ); let listing_posts = listing.iter().map(|l| l.post.id).collect::>(); assert_eq!(HashSet::from([post_1.id, post_2.id]), listing_posts); let suggested = PostQuery { listing_type: Some(ListingType::Suggested), ..Default::default() } .list(&data.site, pool) .await?; assert!(suggested.is_empty()); let form = LocalSiteUpdateForm { suggested_multi_community_id: Some(Some(multi.id)), ..Default::default() }; LocalSite::update(pool, &form).await?; let suggested = PostQuery { listing_type: Some(ListingType::Suggested), ..Default::default() } .list(&data.site, pool) .await?; assert_eq!(listing.items, suggested.items); Ok(()) } ================================================ FILE: crates/db_views/post_comment_combined/Cargo.toml ================================================ [package] name = "lemmy_db_views_post_comment_combined" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [features] full = [ "diesel", "lemmy_db_schema/full", "lemmy_db_views_post/full", "lemmy_db_views_comment/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_views_post/ts-rs", "lemmy_db_views_comment/ts-rs", ] [dependencies] lemmy_db_views_post = { workspace = true } lemmy_db_views_comment = { workspace = true } lemmy_db_schema = { workspace = true } diesel = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } chrono = { workspace = true } [dev-dependencies] ================================================ FILE: crates/db_views/post_comment_combined/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::source::{ comment::{Comment, CommentActions}, community::{Community, CommunityActions}, community_tag::CommunityTagsView, images::ImageDetails, person::{Person, PersonActions}, post::{Post, PostActions}, }; use lemmy_db_views_comment::CommentView; use lemmy_db_views_post::PostView; use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::traits::InternalToCombinedView, lemmy_db_schema::utils::queries::selects::{ CreatorLocalHomeCommunityBanExpiresType, creator_ban_expires_from_community, creator_banned_from_community, creator_is_admin, creator_is_moderator, creator_local_home_community_ban_expires, creator_local_home_community_banned, local_user_can_mod, post_community_tags_fragment, }, }; #[cfg(feature = "full")] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Queryable, Selectable)] #[diesel(check_for_backend(diesel::pg::Pg))] /// A combined person_saved view pub struct PostCommentCombinedViewInternal { #[diesel(embed)] pub comment: Option, #[diesel(embed)] pub post: Post, #[diesel(embed)] pub item_creator: Person, #[diesel(embed)] pub community: Community, #[diesel(embed)] pub community_actions: Option, #[diesel(embed)] pub post_actions: Option, #[diesel(embed)] pub person_actions: Option, #[diesel(embed)] pub comment_actions: Option, #[diesel(embed)] pub image_details: Option, #[diesel(select_expression = creator_is_admin())] pub item_creator_is_admin: bool, #[diesel(select_expression = post_community_tags_fragment())] pub tags: CommunityTagsView, #[diesel(select_expression = local_user_can_mod())] pub can_mod: bool, #[diesel(select_expression = creator_local_home_community_banned())] pub creator_banned: bool, #[diesel( select_expression_type = CreatorLocalHomeCommunityBanExpiresType, select_expression = creator_local_home_community_ban_expires() )] pub creator_ban_expires_at: Option>, #[diesel(select_expression = creator_is_moderator())] pub creator_is_moderator: bool, #[diesel(select_expression = creator_banned_from_community())] pub creator_banned_from_community: bool, #[diesel(select_expression = creator_ban_expires_from_community())] pub creator_community_ban_expires_at: Option>, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] #[serde(tag = "type_", rename_all = "snake_case")] pub enum PostCommentCombinedView { Post(PostView), Comment(CommentView), } #[cfg(feature = "full")] impl InternalToCombinedView for PostCommentCombinedViewInternal { type CombinedView = PostCommentCombinedView; fn map_to_enum(self) -> Option { // Use for a short alias let v = self; if let Some(comment) = v.comment { Some(PostCommentCombinedView::Comment(CommentView { comment, post: v.post, community: v.community, creator: v.item_creator, community_actions: v.community_actions, comment_actions: v.comment_actions, person_actions: v.person_actions, creator_is_admin: v.item_creator_is_admin, tags: v.tags, can_mod: v.can_mod, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_is_moderator: v.creator_is_moderator, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } else { Some(PostCommentCombinedView::Post(PostView { post: v.post, community: v.community, creator: v.item_creator, image_details: v.image_details, community_actions: v.community_actions, post_actions: v.post_actions, person_actions: v.person_actions, creator_is_admin: v.item_creator_is_admin, tags: v.tags, can_mod: v.can_mod, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_is_moderator: v.creator_is_moderator, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } } } impl PostCommentCombinedView { /// Useful in combination with filter_map pub fn to_post_view(&self) -> Option<&PostView> { if let Self::Post(v) = self { Some(v) } else { None } } } ================================================ FILE: crates/db_views/private_message/Cargo.toml ================================================ [package] name = "lemmy_db_views_private_message" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } ================================================ FILE: crates/db_views/private_message/src/api.rs ================================================ use crate::PrivateMessageView; use lemmy_db_schema::newtypes::PrivateMessageId; use lemmy_db_schema_file::PersonId; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a private message. pub struct CreatePrivateMessage { pub content: String, pub recipient_id: PersonId, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete a private message. pub struct DeletePrivateMessage { pub private_message_id: PrivateMessageId, pub deleted: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edit a private message. pub struct EditPrivateMessage { pub private_message_id: PrivateMessageId, pub content: String, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A single private message response. pub struct PrivateMessageResponse { pub private_message_view: PrivateMessageView, } ================================================ FILE: crates/db_views/private_message/src/impls.rs ================================================ use crate::PrivateMessageView; use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; use lemmy_db_schema::{newtypes::PrivateMessageId, source::person::Person}; use lemmy_db_schema_file::{ aliases, schema::{instance_actions, person, person_actions, private_message}, }; use lemmy_diesel_utils::connection::{DbPool, get_conn}; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl PrivateMessageView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { let recipient_id = aliases::person1.field(person::id); let creator_join = person::table.on(private_message::creator_id.eq(person::id)); let recipient_join = aliases::person1.on(private_message::recipient_id.eq(recipient_id)); let person_actions_join = person_actions::table.on( person_actions::target_id .eq(private_message::creator_id) .and(person_actions::person_id.eq(recipient_id)), ); let instance_actions_join = instance_actions::table.on( instance_actions::instance_id .eq(person::instance_id) .and(instance_actions::person_id.eq(recipient_id)), ); private_message::table .inner_join(creator_join) .inner_join(recipient_join) .left_join(person_actions_join) .left_join(instance_actions_join) } pub async fn read( pool: &mut DbPool<'_>, private_message_id: PrivateMessageId, my_person: Option<&Person>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut pm = Self::joins() .filter(private_message::id.eq(private_message_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; pm.private_message.clear_deleted_by_recipient(my_person); Ok(pm) } } ================================================ FILE: crates/db_views/private_message/src/lib.rs ================================================ use lemmy_db_schema::source::{person::Person, private_message::PrivateMessage}; use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::Person1AliasAllColumnsTuple, lemmy_db_schema::utils::queries::selects::person1_select, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A private message view. pub struct PrivateMessageView { #[cfg_attr(feature = "full", diesel(embed))] pub private_message: PrivateMessage, #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel( select_expression_type = Person1AliasAllColumnsTuple, select_expression = person1_select() ) )] pub recipient: Person, } ================================================ FILE: crates/db_views/registration_applications/Cargo.toml ================================================ [package] name = "lemmy_db_views_registration_applications" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "extism", "extism-convert", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } extism = { workspace = true, optional = true } extism-convert = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_views/registration_applications/src/api.rs ================================================ use crate::RegistrationApplicationView; use lemmy_db_schema::newtypes::RegistrationApplicationId; use lemmy_db_schema_file::PersonId; use lemmy_diesel_utils::{pagination::PaginationCursor, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use {extism::ToBytes, extism_convert::Json}; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Approves a registration application. pub struct ApproveRegistrationApplication { pub id: RegistrationApplicationId, pub approve: bool, pub deny_reason: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Gets a registration application for a person pub struct GetRegistrationApplication { pub person_id: PersonId, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetches a list of registration applications. pub struct ListRegistrationApplications { /// Only shows the unread applications (IE those without an admin actor) pub unread_only: Option, pub page_cursor: Option, pub limit: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Register / Sign up to lemmy. pub struct Register { pub username: String, pub password: SensitiveString, pub password_verify: SensitiveString, pub show_nsfw: Option, /// email is mandatory if email verification is enabled on the server pub email: Option, /// The UUID of the captcha item. pub captcha_uuid: Option, /// Your captcha answer. pub captcha_answer: Option, /// A form field to trick signup bots. Should be None. pub honeypot: Option, /// An answer is mandatory if require application is enabled on the server pub answer: Option, /// If this is true the login is valid forever, otherwise it expires after one week. pub stay_logged_in: Option, } #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(ToBytes,))] #[cfg_attr(feature = "full", encoding(Json))] pub struct CaptchaAnswer { pub answer: String, pub uuid: String, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The response of an action done to a registration application. pub struct RegistrationApplicationResponse { pub registration_application: RegistrationApplicationView, } ================================================ FILE: crates/db_views/registration_applications/src/impls.rs ================================================ use crate::RegistrationApplicationView; use diesel::{ ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, SelectableHelper, dsl::count, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ newtypes::RegistrationApplicationId, source::registration_application::{ RegistrationApplication, registration_application_keys as key, }, utils::limit_fetch, }; use lemmy_db_schema_file::{ PersonId, aliases, schema::{local_user, person, registration_application}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, traits::Crud, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl PaginationCursorConversion for RegistrationApplicationView { type PaginatedType = RegistrationApplication; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.registration_application.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { RegistrationApplication::read(pool, RegistrationApplicationId(cursor.id()?)).await } } impl RegistrationApplicationView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { let local_user_join = local_user::table.on(registration_application::local_user_id.eq(local_user::id)); let creator_join = person::table.on(local_user::person_id.eq(person::id)); let admin_join = aliases::person1 .on(registration_application::admin_id.eq(aliases::person1.field(person::id).nullable())); registration_application::table .inner_join(local_user_join) .inner_join(creator_join) .left_join(admin_join) } pub async fn read(pool: &mut DbPool<'_>, id: RegistrationApplicationId) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(registration_application::id.eq(id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } pub async fn read_by_person(pool: &mut DbPool<'_>, person_id: PersonId) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(person::id.eq(person_id)) .select(Self::as_select()) .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } /// Returns the current unread registration_application count pub async fn get_unread_count( pool: &mut DbPool<'_>, verified_email_only: bool, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let mut query = Self::joins() .filter(RegistrationApplication::is_unread()) .select(count(registration_application::id)) .into_boxed(); if verified_email_only { query = query.filter(local_user::email_verified.eq(true)) } query .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } #[derive(Default)] pub struct RegistrationApplicationQuery { pub unread_only: Option, pub verified_email_only: Option, pub page_cursor: Option, pub limit: Option, } impl RegistrationApplicationQuery { pub async fn list( self, pool: &mut DbPool<'_>, ) -> LemmyResult> { let limit = limit_fetch(self.limit, None)?; let o = self; let mut query = RegistrationApplicationView::joins() .select(RegistrationApplicationView::as_select()) .limit(limit) .into_boxed(); if o.unread_only.unwrap_or_default() { query = query .filter(RegistrationApplication::is_unread()) .order_by(registration_application::published_at.asc()); } else { query = query.order_by(registration_application::published_at.desc()); } if o.verified_email_only.unwrap_or_default() { query = query.filter(local_user::email_verified.eq(true)) } // Sorting by published let paginated_query = RegistrationApplicationView::paginate(query, &o.page_cursor, SortDirection::Desc, pool, None) .await? .then_order_by(key::published_at); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, o.page_cursor) } } #[cfg(test)] mod tests { use crate::{RegistrationApplicationView, impls::RegistrationApplicationQuery}; use lemmy_db_schema::source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, person::{Person, PersonInsertForm}, registration_application::{ RegistrationApplication, RegistrationApplicationInsertForm, RegistrationApplicationUpdateForm, }, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_crud() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_person_form = PersonInsertForm::test_form(instance.id, "timmy_rav"); let timmy_person = Person::create(pool, &timmy_person_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form_admin(timmy_person.id); let _inserted_timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let sara_person_form = PersonInsertForm::test_form(instance.id, "sara_rav"); let sara_person = Person::create(pool, &sara_person_form).await?; let sara_local_user_form = LocalUserInsertForm::test_form(sara_person.id); let sara_local_user = LocalUser::create(pool, &sara_local_user_form, vec![]).await?; // Sara creates an application let sara_app_form = RegistrationApplicationInsertForm { local_user_id: sara_local_user.id, answer: "LET ME IIIIINN".to_string(), }; let sara_app = RegistrationApplication::create(pool, &sara_app_form).await?; let read_sara_app_view = RegistrationApplicationView::read(pool, sara_app.id).await?; let jess_person_form = PersonInsertForm::test_form(instance.id, "jess_rav"); let inserted_jess_person = Person::create(pool, &jess_person_form).await?; let jess_local_user_form = LocalUserInsertForm::test_form(inserted_jess_person.id); let jess_local_user = LocalUser::create(pool, &jess_local_user_form, vec![]).await?; // Sara creates an application let jess_app_form = RegistrationApplicationInsertForm { local_user_id: jess_local_user.id, answer: "LET ME IIIIINN".to_string(), }; let jess_app = RegistrationApplication::create(pool, &jess_app_form).await?; let read_jess_app_view = RegistrationApplicationView::read(pool, jess_app.id).await?; let mut expected_sara_app_view = RegistrationApplicationView { registration_application: sara_app.clone(), creator_local_user: LocalUser { id: sara_local_user.id, person_id: sara_local_user.person_id, email: sara_local_user.email, show_nsfw: sara_local_user.show_nsfw, blur_nsfw: sara_local_user.blur_nsfw, theme: sara_local_user.theme, default_post_sort_type: sara_local_user.default_post_sort_type, default_comment_sort_type: sara_local_user.default_comment_sort_type, default_listing_type: sara_local_user.default_listing_type, default_items_per_page: sara_local_user.default_items_per_page, interface_language: sara_local_user.interface_language, show_avatars: sara_local_user.show_avatars, send_notifications_to_email: sara_local_user.send_notifications_to_email, show_bot_accounts: sara_local_user.show_bot_accounts, show_read_posts: sara_local_user.show_read_posts, email_verified: sara_local_user.email_verified, accepted_application: sara_local_user.accepted_application, totp_2fa_secret: sara_local_user.totp_2fa_secret, password_encrypted: sara_local_user.password_encrypted, open_links_in_new_tab: sara_local_user.open_links_in_new_tab, infinite_scroll_enabled: sara_local_user.infinite_scroll_enabled, post_listing_mode: sara_local_user.post_listing_mode, totp_2fa_enabled: sara_local_user.totp_2fa_enabled, enable_animated_images: sara_local_user.enable_animated_images, enable_private_messages: sara_local_user.enable_private_messages, collapse_bot_comments: sara_local_user.collapse_bot_comments, last_donation_notification_at: sara_local_user.last_donation_notification_at, show_upvotes: sara_local_user.show_upvotes, show_downvotes: sara_local_user.show_downvotes, admin: sara_local_user.admin, auto_mark_fetched_posts_as_read: sara_local_user.auto_mark_fetched_posts_as_read, hide_media: sara_local_user.hide_media, default_post_time_range_seconds: sara_local_user.default_post_time_range_seconds, show_score: sara_local_user.show_score, show_upvote_percentage: sara_local_user.show_upvote_percentage, show_person_votes: sara_local_user.show_person_votes, }, creator: Person { id: sara_person.id, name: sara_person.name.clone(), display_name: None, published_at: sara_person.published_at, avatar: None, ap_id: sara_person.ap_id.clone(), local: true, deleted: false, bot_account: false, bio: None, banner: None, updated_at: None, inbox_url: sara_person.inbox_url.clone(), matrix_user_id: None, instance_id: instance.id, private_key: sara_person.private_key, public_key: sara_person.public_key, last_refreshed_at: sara_person.last_refreshed_at, post_count: 0, post_score: 0, comment_count: 0, comment_score: 0, }, admin: None, }; assert_eq!(read_sara_app_view, expected_sara_app_view); // Do a batch read of the applications let apps = RegistrationApplicationQuery { unread_only: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!( apps, [expected_sara_app_view.clone(), read_jess_app_view.clone()] ); // Make sure the counts are correct let unread_count = RegistrationApplicationView::get_unread_count(pool, false).await?; assert_eq!(unread_count, 2); // Approve the application let approve_form = RegistrationApplicationUpdateForm { admin_id: Some(Some(timmy_person.id)), deny_reason: None, // Normally this would be Utc::now() updated_at: None, }; RegistrationApplication::update(pool, sara_app.id, &approve_form).await?; // Update the local_user row let approve_local_user_form = LocalUserUpdateForm { accepted_application: Some(true), ..Default::default() }; LocalUser::update(pool, sara_local_user.id, &approve_local_user_form).await?; let read_sara_app_view_after_approve = RegistrationApplicationView::read(pool, sara_app.id).await?; // Make sure the columns changed expected_sara_app_view .creator_local_user .accepted_application = true; expected_sara_app_view.registration_application.admin_id = Some(timmy_person.id); expected_sara_app_view.admin = Some(Person { id: timmy_person.id, name: timmy_person.name.clone(), display_name: None, published_at: timmy_person.published_at, avatar: None, ap_id: timmy_person.ap_id.clone(), local: true, deleted: false, bot_account: false, bio: None, banner: None, updated_at: None, inbox_url: timmy_person.inbox_url.clone(), matrix_user_id: None, instance_id: instance.id, private_key: timmy_person.private_key, public_key: timmy_person.public_key, last_refreshed_at: timmy_person.last_refreshed_at, post_count: 0, post_score: 0, comment_count: 0, comment_score: 0, }); assert_eq!(read_sara_app_view_after_approve, expected_sara_app_view); // Do a batch read of apps again // It should show only jessicas which is unresolved let apps_after_resolve = RegistrationApplicationQuery { unread_only: Some(true), ..Default::default() } .list(pool) .await? .items; assert_eq!(apps_after_resolve, vec![read_jess_app_view]); // Make sure the counts are correct let unread_count_after_approve = RegistrationApplicationView::get_unread_count(pool, false).await?; assert_eq!(unread_count_after_approve, 1); // Make sure the not undenied_only has all the apps let all_apps = RegistrationApplicationQuery::default().list(pool).await?; assert_eq!(all_apps.len(), 2); Person::delete(pool, timmy_person.id).await?; Person::delete(pool, sara_person.id).await?; Person::delete(pool, inserted_jess_person.id).await?; Instance::delete(pool, instance.id).await?; Ok(()) } } ================================================ FILE: crates/db_views/registration_applications/src/lib.rs ================================================ use lemmy_db_schema::source::{ local_user::LocalUser, person::Person, registration_application::RegistrationApplication, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{NullableExpressionMethods, Queryable, Selectable, helper_types::Nullable}, lemmy_db_schema::{Person1AliasAllColumnsTuple, utils::queries::selects::person1_select}, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[skip_serializing_none] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A registration application view. pub struct RegistrationApplicationView { #[cfg_attr(feature = "full", diesel(embed))] pub registration_application: RegistrationApplication, #[cfg_attr(feature = "full", diesel(embed))] pub creator_local_user: LocalUser, #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel( select_expression_type = Nullable, select_expression = person1_select().nullable() ) )] pub admin: Option, } ================================================ FILE: crates/db_views/report_combined/Cargo.toml ================================================ [package] name = "lemmy_db_views_report_combined" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true publish = false [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_db_views_report_combined_sql", "lemmy_diesel_utils/full", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_views_local_user = { workspace = true } lemmy_db_views_report_combined_sql = { workspace = true, optional = true } lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } chrono = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/report_combined/src/api.rs ================================================ use crate::{CommentReportView, CommunityReportView, PostReportView, PrivateMessageReportView}; use lemmy_db_schema::{ ReportType, newtypes::{ CommentId, CommentReportId, CommunityId, CommunityReportId, PostId, PostReportId, PrivateMessageId, PrivateMessageReportId, }, }; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// List reports. pub struct ListReports { /// Only shows the unresolved reports pub unresolved_only: Option, /// Filter the type of report. pub type_: Option, /// Filter by the post id. Can return either comment or post reports. pub post_id: Option, /// if no community is given, it returns reports for all communities moderated by the auth user pub community_id: Option, pub page_cursor: Option, pub limit: Option, /// Only for admins: also show reports with `violates_instance_rules=false` pub show_community_rule_violations: Option, /// If true, view all your created reports. Works for non-admins/mods also. pub my_reports_only: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The comment report response. pub struct CommentReportResponse { pub comment_report_view: CommentReportView, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A community report response. pub struct CommunityReportResponse { pub community_report_view: CommunityReportView, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Report a comment. pub struct CreateCommentReport { pub comment_id: CommentId, pub reason: String, /// The comment violates rules of the local instance. This report will only be shown to local /// admins, not to community mods and will not be federated. pub violates_instance_rules: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a report for a community. pub struct CreateCommunityReport { pub community_id: CommunityId, pub reason: String, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a post report. pub struct CreatePostReport { pub post_id: PostId, pub reason: String, /// The post violates rules of the local instance. This report will only be shown to local /// admins, not to community mods and will not be federated. pub violates_instance_rules: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Resolve a comment report (only doable by mods). pub struct ResolveCommentReport { pub report_id: CommentReportId, pub resolved: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Resolve a community report. pub struct ResolveCommunityReport { pub report_id: CommunityReportId, pub resolved: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Resolve a post report (mods only). pub struct ResolvePostReport { pub report_id: PostReportId, pub resolved: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Resolve a private message report. pub struct ResolvePrivateMessageReport { pub report_id: PrivateMessageReportId, pub resolved: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a report for a private message. pub struct CreatePrivateMessageReport { pub private_message_id: PrivateMessageId, pub reason: String, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A private message report response. pub struct PrivateMessageReportResponse { pub private_message_report_view: PrivateMessageReportView, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The post report response. pub struct PostReportResponse { pub post_report_view: PostReportView, } ================================================ FILE: crates/db_views/report_combined/src/impls.rs ================================================ use crate::{ CommentReportView, CommunityReportView, LocalUserView, PostReportView, PrivateMessageReportView, ReportCombinedView, ReportCombinedViewInternal, }; use chrono::{DateTime, Days, Utc}; use diesel::{ BoolExpressionMethods, ExpressionMethods, PgExpressionMethods, QueryDsl, SelectableHelper, dsl::not, }; use diesel_async::RunQueryDsl; use i_love_jesus::asc_if; use lemmy_db_schema::{ ReportType, newtypes::{ CommentReportId, CommunityId, CommunityReportId, PostId, PostReportId, PrivateMessageReportId, }, source::{ combined::report::{ReportCombined, report_combined_keys as key}, person::Person, }, traits::InternalToCombinedView, utils::limit_fetch, }; use lemmy_db_schema_file::{ aliases, schema::{ comment_report, community, community_actions, person, post, post_report, report_combined, }, }; use lemmy_db_views_report_combined_sql::report_combined_joins; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; impl ReportCombinedViewInternal { pub async fn read_comment_report( pool: &mut DbPool<'_>, report_id: CommentReportId, my_person: &Person, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = report_combined_joins(my_person.id, my_person.instance_id) .filter(report_combined::comment_report_id.eq(report_id)) .select(ReportCombinedViewInternal::as_select()) .first(conn) .await?; let res = InternalToCombinedView::map_to_enum(res); let Some(ReportCombinedView::Comment(c)) = res else { return Err(LemmyErrorType::NotFound.into()); }; Ok(c) } pub async fn read_post_report( pool: &mut DbPool<'_>, report_id: PostReportId, my_person: &Person, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = report_combined_joins(my_person.id, my_person.instance_id) .filter(report_combined::post_report_id.eq(report_id)) .select(ReportCombinedViewInternal::as_select()) .first(conn) .await?; let res = InternalToCombinedView::map_to_enum(res); let Some(ReportCombinedView::Post(p)) = res else { return Err(LemmyErrorType::NotFound.into()); }; Ok(p) } pub async fn read_community_report( pool: &mut DbPool<'_>, report_id: CommunityReportId, my_person: &Person, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = report_combined_joins(my_person.id, my_person.instance_id) .filter(report_combined::community_report_id.eq(report_id)) .select(ReportCombinedViewInternal::as_select()) .first(conn) .await?; let res = InternalToCombinedView::map_to_enum(res); let Some(ReportCombinedView::Community(c)) = res else { return Err(LemmyErrorType::NotFound.into()); }; Ok(c) } pub async fn read_private_message_report( pool: &mut DbPool<'_>, report_id: PrivateMessageReportId, my_person: &Person, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let res = report_combined_joins(my_person.id, my_person.instance_id) .filter(report_combined::private_message_report_id.eq(report_id)) .select(ReportCombinedViewInternal::as_select()) .first(conn) .await?; let res = InternalToCombinedView::map_to_enum(res); let Some(ReportCombinedView::PrivateMessage(pm)) = res else { return Err(LemmyErrorType::NotFound.into()); }; Ok(pm) } /// returns the current unresolved report count for the communities you mod pub async fn get_report_count(pool: &mut DbPool<'_>, user: &LocalUserView) -> LemmyResult { use diesel::dsl::count; let conn = &mut get_conn(pool).await?; let mut query = report_combined_joins(user.person.id, user.person.instance_id) .filter(not(report_combined::resolved)) .select(count(report_combined::id)) .into_boxed(); if user.local_user.admin { query = query.filter(filter_admin_reports(Utc::now() - Days::new(3))); } else { query = query.filter(filter_mod_reports()); } query .first::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl PaginationCursorConversion for ReportCombinedView { type PaginatedType = ReportCombined; fn to_cursor(&self) -> CursorData { let (prefix, id) = match &self { ReportCombinedView::Comment(v) => ('C', v.comment_report.id.0), ReportCombinedView::Post(v) => ('P', v.post_report.id.0), ReportCombinedView::PrivateMessage(v) => ('M', v.private_message_report.id.0), ReportCombinedView::Community(v) => ('Y', v.community_report.id.0), }; CursorData::new_with_prefix(prefix, id) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let (prefix, id) = cursor.id_and_prefix()?; let mut query = report_combined::table .select(Self::PaginatedType::as_select()) .into_boxed(); query = match prefix { 'C' => query.filter(report_combined::comment_report_id.eq(id)), 'P' => query.filter(report_combined::post_report_id.eq(id)), 'M' => query.filter(report_combined::private_message_report_id.eq(id)), 'Y' => query.filter(report_combined::community_report_id.eq(id)), _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()), }; let token = query.first(conn).await?; Ok(token) } } #[derive(Default)] pub struct ReportCombinedQuery { pub type_: Option, pub post_id: Option, pub community_id: Option, pub unresolved_only: Option, /// For admins, also show reports with `violates_instance_rules=false` pub show_community_rule_violations: Option, pub page_cursor: Option, pub my_reports_only: Option, pub limit: Option, } impl ReportCombinedQuery { pub async fn list( self, pool: &mut DbPool<'_>, user: &LocalUserView, ) -> LemmyResult> { let limit = limit_fetch(self.limit, None)?; let report_creator = aliases::person1.field(person::id); let mut query = report_combined_joins(user.person.id, user.person.instance_id) .select(ReportCombinedViewInternal::as_select()) .limit(limit) .into_boxed(); if let Some(community_id) = self.community_id { query = query.filter( community::id .eq(community_id) .and(report_combined::community_report_id.is_null()), ); } if user.local_user.admin { let show_community_rule_violations = self.show_community_rule_violations.unwrap_or_default(); if !show_community_rule_violations { query = query.filter(filter_admin_reports(Utc::now() - Days::new(3))); } } else { query = query.filter(filter_mod_reports()); } if let Some(post_id) = self.post_id { query = query.filter(post::id.eq(post_id)); } if self.my_reports_only.unwrap_or_default() { query = query.filter(report_creator.eq(user.person.id)); } if let Some(type_) = self.type_ { query = match type_ { ReportType::All => query, ReportType::Posts => query.filter(report_combined::post_report_id.is_not_null()), ReportType::Comments => query.filter(report_combined::comment_report_id.is_not_null()), ReportType::PrivateMessages => { query.filter(report_combined::private_message_report_id.is_not_null()) } ReportType::Communities => query.filter(report_combined::community_report_id.is_not_null()), } } // If viewing all reports, order by newest, but if viewing unresolved only, show the oldest // first (FIFO) let unresolved_only = self.unresolved_only.unwrap_or_default(); let sort_direction = asc_if(unresolved_only); if unresolved_only { query = query.filter(not(report_combined::resolved)); }; // Sorting by published let paginated_query = ReportCombinedView::paginate(query, &self.page_cursor, sort_direction, pool, None) .await? .then_order_by(key::published_at) // Tie breaker .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await?; // Map the query results to the enum let out = res .into_iter() .filter_map(InternalToCombinedView::map_to_enum) .collect(); paginate_response(out, limit, self.page_cursor) } } /// Mods can only see reports for posts/comments inside of communities where they are moderator, /// and which have `violates_instance_rules == false`. #[diesel::dsl::auto_type] fn filter_mod_reports() -> _ { community_actions::became_moderator_at .is_not_null() // Reporting a community or private message must go to admins .and(report_combined::community_report_id.is_null()) .and(report_combined::private_message_report_id.is_null()) .and(filter_violates_instance_rules().is_distinct_from(true)) } /// Admins can see reports intended for them, or mod reports older than 3 days. Also reports /// on communities, person and private messages. #[diesel::dsl::auto_type] fn filter_admin_reports(interval: DateTime) -> _ { filter_violates_instance_rules() .or(report_combined::published_at.lt(interval)) // Also show community reports where the admin is a community mod .or(community_actions::became_moderator_at.is_not_null()) } /// Filter reports which are only for admins (either post/comment report with /// `violates_instance_rules=true`, or report on a community/person/private message. #[diesel::dsl::auto_type] fn filter_violates_instance_rules() -> _ { post_report::violates_instance_rules .or(comment_report::violates_instance_rules) .or(report_combined::community_report_id.is_not_null()) .or(report_combined::private_message_report_id.is_not_null()) } impl InternalToCombinedView for ReportCombinedViewInternal { type CombinedView = ReportCombinedView; fn map_to_enum(self) -> Option { // Use for a short alias let v = self; if let (Some(post_report), Some(post), Some(community), Some(post_creator)) = ( v.post_report, v.post.clone(), v.community.clone(), v.creator.clone(), ) { Some(ReportCombinedView::Post(PostReportView { post_report, post, community, post_creator, creator: v.report_creator, resolver: v.resolver, community_actions: v.community_actions, post_actions: v.post_actions, person_actions: v.person_actions, creator_is_admin: v.creator_is_admin, creator_is_moderator: v.creator_is_moderator, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } else if let ( Some(comment_report), Some(comment), Some(post), Some(community), Some(comment_creator), ) = ( v.comment_report, v.comment, v.post, v.community.clone(), v.creator.clone(), ) { Some(ReportCombinedView::Comment(CommentReportView { comment_report, comment, post, community, creator: v.report_creator, comment_creator, resolver: v.resolver, community_actions: v.community_actions, comment_actions: v.comment_actions, person_actions: v.person_actions, creator_is_admin: v.creator_is_admin, creator_is_moderator: v.creator_is_moderator, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } else if let ( Some(private_message_report), Some(private_message), Some(private_message_creator), ) = (v.private_message_report, v.private_message, v.creator) { Some(ReportCombinedView::PrivateMessage( PrivateMessageReportView { private_message_report, private_message, creator: v.report_creator, private_message_creator, resolver: v.resolver, creator_is_admin: v.creator_is_admin, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, }, )) } else if let (Some(community), Some(community_report)) = (v.community, v.community_report) { Some(ReportCombinedView::Community(CommunityReportView { community_report, community, creator: v.report_creator, resolver: v.resolver, creator_is_admin: v.creator_is_admin, creator_is_moderator: v.creator_is_moderator, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } else { None } } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::{ LocalUserView, ReportCombinedView, ReportCombinedViewInternal, impls::ReportCombinedQuery, }; use chrono::{Days, Utc}; use diesel::{ExpressionMethods, QueryDsl, update}; use diesel_async::RunQueryDsl; use lemmy_db_schema::{ ReportType, assert_length, source::{ comment::{Comment, CommentInsertForm}, comment_report::{CommentReport, CommentReportForm}, community::{Community, CommunityActions, CommunityInsertForm, CommunityModeratorForm}, community_report::{CommunityReport, CommunityReportForm}, instance::{Instance, InstanceActions, InstanceBanForm}, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostInsertForm}, post_report::{PostReport, PostReportForm}, private_message::{PrivateMessage, PrivateMessageInsertForm}, private_message_report::{PrivateMessageReport, PrivateMessageReportForm}, }, traits::{Bannable, Reportable}, }; use lemmy_db_schema_file::schema::report_combined; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests, get_conn}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; struct Data { instance: Instance, timmy: Person, sara: Person, jessica: Person, timmy_view: LocalUserView, admin_view: LocalUserView, community: Community, post: Post, post_2: Post, comment: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let timmy_form = PersonInsertForm::test_form(inserted_instance.id, "timmy_rcv"); let inserted_timmy = Person::create(pool, &timmy_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form(inserted_timmy.id); let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let timmy_view = LocalUserView { local_user: timmy_local_user, person: inserted_timmy.clone(), banned: false, ban_expires_at: None, }; // Make an admin, to be able to see private message reports. let admin_form = PersonInsertForm::test_form(inserted_instance.id, "admin_rcv"); let inserted_admin = Person::create(pool, &admin_form).await?; let admin_local_user_form = LocalUserInsertForm::test_form_admin(inserted_admin.id); let admin_local_user = LocalUser::create(pool, &admin_local_user_form, vec![]).await?; let admin_view = LocalUserView { local_user: admin_local_user, person: inserted_admin.clone(), banned: false, ban_expires_at: None, }; let sara_form = PersonInsertForm::test_form(inserted_instance.id, "sara_rcv"); let inserted_sara = Person::create(pool, &sara_form).await?; let jessica_form = PersonInsertForm::test_form(inserted_instance.id, "jessica_mrv"); let inserted_jessica = Person::create(pool, &jessica_form).await?; let community_form = CommunityInsertForm::new( inserted_instance.id, "test community crv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &community_form).await?; // Make timmy a mod let timmy_moderator_form = CommunityModeratorForm::new(inserted_community.id, inserted_timmy.id); CommunityActions::join(pool, &timmy_moderator_form).await?; let post_form = PostInsertForm::new( "A test post crv".into(), inserted_timmy.id, inserted_community.id, ); let inserted_post = Post::create(pool, &post_form).await?; let new_post_2 = PostInsertForm::new( "A test post crv 2".into(), inserted_timmy.id, inserted_community.id, ); let inserted_post_2 = Post::create(pool, &new_post_2).await?; // Timmy creates a comment let comment_form = CommentInsertForm::new( inserted_timmy.id, inserted_post.id, "A test comment rv".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; Ok(Data { instance: inserted_instance, timmy: inserted_timmy, sara: inserted_sara, jessica: inserted_jessica, admin_view, timmy_view, community: inserted_community, post: inserted_post, post_2: inserted_post_2, comment: inserted_comment, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn combined() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Sara reports the community let sara_report_community_form = CommunityReportForm { creator_id: data.sara.id, community_id: data.community.id, original_community_name: data.community.name.clone(), original_community_title: data.community.title.clone(), original_community_banner: None, original_community_summary: None, original_community_sidebar: None, original_community_icon: None, reason: "from sara".into(), }; CommunityReport::report(pool, &sara_report_community_form).await?; // sara reports the post let sara_report_post_form = PostReportForm { creator_id: data.sara.id, post_id: data.post.id, original_post_name: "Orig post".into(), original_post_url: None, original_post_body: None, reason: "from sara".into(), violates_instance_rules: false, }; let inserted_post_report = PostReport::report(pool, &sara_report_post_form).await?; // Sara reports the comment let sara_report_comment_form = CommentReportForm { creator_id: data.sara.id, comment_id: data.comment.id, original_comment_text: "A test comment rv".into(), reason: "from sara".into(), violates_instance_rules: false, }; CommentReport::report(pool, &sara_report_comment_form).await?; // Timmy creates a private message let pm_form = PrivateMessageInsertForm::new( data.timmy.id, data.sara.id, "something offensive crv".to_string(), ); let inserted_pm = PrivateMessage::create(pool, &pm_form).await?; // sara reports private message let pm_report_form = PrivateMessageReportForm { creator_id: data.sara.id, original_pm_text: inserted_pm.content.clone(), private_message_id: inserted_pm.id, reason: "its offensive".to_string(), }; PrivateMessageReport::report(pool, &pm_report_form).await?; // Do a batch read of admins reports let reports = ReportCombinedQuery { show_community_rule_violations: Some(true), ..Default::default() } .list(pool, &data.admin_view) .await?; assert_length!(4, reports); // Make sure the report types are correct if let ReportCombinedView::Community(v) = &reports[3] { assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let ReportCombinedView::Post(v) = &reports[2] { assert_eq!(data.post.id, v.post.id); assert_eq!(data.sara.id, v.creator.id); assert_eq!(data.timmy.id, v.post_creator.id); } else { panic!("wrong type"); } if let ReportCombinedView::Comment(v) = &reports[1] { assert_eq!(data.comment.id, v.comment.id); assert_eq!(data.post.id, v.post.id); assert_eq!(data.timmy.id, v.comment_creator.id); } else { panic!("wrong type"); } if let ReportCombinedView::PrivateMessage(v) = &reports[0] { assert_eq!(inserted_pm.id, v.private_message.id); } else { panic!("wrong type"); } let report_count_mod = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(2, report_count_mod); let report_count_admin = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view).await?; assert_eq!(2, report_count_admin); // Make sure the type_ filter is working let reports_by_type = ReportCombinedQuery { type_: Some(ReportType::Posts), ..Default::default() } .list(pool, &data.timmy_view) .await?; assert_length!(1, reports_by_type); // Filter by the post id // Should be 2, for the post, and the comment on that post let reports_by_post_id = ReportCombinedQuery { post_id: Some(data.post.id), ..Default::default() } .list(pool, &data.timmy_view) .await?; assert_length!(2, reports_by_post_id); // Timmy should only see 2 reports, since they're not an admin, // but they do mod the community let timmys_reports = ReportCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_length!(2, timmys_reports); // Make sure the report types are correct if let ReportCombinedView::Post(v) = &timmys_reports[1] { assert_eq!(data.post.id, v.post.id); assert_eq!(data.sara.id, v.creator.id); assert_eq!(data.timmy.id, v.post_creator.id); } else { panic!("wrong type"); } if let ReportCombinedView::Comment(v) = &timmys_reports[0] { assert_eq!(data.comment.id, v.comment.id); assert_eq!(data.post.id, v.post.id); assert_eq!(data.timmy.id, v.comment_creator.id); } else { panic!("wrong type"); } let report_count_timmy = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(2, report_count_timmy); // Resolve the post report PostReport::update_resolved(pool, inserted_post_report.id, data.timmy.id, true).await?; // Do a batch read of timmys reports // It should only show saras, which is unresolved let reports_after_resolve = ReportCombinedQuery { unresolved_only: Some(true), ..Default::default() } .list(pool, &data.timmy_view) .await?; assert_length!(1, reports_after_resolve); // Make sure the counts are correct let report_count_after_resolved = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(1, report_count_after_resolved); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn private_message_reports() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // timmy sends private message to jessica let pm_form = PrivateMessageInsertForm::new( data.timmy.id, data.jessica.id, "something offensive".to_string(), ); let pm = PrivateMessage::create(pool, &pm_form).await?; // jessica reports private message let pm_report_form = PrivateMessageReportForm { creator_id: data.jessica.id, original_pm_text: pm.content.clone(), private_message_id: pm.id, reason: "its offensive".to_string(), }; let pm_report = PrivateMessageReport::report(pool, &pm_report_form).await?; let reports = ReportCombinedQuery { show_community_rule_violations: Some(true), ..Default::default() } .list(pool, &data.admin_view) .await?; assert_length!(1, reports); if let ReportCombinedView::PrivateMessage(v) = &reports[0] { assert!(!v.private_message_report.resolved); assert_eq!(data.timmy.name, v.private_message_creator.name); assert_eq!(data.jessica.name, v.creator.name); assert_eq!(pm_report.reason, v.private_message_report.reason); assert_eq!(pm.content, v.private_message.content); } else { panic!("wrong type"); } // admin resolves the report (after taking appropriate action) PrivateMessageReport::update_resolved(pool, pm_report.id, data.admin_view.person.id, true) .await?; let reports = ReportCombinedQuery::default() .list(pool, &data.admin_view) .await?; assert_length!(1, reports); if let ReportCombinedView::PrivateMessage(v) = &reports[0] { assert!(v.private_message_report.resolved); assert!(v.resolver.is_some()); assert_eq!( Some(&data.admin_view.person.name), v.resolver.as_ref().map(|r| &r.name) ); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn post_reports() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // sara reports let sara_report_form = PostReportForm { creator_id: data.sara.id, post_id: data.post.id, original_post_name: "Orig post".into(), original_post_url: None, original_post_body: None, reason: "from sara".into(), violates_instance_rules: false, }; PostReport::report(pool, &sara_report_form).await?; // jessica reports let jessica_report_form = PostReportForm { creator_id: data.jessica.id, post_id: data.post_2.id, original_post_name: "Orig post".into(), original_post_url: None, original_post_body: None, reason: "from jessica".into(), violates_instance_rules: false, }; let inserted_jessica_report = PostReport::report(pool, &jessica_report_form).await?; let read_jessica_report_view = ReportCombinedViewInternal::read_post_report(pool, inserted_jessica_report.id, &data.timmy) .await?; // Make sure the triggers are reading the aggregates correctly. let agg_1 = Post::read(pool, data.post.id).await?; let agg_2 = Post::read(pool, data.post_2.id).await?; assert_eq!( read_jessica_report_view.post_report, inserted_jessica_report ); assert_eq!(read_jessica_report_view.post.id, data.post_2.id); assert_eq!(read_jessica_report_view.community.id, data.community.id); assert_eq!(read_jessica_report_view.creator.id, data.jessica.id); assert_eq!(read_jessica_report_view.post_creator.id, data.timmy.id); assert_eq!(read_jessica_report_view.resolver, None); assert_eq!(agg_1.report_count, 1); assert_eq!(agg_1.unresolved_report_count, 1); assert_eq!(agg_2.report_count, 1); assert_eq!(agg_2.unresolved_report_count, 1); // Do a batch read of timmys reports let reports = ReportCombinedQuery::default() .list(pool, &data.timmy_view) .await?; if let ReportCombinedView::Post(v) = &reports[1] { assert_eq!(v.creator.id, data.sara.id); } else { panic!("wrong type"); } if let ReportCombinedView::Post(v) = &reports[0] { assert_eq!(v.creator.id, data.jessica.id); } else { panic!("wrong type"); } // Make sure the counts are correct let report_count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(2, report_count); // Pretend the post was removed, and resolve all reports for that object. // This is called manually in the API for post removals PostReport::resolve_all_for_object(pool, inserted_jessica_report.post_id, data.timmy.id) .await?; let read_jessica_report_view_after_resolve = ReportCombinedViewInternal::read_post_report(pool, inserted_jessica_report.id, &data.timmy) .await?; assert!(read_jessica_report_view_after_resolve.post_report.resolved); assert_eq!( read_jessica_report_view_after_resolve .post_report .resolver_id, Some(data.timmy.id) ); assert_eq!( read_jessica_report_view_after_resolve .resolver .map(|r| r.id), Some(data.timmy.id) ); // Make sure the unresolved_post report got decremented in the trigger let agg_2 = Post::read(pool, data.post_2.id).await?; assert_eq!(agg_2.report_count, 1); assert_eq!(agg_2.unresolved_report_count, 0); // Make sure the other unresolved report isn't changed let agg_1 = Post::read(pool, data.post.id).await?; assert_eq!(agg_1.report_count, 1); assert_eq!(agg_1.unresolved_report_count, 1); // Do a batch read of timmys reports // It should only show saras, which is unresolved let reports_after_resolve = ReportCombinedQuery { unresolved_only: Some(true), ..Default::default() } .list(pool, &data.timmy_view) .await?; if let ReportCombinedView::Post(v) = &reports_after_resolve[0] { assert_length!(1, reports_after_resolve); assert_eq!(v.creator.id, data.sara.id); } else { panic!("wrong type"); } // Make sure the counts are correct let report_count_after_resolved = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(1, report_count_after_resolved); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn comment_reports() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // sara reports let sara_report_form = CommentReportForm { creator_id: data.sara.id, comment_id: data.comment.id, original_comment_text: "this was it at time of creation".into(), reason: "from sara".into(), violates_instance_rules: false, }; CommentReport::report(pool, &sara_report_form).await?; // jessica reports let jessica_report_form = CommentReportForm { creator_id: data.jessica.id, comment_id: data.comment.id, original_comment_text: "this was it at time of creation".into(), reason: "from jessica".into(), violates_instance_rules: false, }; let inserted_jessica_report = CommentReport::report(pool, &jessica_report_form).await?; let comment = Comment::read(pool, data.comment.id).await?; assert_eq!(comment.report_count, 2); let read_jessica_report_view = ReportCombinedViewInternal::read_comment_report( pool, inserted_jessica_report.id, &data.timmy, ) .await?; assert_eq!(read_jessica_report_view.comment.unresolved_report_count, 2); // Do a batch read of timmys reports let reports = ReportCombinedQuery::default() .list(pool, &data.timmy_view) .await?; if let ReportCombinedView::Comment(v) = &reports[0] { assert_eq!(v.creator.id, data.jessica.id); } else { panic!("wrong type"); } if let ReportCombinedView::Comment(v) = &reports[1] { assert_eq!(v.creator.id, data.sara.id); } else { panic!("wrong type"); } // Make sure the counts are correct let report_count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(2, report_count); // Resolve the report CommentReport::update_resolved(pool, inserted_jessica_report.id, data.timmy.id, true).await?; let read_jessica_report_view_after_resolve = ReportCombinedViewInternal::read_comment_report( pool, inserted_jessica_report.id, &data.timmy, ) .await?; assert!( read_jessica_report_view_after_resolve .comment_report .resolved ); assert_eq!( read_jessica_report_view_after_resolve .comment_report .resolver_id, Some(data.timmy.id) ); assert_eq!( read_jessica_report_view_after_resolve .resolver .map(|r| r.id), Some(data.timmy.id) ); // Do a batch read of timmys reports // It should only show saras, which is unresolved let reports_after_resolve = ReportCombinedQuery { unresolved_only: Some(true), ..Default::default() } .list(pool, &data.timmy_view) .await?; if let ReportCombinedView::Comment(v) = &reports_after_resolve[0] { assert_length!(1, reports_after_resolve); assert_eq!(v.creator.id, data.sara.id); } else { panic!("wrong type"); } // Make sure the counts are correct let report_count_after_resolved = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(1, report_count_after_resolved); // Filter by post id, which should still include the comments. let reports_post_id_filter = ReportCombinedQuery { post_id: Some(data.post.id), ..Default::default() } .list(pool, &data.timmy_view) .await?; assert_length!(2, reports_post_id_filter); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn community_reports() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // jessica reports community let community_report_form = CommunityReportForm { creator_id: data.jessica.id, community_id: data.community.id, original_community_name: data.community.name.clone(), original_community_title: data.community.title.clone(), original_community_banner: None, original_community_summary: None, original_community_sidebar: None, original_community_icon: None, reason: "the ice cream incident".into(), }; let community_report = CommunityReport::report(pool, &community_report_form).await?; let reports = ReportCombinedQuery { show_community_rule_violations: Some(true), ..Default::default() } .list(pool, &data.admin_view) .await?; assert_length!(1, reports); if let ReportCombinedView::Community(v) = &reports[0] { assert!(!v.community_report.resolved); assert_eq!(data.jessica.name, v.creator.name); assert_eq!(community_report.reason, v.community_report.reason); assert_eq!(data.community.name, v.community.name); assert_eq!(data.community.title, v.community.title); let read_report = ReportCombinedViewInternal::read_community_report( pool, community_report.id, &data.admin_view.person, ) .await?; assert_eq!(&read_report, v); } else { panic!("wrong type"); } // admin resolves the report (after taking appropriate action) CommunityReport::update_resolved(pool, community_report.id, data.admin_view.person.id, true) .await?; let reports = ReportCombinedQuery { show_community_rule_violations: Some(true), ..Default::default() } .list(pool, &data.admin_view) .await?; assert_length!(1, reports); if let ReportCombinedView::Community(v) = &reports[0] { assert!(v.community_report.resolved); assert!(v.resolver.is_some()); assert_eq!( Some(&data.admin_view.person.name), v.resolver.as_ref().map(|r| &r.name) ); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn violates_instance_rules() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // create report to admins let report_form = PostReportForm { creator_id: data.sara.id, post_id: data.post_2.id, original_post_name: "Orig post".into(), original_post_url: None, original_post_body: None, reason: "from sara".into(), violates_instance_rules: true, }; PostReport::report(pool, &report_form).await?; // timmy is a mod and cannot see the report let mod_reports = ReportCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_length!(0, mod_reports); let count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(0, count); // only admin can see the report let admin_reports = ReportCombinedQuery::default() .list(pool, &data.admin_view) .await?; assert_length!(1, admin_reports); let count = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view).await?; assert_eq!(1, count); // cleanup the report for easier checks below Post::delete(pool, data.post_2.id).await?; // now create a mod report let report_form = CommentReportForm { creator_id: data.sara.id, comment_id: data.comment.id, original_comment_text: "this was it at time of creation".into(), reason: "from sara".into(), violates_instance_rules: false, }; let comment_report = CommentReport::report(pool, &report_form).await?; // this time the mod can see it let mod_reports = ReportCombinedQuery::default() .list(pool, &data.timmy_view) .await?; assert_length!(1, mod_reports); let count = ReportCombinedViewInternal::get_report_count(pool, &data.timmy_view).await?; assert_eq!(1, count); // but not the admin let admin_reports = ReportCombinedQuery::default() .list(pool, &data.admin_view) .await?; assert_length!(0, admin_reports); let count = ReportCombinedViewInternal::get_report_count(pool, &data.admin_view).await?; assert_eq!(0, count); // admin can see the report with `view_mod_reports` set let admin_reports = ReportCombinedQuery { show_community_rule_violations: Some(true), ..Default::default() } .list(pool, &data.timmy_view) .await?; assert_length!(1, admin_reports); // change a comment to be 3 days old, now admin can also see it by default update( report_combined::table.filter(report_combined::dsl::comment_report_id.eq(comment_report.id)), ) .set(report_combined::published_at.eq(Utc::now() - Days::new(3))) .execute(&mut get_conn(pool).await?) .await?; let admin_reports = ReportCombinedQuery::default() .list(pool, &data.admin_view) .await?; assert_length!(1, admin_reports); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn my_reports_only() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // sara reports let sara_report_form = CommentReportForm { creator_id: data.sara.id, comment_id: data.comment.id, original_comment_text: "this was it at time of creation".into(), reason: "from sara".into(), violates_instance_rules: false, }; CommentReport::report(pool, &sara_report_form).await?; // timmy reports let timmy_report_form = CommentReportForm { creator_id: data.timmy.id, comment_id: data.comment.id, original_comment_text: "this was it at time of creation".into(), reason: "from timmy".into(), violates_instance_rules: false, }; CommentReport::report(pool, &timmy_report_form).await?; let agg = Comment::read(pool, data.comment.id).await?; assert_eq!(agg.report_count, 2); // Do a batch read of timmys reports, it should only show his own let reports = ReportCombinedQuery { my_reports_only: Some(true), ..Default::default() } .list(pool, &data.timmy_view) .await?; assert_length!(1, reports); if let ReportCombinedView::Comment(v) = &reports[0] { assert_eq!(v.creator.id, data.timmy.id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn ensure_creator_data_is_correct() -> LemmyResult<()> { // The creator_banned and other creator_data should be the content creator, not the report // creator. let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // sara reports timmys post let sara_report_form = PostReportForm { creator_id: data.sara.id, post_id: data.post.id, original_post_name: "Orig post".into(), original_post_url: None, original_post_body: None, reason: "from sara".into(), violates_instance_rules: false, }; let inserted_sara_report = PostReport::report(pool, &sara_report_form).await?; // Admin ban timmy (the post creator) let ban_timmy_form = InstanceBanForm::new(data.timmy.id, data.instance.id, None); InstanceActions::ban(pool, &ban_timmy_form).await?; let read_sara_report_view = ReportCombinedViewInternal::read_post_report(pool, inserted_sara_report.id, &data.timmy) .await?; // Make sure timmy is seen as banned. assert_eq!(read_sara_report_view.creator_banned, true); cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/report_combined/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::source::{ combined::report::ReportCombined, comment::{Comment, CommentActions}, comment_report::CommentReport, community::{Community, CommunityActions}, community_report::CommunityReport, person::{Person, PersonActions}, post::{Post, PostActions}, post_report::PostReport, private_message::PrivateMessage, private_message_report::PrivateMessageReport, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{NullableExpressionMethods, Queryable, Selectable, dsl::Nullable}, lemmy_db_schema::utils::queries::selects::{ CreatorLocalHomeCommunityBanExpiresType, creator_ban_expires_from_community, creator_banned_from_community, creator_is_moderator, creator_local_home_community_ban_expires, creator_local_home_community_banned, local_user_is_admin, person1_select, person2_select, }, lemmy_db_schema::{Person1AliasAllColumnsTuple, Person2AliasAllColumnsTuple}, lemmy_db_views_local_user::LocalUserView, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[cfg(feature = "full")] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Queryable, Selectable)] #[diesel(check_for_backend(diesel::pg::Pg))] /// A combined report view pub struct ReportCombinedViewInternal { #[diesel(embed)] pub report_combined: ReportCombined, #[diesel(embed)] pub post_report: Option, #[diesel(embed)] pub comment_report: Option, #[diesel(embed)] pub private_message_report: Option, #[diesel(embed)] pub community_report: Option, #[diesel( select_expression_type = Person1AliasAllColumnsTuple, select_expression = person1_select() )] pub report_creator: Person, #[diesel(embed)] pub comment: Option, #[diesel(embed)] pub private_message: Option, #[diesel(embed)] pub post: Option, #[diesel(embed)] pub creator: Option, #[diesel( select_expression_type = Nullable, select_expression = person2_select().nullable() )] pub resolver: Option, #[diesel(select_expression = local_user_is_admin())] pub creator_is_admin: bool, #[diesel(select_expression = creator_is_moderator())] pub creator_is_moderator: bool, #[diesel(select_expression = creator_local_home_community_banned())] pub creator_banned: bool, #[diesel( select_expression_type = CreatorLocalHomeCommunityBanExpiresType, select_expression = creator_local_home_community_ban_expires() )] pub creator_ban_expires_at: Option>, #[diesel(select_expression = creator_banned_from_community())] pub creator_banned_from_community: bool, #[diesel(select_expression = creator_ban_expires_from_community())] pub creator_community_ban_expires_at: Option>, #[diesel(embed)] pub community: Option, #[diesel(embed)] pub community_actions: Option, #[diesel(embed)] pub post_actions: Option, #[diesel(embed)] pub person_actions: Option, #[diesel(embed)] pub comment_actions: Option, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] #[serde(tag = "type_", rename_all = "snake_case")] pub enum ReportCombinedView { Post(PostReportView), Comment(CommentReportView), PrivateMessage(PrivateMessageReportView), Community(CommunityReportView), } #[skip_serializing_none] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A private message report view. pub struct PrivateMessageReportView { pub private_message_report: PrivateMessageReport, pub private_message: PrivateMessage, pub creator: Person, pub private_message_creator: Person, pub resolver: Option, pub creator_is_admin: bool, pub creator_banned: bool, pub creator_ban_expires_at: Option>, } #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A comment report view. pub struct CommentReportView { pub comment_report: CommentReport, pub comment: Comment, pub post: Post, pub community: Community, pub creator: Person, pub comment_creator: Person, pub comment_actions: Option, pub resolver: Option, pub person_actions: Option, pub community_actions: Option, pub creator_is_admin: bool, pub creator_is_moderator: bool, pub creator_banned: bool, pub creator_ban_expires_at: Option>, pub creator_banned_from_community: bool, pub creator_community_ban_expires_at: Option>, } #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A community report view. pub struct CommunityReportView { pub community_report: CommunityReport, pub community: Community, pub creator: Person, pub resolver: Option, pub creator_is_admin: bool, pub creator_is_moderator: bool, pub creator_banned: bool, pub creator_ban_expires_at: Option>, pub creator_banned_from_community: bool, pub creator_community_ban_expires_at: Option>, } #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A post report view. pub struct PostReportView { pub post_report: PostReport, pub post: Post, pub community: Community, pub creator: Person, pub post_creator: Person, pub community_actions: Option, pub post_actions: Option, pub person_actions: Option, pub resolver: Option, pub creator_is_admin: bool, pub creator_is_moderator: bool, pub creator_banned: bool, pub creator_ban_expires_at: Option>, pub creator_banned_from_community: bool, pub creator_community_ban_expires_at: Option>, } ================================================ FILE: crates/db_views/report_combined_sql/Cargo.toml ================================================ [package] name = "lemmy_db_views_report_combined_sql" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false test = false [lints] workspace = true [dependencies] lemmy_db_schema_file = { workspace = true, features = ["full"] } diesel = { workspace = true } ================================================ FILE: crates/db_views/report_combined_sql/src/lib.rs ================================================ use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, }; use lemmy_db_schema_file::{ InstanceId, PersonId, aliases, aliases::creator_community_actions, joins::{ creator_community_instance_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, }, schema::{ comment, comment_actions, comment_report, community, community_actions, community_report, local_user, person, person_actions, post, post_actions, post_report, private_message, private_message_report, report_combined, }, }; #[diesel::dsl::auto_type(no_type_alias)] pub fn report_combined_joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ { // The item creator needs to be person::id, otherwise all the creator actions like // creator_banned will be wrong. let item_creator = person::id; let report_creator = aliases::person1.field(person::id); let resolver = aliases::person2.field(person::id).nullable(); let comment_join = comment::table.on(comment_report::comment_id.eq(comment::id)); let private_message_join = private_message::table.on(private_message_report::private_message_id.eq(private_message::id)); let post_join = post::table.on( post_report::post_id .eq(post::id) .or(comment::post_id.eq(post::id)), ); let community_actions_join = community_actions::table.on( community_actions::community_id .eq(community::id) .and(community_actions::person_id.eq(my_person_id)), ); let report_creator_join = aliases::person1.on( post_report::creator_id .eq(report_creator) .or(comment_report::creator_id.eq(report_creator)) .or(private_message_report::creator_id.eq(report_creator)) .or(community_report::creator_id.eq(report_creator)), ); let item_creator_join = person::table.on( post::creator_id .eq(item_creator) .or(comment::creator_id.eq(item_creator)) .or(private_message::creator_id.eq(item_creator)), ); let resolver_join = aliases::person2.on( private_message_report::resolver_id .eq(resolver) .or(post_report::resolver_id.eq(resolver)) .or(comment_report::resolver_id.eq(resolver)) .or(community_report::resolver_id.eq(resolver)), ); let community_join = community::table.on( community_report::community_id .eq(community::id) .or(post::community_id.eq(community::id)), ); let local_user_join = local_user::table.on( item_creator .eq(local_user::person_id) .and(local_user::admin.eq(true)), ); let creator_community_actions_join = creator_community_actions.on( creator_community_actions .field(community_actions::community_id) .eq(post::community_id) .and( creator_community_actions .field(community_actions::person_id) .eq(item_creator), ), ); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); let post_actions_join = post_actions::table.on( post_actions::post_id .eq(post::id) .and(post_actions::person_id.eq(my_person_id)), ); let person_actions_join = person_actions::table.on( person_actions::target_id .eq(item_creator) .and(person_actions::person_id.eq(my_person_id)), ); let comment_actions_join = comment_actions::table.on( comment_actions::comment_id .eq(comment::id) .and(comment_actions::person_id.eq(my_person_id)), ); report_combined::table .left_join(post_report::table) .left_join(comment_report::table) .left_join(private_message_report::table) .left_join(community_report::table) .inner_join(report_creator_join) .left_join(comment_join) .left_join(private_message_join) .left_join(post_join) .left_join(item_creator_join) .left_join(resolver_join) .left_join(community_join) .left_join(creator_community_actions_join) .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(creator_community_instance_actions_join()) .left_join(local_user_join) .left_join(community_actions_join) .left_join(post_actions_join) .left_join(person_actions_join) .left_join(comment_actions_join) } ================================================ FILE: crates/db_views/search_combined/Cargo.toml ================================================ [package] name = "lemmy_db_views_search_combined" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_db_views_post/full", "lemmy_db_views_comment/full", "lemmy_db_views_community/full", "lemmy_db_views_person/full", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs", "lemmy_db_views_comment/ts-rs", "lemmy_db_views_community/ts-rs", "lemmy_db_views_person/ts-rs", "lemmy_db_views_post/ts-rs", ] [dependencies] lemmy_db_views_post = { workspace = true } lemmy_db_views_comment = { workspace = true } lemmy_db_views_community = { workspace = true } lemmy_db_views_person = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } serde_with = { workspace = true } chrono = { workspace = true } url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/search_combined/src/api.rs ================================================ use lemmy_db_schema::newtypes::{CommentId, PostId}; use lemmy_db_views_community::CommunityView; use lemmy_db_views_post::PostView; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] // TODO this should be made into a tagged enum /// Get a post. Needs either the post id, or comment_id. pub struct GetPost { pub id: Option, pub comment_id: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The post response. pub struct GetPostResponse { pub post_view: PostView, pub community_view: CommunityView, /// A list of cross-posts, or other times / communities this link has been posted to. pub cross_posts: Vec, } ================================================ FILE: crates/db_views/search_combined/src/impls.rs ================================================ use crate::{ CommentView, CommunityView, LocalUserView, PersonView, PostView, SearchCombinedView, SearchCombinedViewInternal, }; use diesel::{ BoolExpressionMethods, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, PgTextExpressionMethods, QueryDsl, SelectableHelper, dsl::not, }; use diesel_async::RunQueryDsl; use i_love_jesus::asc_if; use lemmy_db_schema::{ SearchSortType::{self, *}, SearchType, impls::local_user::LocalUserOptionHelper, newtypes::CommunityId, source::{ combined::search::{SearchCombined, search_combined_keys as key}, site::Site, }, traits::InternalToCombinedView, utils::{ limit_fetch, queries::filters::{ filter_is_subscribed, filter_not_unlisted_or_is_subscribed, filter_suggested_communities, }, }, }; use lemmy_db_schema_file::{ InstanceId, PersonId, enums::{CommunityFollowerState, CommunityVisibility, ListingType}, joins::{ creator_community_actions_join, creator_home_instance_actions_join, creator_local_instance_actions_join, creator_local_user_admin_join, image_details_join, my_comment_actions_join, my_community_actions_join, my_local_user_admin_join, my_person_actions_join, my_post_actions_join, }, schema::{ comment, comment_actions, community, community_actions, multi_community, person, post, post_actions, search_combined, }, }; use lemmy_db_views_community::MultiCommunityView; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, utils::{fuzzy_search, now, seconds_to_pg_interval}, }; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::validation::clean_url, }; use url::Url; impl SearchCombinedViewInternal { #[diesel::dsl::auto_type(no_type_alias)] fn joins(my_person_id: Option, local_instance_id: InstanceId) -> _ { let item_creator = person::id; let item_creator_join = person::table.on( search_combined::person_id .eq(item_creator.nullable()) .or( search_combined::comment_id .is_not_null() .and(comment::creator_id.eq(item_creator)), ) .or( search_combined::post_id .is_not_null() .and(post::creator_id.eq(item_creator)), ) .or( search_combined::multi_community_id .is_not_null() .and(multi_community::creator_id.eq(item_creator)), ) .and(not(person::deleted)), ); let comment_join = comment::table.on( search_combined::comment_id .eq(comment::id.nullable()) .and(not(comment::removed)) .and(not(comment::deleted)), ); let post_join = post::table.on( search_combined::post_id .eq(post::id.nullable()) .or(comment::post_id.eq(post::id)) .and(not(post::removed)) .and(not(post::deleted)), ); let community_join = community::table.on( search_combined::community_id .eq(community::id.nullable()) .or(post::community_id.eq(community::id)) .and(not(community::removed)) .and(not(community::local_removed)) .and(not(community::deleted)), ); let multi_community_join = multi_community::table.on( search_combined::multi_community_id .eq(multi_community::id.nullable()) .and(not(multi_community::deleted)), ); let my_community_actions_join: my_community_actions_join = my_community_actions_join(my_person_id); let my_post_actions_join: my_post_actions_join = my_post_actions_join(my_person_id); let my_comment_actions_join: my_comment_actions_join = my_comment_actions_join(my_person_id); let my_local_user_admin_join: my_local_user_admin_join = my_local_user_admin_join(my_person_id); let my_person_actions_join: my_person_actions_join = my_person_actions_join(my_person_id); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); search_combined::table .left_join(comment_join) .left_join(post_join) .left_join(multi_community_join) .left_join(item_creator_join) .left_join(community_join) .left_join(image_details_join()) .left_join(creator_community_actions_join()) .left_join(creator_local_user_admin_join()) .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(my_local_user_admin_join) .left_join(my_community_actions_join) .left_join(my_post_actions_join) .left_join(my_person_actions_join) .left_join(my_comment_actions_join) } } impl SearchCombinedView { /// Useful in combination with filter_map pub fn to_post_view(&self) -> Option<&PostView> { if let Self::Post(v) = self { Some(v) } else { None } } } impl PaginationCursorConversion for SearchCombinedView { type PaginatedType = SearchCombined; fn to_cursor(&self) -> CursorData { let (prefix, id) = match &self { SearchCombinedView::Post(v) => ('P', v.post.id.0), SearchCombinedView::Comment(v) => ('C', v.comment.id.0), SearchCombinedView::Community(v) => ('O', v.community.id.0), SearchCombinedView::Person(v) => ('E', v.person.id.0), SearchCombinedView::MultiCommunity(v) => ('M', v.multi.id.0), }; CursorData::new_with_prefix(prefix, id) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let conn = &mut get_conn(pool).await?; let (prefix, id) = cursor.id_and_prefix()?; let mut query = search_combined::table .select(Self::PaginatedType::as_select()) .into_boxed(); query = match prefix { 'P' => query.filter(search_combined::post_id.eq(id)), 'C' => query.filter(search_combined::comment_id.eq(id)), 'O' => query.filter(search_combined::community_id.eq(id)), 'E' => query.filter(search_combined::person_id.eq(id)), 'M' => query.filter(search_combined::multi_community_id.eq(id)), _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()), }; let token = query.first(conn).await?; Ok(token) } } #[derive(Default)] pub struct SearchCombinedQuery { pub search_term: Option, pub community_id: Option, pub creator_id: Option, pub type_: Option, pub sort: Option, pub time_range_seconds: Option, pub listing_type: Option, pub title_only: Option, pub post_url_only: Option, pub liked_only: Option, pub disliked_only: Option, pub show_nsfw: Option, pub page_cursor: Option, pub limit: Option, } impl SearchCombinedQuery { pub async fn list( self, pool: &mut DbPool<'_>, user: &Option, site_local: &Site, ) -> LemmyResult> { let my_local_user = user.as_ref().map(|u| &u.local_user); let my_person_id = my_local_user.person_id(); let item_creator = person::id; let limit = limit_fetch(self.limit, None)?; let mut query = SearchCombinedViewInternal::joins(my_person_id, site_local.instance_id) .select(SearchCombinedViewInternal::as_select()) .limit(limit) .into_boxed(); // The filters // Some helpers let is_post = search_combined::post_id.is_not_null(); let is_comment = search_combined::comment_id.is_not_null(); let is_community = search_combined::community_id.is_not_null(); let is_person = search_combined::person_id.is_not_null(); let is_multi_community = search_combined::multi_community_id.is_not_null(); // The search term if let Some(search_term) = self.search_term { if self.post_url_only.unwrap_or_default() { // Parse and normalize the url, removing tracking parameters (same logic which is used // when creating a new post). let normalized_url = Url::parse(&search_term).map(|u| clean_url(&u).to_string()); // If any of the normalization steps above failed, use the search term directly // (this can happen when searching part of an url). let url_searcher = fuzzy_search(&normalized_url.unwrap_or(search_term)); query = query.filter(is_post.and(post::url.ilike(url_searcher))); } else { let searcher = fuzzy_search(&search_term); // These need to also filter by the type, otherwise they may return children let name_or_title_filter = is_post .and(post::name.ilike(searcher.clone())) .or(is_comment.and(comment::content.ilike(searcher.clone()))) .or(is_community.and(community::name.ilike(searcher.clone()))) .or(is_community.and(community::title.ilike(searcher.clone()))) .or(is_person.and(person::name.ilike(searcher.clone()))) .or(is_person.and(person::display_name.ilike(searcher.clone()))) .or(is_multi_community.and(multi_community::title.ilike(searcher.clone()))) .or(is_multi_community.and(multi_community::name.ilike(searcher.clone()))); query = if self.title_only.unwrap_or_default() { query.filter(name_or_title_filter) } else { let body_or_description_filter = is_post .and(post::body.ilike(searcher.clone())) .or(is_community.and(community::summary.ilike(searcher.clone()))) .or(is_multi_community.and(multi_community::summary.ilike(searcher.clone()))) .or(is_person.and(person::bio.ilike(searcher.clone()))); query.filter(name_or_title_filter.or(body_or_description_filter)) } } } // Community id if let Some(community_id) = self.community_id { query = query.filter(community::id.eq(community_id)); } // Creator id if let Some(creator_id) = self.creator_id { query = query.filter(item_creator.eq(creator_id)); } // Liked / disliked filter if let Some(my_id) = my_person_id { let not_creator_filter = item_creator.ne(my_id); let liked_disliked_filter = |should_be_upvote: bool| { is_post .and(post_actions::vote_is_upvote.eq(should_be_upvote)) .or(is_comment.and(comment_actions::vote_is_upvote.eq(should_be_upvote))) }; if self.liked_only.unwrap_or_default() { query = query .filter(not_creator_filter) .filter(liked_disliked_filter(true)); } else if self.disliked_only.unwrap_or_default() { query = query .filter(not_creator_filter) .filter(liked_disliked_filter(false)); } }; // Type query = match self.type_.unwrap_or_default() { SearchType::All => query, SearchType::Posts => query.filter(is_post), SearchType::Comments => query.filter(is_comment), SearchType::Communities => query.filter(is_community), SearchType::Users => query.filter(is_person), SearchType::MultiCommunities => query.filter(is_multi_community), }; // Listing type query = match self.listing_type.unwrap_or_default() { ListingType::Subscribed => query.filter(filter_is_subscribed()), ListingType::Local => query.filter( community::local .eq(true) .and(filter_not_unlisted_or_is_subscribed()) .or(is_person.and(person::local)) .or(multi_community::local), ), ListingType::All => query.filter( filter_not_unlisted_or_is_subscribed() .or(is_person) .or(is_multi_community), ), ListingType::ModeratorView => { query.filter(community_actions::became_moderator_at.is_not_null()) } ListingType::Suggested => query.filter(filter_suggested_communities()), }; // Filter by the time range if let Some(time_range_seconds) = self.time_range_seconds { query = query.filter( search_combined::published_at.gt(now() - seconds_to_pg_interval(time_range_seconds)), ); } // NSFW let user_and_site_nsfw = my_local_user.show_nsfw(site_local); if !self.show_nsfw.unwrap_or(user_and_site_nsfw) { let safe_community = community::nsfw.eq(false); let safe_post_and_community = post::nsfw.eq(false).and(safe_community); query = query.filter( is_community .and(safe_community) .or(is_post.and(safe_post_and_community)) .or(is_comment.and(safe_post_and_community)) .or(is_person) .or(is_multi_community), ); }; // Check permissions to view private community content. // Specifically, if the community is private then only accepted followers may view its // content, otherwise it is filtered out. Admins can view private community content // without restriction. if !my_local_user.is_admin() { let view_private_community = community::visibility .ne(CommunityVisibility::Private) .or(community_actions::follow_state.eq(CommunityFollowerState::Accepted)); // Only filter for communities, posts, and comments query = query.filter( is_community .and(view_private_community.clone()) .or(is_post.and(view_private_community.clone())) .or(is_comment.and(view_private_community.clone())) .or(is_person) .or(is_multi_community), ); }; // Only sort by asc if old let sort = self.sort.unwrap_or_default(); let sort_direction = asc_if(sort == Old); let mut paginated_query = SearchCombinedView::paginate(query, &self.page_cursor, sort_direction, pool, None).await?; paginated_query = match sort { New | Old => paginated_query.then_order_by(key::published_at), Top => paginated_query.then_order_by(key::score), } // finally use unique id as tie breaker .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = paginated_query .load::(conn) .await?; // Map the query results to the enum let out = res .into_iter() .filter_map(InternalToCombinedView::map_to_enum) .collect(); paginate_response(out, limit, self.page_cursor) } } impl InternalToCombinedView for SearchCombinedViewInternal { type CombinedView = SearchCombinedView; fn map_to_enum(self) -> Option { // Use for a short alias let v = self; if let (Some(comment), Some(creator), Some(post), Some(community)) = ( v.comment, v.item_creator.clone(), v.post.clone(), v.community.clone(), ) { Some(SearchCombinedView::Comment(CommentView { comment, post, community, creator, community_actions: v.community_actions, person_actions: v.person_actions, comment_actions: v.comment_actions, creator_is_admin: v.item_creator_is_admin, tags: v.tags, can_mod: v.can_mod, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_is_moderator: v.creator_is_moderator, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } else if let (Some(post), Some(creator), Some(community)) = (v.post, v.item_creator.clone(), v.community.clone()) { Some(SearchCombinedView::Post(PostView { post, community, creator, creator_is_admin: v.item_creator_is_admin, image_details: v.image_details, community_actions: v.community_actions, person_actions: v.person_actions, post_actions: v.post_actions, tags: v.tags, can_mod: v.can_mod, creator_banned: v.creator_banned, creator_ban_expires_at: v.creator_ban_expires_at, creator_is_moderator: v.creator_is_moderator, creator_banned_from_community: v.creator_banned_from_community, creator_community_ban_expires_at: v.creator_community_ban_expires_at, })) } else if let Some(community) = v.community { Some(SearchCombinedView::Community(CommunityView { community, community_actions: v.community_actions, can_mod: v.can_mod, tags: v.tags, })) } else if let (Some(multi), Some(creator)) = (v.multi_community, &v.item_creator) { Some(SearchCombinedView::MultiCommunity(MultiCommunityView { multi, owner: creator.clone(), follow_state: None, })) } else if let Some(person) = v.item_creator { Some(SearchCombinedView::Person(PersonView { person, is_admin: v.item_creator_is_admin, person_actions: v.person_actions, banned: v.creator_banned, ban_expires_at: v.creator_ban_expires_at, })) } else { None } } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::{LocalUserView, SearchCombinedView, impls::SearchCombinedQuery}; use lemmy_db_schema::{ SearchSortType, SearchType, assert_length, source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm, CommentUpdateForm}, community::{Community, CommunityActions, CommunityFollowerForm, CommunityInsertForm}, instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, multi_community::{MultiCommunity, MultiCommunityInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm, PostUpdateForm}, site::{Site, SiteInsertForm}, }, traits::{Followable, Likeable}, }; use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility}; use lemmy_diesel_utils::{ connection::{DbPool, build_db_pool_for_tests}, traits::Crud, }; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; use url::Url; struct Data { instance: Instance, site: Site, timmy: Person, timmy_view: LocalUserView, sara: Person, community: Community, community_2: Community, private_community: Community, timmy_post: Post, timmy_post_2: Post, sara_post: Post, nsfw_post: Post, timmy_post_private_comm: Post, timmy_comment: Comment, sara_comment: Comment, sara_comment_2: Comment, comment_in_nsfw_post: Comment, timmy_comment_private_comm: Comment, } async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { let instance = Instance::read_or_create(pool, "my_domain.tld").await?; let site_form = SiteInsertForm::new("test_site".to_string(), instance.id); let site = Site::create(pool, &site_form).await?; let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); let sara = Person::create(pool, &sara_form).await?; let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); let timmy = Person::create(pool, &timmy_form).await?; let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id); let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; let timmy_view = LocalUserView { local_user: timmy_local_user, person: timmy.clone(), banned: false, ban_expires_at: None, }; let community_form = CommunityInsertForm { summary: Some("ask lemmy things".into()), ..CommunityInsertForm::new( instance.id, "asklemmy".to_string(), "Ask Lemmy".to_owned(), "pubkey".to_string(), ) }; let community = Community::create(pool, &community_form).await?; let community_form_2 = CommunityInsertForm::new( instance.id, "startrek_ds9".to_string(), "Star Trek - Deep Space Nine".to_owned(), "pubkey".to_string(), ); let community_2 = Community::create(pool, &community_form_2).await?; let private_community_form = CommunityInsertForm { visibility: Some(CommunityVisibility::Private), ..CommunityInsertForm::new( instance.id, "private_comm".to_string(), "This is a private comm".to_owned(), "pubkey".to_string(), ) }; let private_community = Community::create(pool, &private_community_form).await?; let timmy_post_form = PostInsertForm { body: Some("postbody inside here".into()), url: Some(Url::parse("https://google.com")?.into()), ..PostInsertForm::new("timmy post prv".into(), timmy.id, community.id) }; let timmy_post = Post::create(pool, &timmy_post_form).await?; let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community_2.id); let sara_post = Post::create(pool, &sara_post_form).await?; let nsfw_post_form = PostInsertForm { body: Some("nsfw post inside here".into()), url: Some(Url::parse("https://google.com")?.into()), nsfw: Some(true), ..PostInsertForm::new("nsfw post prv".into(), timmy.id, community.id) }; let nsfw_post = Post::create(pool, &nsfw_post_form).await?; let timmy_post_private_comm_form = PostInsertForm::new( "timmy post private comm".into(), timmy.id, private_community.id, ); let timmy_post_private_comm = Post::create(pool, &timmy_post_private_comm_form).await?; let timmy_comment_form = CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv gold".into()); let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; let sara_comment_form = CommentInsertForm::new(sara.id, sara_post.id, "sara comment prv gold".into()); let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; let sara_comment_form_2 = CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; let comment_in_nsfw_post_form = CommentInsertForm::new( sara.id, nsfw_post.id, "sara comment in nsfw post prv 2".into(), ); let comment_in_nsfw_post = Comment::create(pool, &comment_in_nsfw_post_form, None).await?; let timmy_comment_private_comm_form = CommentInsertForm::new( timmy.id, timmy_post_private_comm.id, "timmy comment private comm".into(), ); let timmy_comment_private_comm = Comment::create(pool, &timmy_comment_private_comm_form, None).await?; // Timmy likes and dislikes a few things let timmy_like_post_form = PostLikeForm::new(timmy_post.id, timmy.id, Some(true)); PostActions::like(pool, &timmy_like_post_form).await?; let timmy_like_sara_post_form = PostLikeForm::new(sara_post.id, timmy.id, Some(true)); PostActions::like(pool, &timmy_like_sara_post_form).await?; let timmy_dislike_post_form = PostLikeForm::new(timmy_post_2.id, timmy.id, Some(false)); PostActions::like(pool, &timmy_dislike_post_form).await?; let timmy_like_comment_form = CommentLikeForm::new(timmy_comment.id, timmy.id, Some(true)); CommentActions::like(pool, &timmy_like_comment_form).await?; let timmy_like_sara_comment_form = CommentLikeForm::new(sara_comment.id, timmy.id, Some(true)); CommentActions::like(pool, &timmy_like_sara_comment_form).await?; let timmy_dislike_sara_comment_form = CommentLikeForm::new(sara_comment_2.id, timmy.id, Some(false)); CommentActions::like(pool, &timmy_dislike_sara_comment_form).await?; Ok(Data { instance, site, timmy, timmy_view, sara, community, community_2, private_community, timmy_post, timmy_post_2, sara_post, nsfw_post, timmy_post_private_comm, timmy_comment, sara_comment, sara_comment_2, comment_in_nsfw_post, timmy_comment_private_comm, }) } async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { Instance::delete(pool, data.instance.id).await?; Ok(()) } #[tokio::test] #[serial] async fn combined() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // search let search = SearchCombinedQuery::default() .list(pool, &None, &data.site) .await?; assert_length!(10, search); // Make sure the types are correct if let SearchCombinedView::Comment(v) = &search[0] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Comment(v) = &search[1] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara_post.id, v.post.id); assert_eq!(data.community_2.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Comment(v) = &search[2] { assert_eq!(data.timmy_comment.id, v.comment.id); assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Post(v) = &search[3] { assert_eq!(data.sara_post.id, v.post.id); assert_eq!(data.community_2.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Post(v) = &search[4] { assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Post(v) = &search[5] { assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Community(v) = &search[6] { assert_eq!(data.community_2.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Community(v) = &search[7] { assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Person(v) = &search[8] { assert_eq!(data.timmy.id, v.person.id); } else { panic!("wrong type"); } if let SearchCombinedView::Person(v) = &search[9] { assert_eq!(data.sara.id, v.person.id); } else { panic!("wrong type"); } // Filtered by community id let search_by_community = SearchCombinedQuery { community_id: Some(data.community.id), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(5, search_by_community); // Filtered by creator_id let search_by_creator = SearchCombinedQuery { creator_id: Some(data.timmy.id), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(4, search_by_creator); // Using a term let search_by_name = SearchCombinedQuery { search_term: Some("gold".into()), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, search_by_name); // Liked / disliked only let search_liked_only = SearchCombinedQuery { liked_only: Some(true), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; assert_length!(2, search_liked_only); let search_disliked_only = SearchCombinedQuery { disliked_only: Some(true), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; assert_length!(1, search_disliked_only); // Test sorts // Test Old sort let search_old_sort = SearchCombinedQuery { sort: Some(SearchSortType::Old), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; if let SearchCombinedView::Person(v) = &search_old_sort[0] { assert_eq!(data.sara.id, v.person.id); } else { panic!("wrong type"); } assert_length!(10, search_old_sort); // Remove a post and delete a comment Post::update( pool, data.timmy_post_2.id, &PostUpdateForm { removed: Some(true), ..Default::default() }, ) .await?; Comment::update( pool, data.sara_comment.id, &CommentUpdateForm { deleted: Some(true), ..Default::default() }, ) .await?; // 2 things got removed, but the post also has another comment which got removed let search = SearchCombinedQuery::default() .list(pool, &None, &data.site) .await?; assert_length!(7, search); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Community search let community_search = SearchCombinedQuery { type_: Some(SearchType::Communities), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, community_search); // Make sure the types are correct if let SearchCombinedView::Community(v) = &community_search[0] { assert_eq!(data.community_2.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Community(v) = &community_search[1] { assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } // Filtered by id let community_search_by_id = SearchCombinedQuery { community_id: Some(data.community.id), type_: Some(SearchType::Communities), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, community_search_by_id); // Using a term let community_search_by_name = SearchCombinedQuery { search_term: Some("things".into()), type_: Some(SearchType::Communities), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, community_search_by_name); if let SearchCombinedView::Community(v) = &community_search_by_name[0] { // The asklemmy community assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } // Test title only search to make sure 'ask lemmy things' doesn't get returned // Using a term let community_search_title_only = SearchCombinedQuery { search_term: Some("things".into()), type_: Some(SearchType::Communities), title_only: Some(true), ..Default::default() } .list(pool, &None, &data.site) .await?; assert!(community_search_title_only.is_empty()); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn person() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Person search let person_search = SearchCombinedQuery { type_: Some(SearchType::Users), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, person_search); // Make sure the types are correct if let SearchCombinedView::Person(v) = &person_search[0] { assert_eq!(data.timmy.id, v.person.id); } else { panic!("wrong type"); } if let SearchCombinedView::Person(v) = &person_search[1] { assert_eq!(data.sara.id, v.person.id); } else { panic!("wrong type"); } // Filtered by creator_id let person_search_by_id = SearchCombinedQuery { creator_id: Some(data.sara.id), type_: Some(SearchType::Users), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, person_search_by_id); if let SearchCombinedView::Person(v) = &person_search_by_id[0] { assert_eq!(data.sara.id, v.person.id); } else { panic!("wrong type"); } // Using a term let person_search_by_name = SearchCombinedQuery { search_term: Some("tim".into()), type_: Some(SearchType::Users), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, person_search_by_name); if let SearchCombinedView::Person(v) = &person_search_by_name[0] { assert_eq!(data.timmy.id, v.person.id); } else { panic!("wrong type"); } // Test Top sorting (uses post score) let person_search_sort_top = SearchCombinedQuery { type_: Some(SearchType::Users), sort: Some(SearchSortType::Top), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, person_search_sort_top); // Sara should be first, as she has a higher score if let SearchCombinedView::Person(v) = &person_search_sort_top[0] { assert_eq!(data.sara.id, v.person.id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn post() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // post search let post_search = SearchCombinedQuery { type_: Some(SearchType::Posts), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(3, post_search); // Make sure the types are correct if let SearchCombinedView::Post(v) = &post_search[0] { assert_eq!(data.sara_post.id, v.post.id); assert_eq!(data.community_2.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Post(v) = &post_search[1] { assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Post(v) = &post_search[2] { assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } // Filtered by id let post_search_by_community = SearchCombinedQuery { community_id: Some(data.community.id), type_: Some(SearchType::Posts), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, post_search_by_community); // Using a term let post_search_by_name = SearchCombinedQuery { search_term: Some("sara".into()), type_: Some(SearchType::Posts), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, post_search_by_name); // Test title only search to make sure 'postbody' doesn't show up // Using a term let post_search_title_only = SearchCombinedQuery { search_term: Some("postbody".into()), type_: Some(SearchType::Posts), title_only: Some(true), ..Default::default() } .list(pool, &None, &data.site) .await?; assert!(post_search_title_only.is_empty()); // Test title only search to make sure 'postbody' doesn't show up // Using a term let post_search_url_only = SearchCombinedQuery { search_term: data.timmy_post.url.as_ref().map(ToString::to_string), post_url_only: Some(true), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, post_search_url_only); let post_search_partial_url = SearchCombinedQuery { search_term: Some("google.c".to_string()), post_url_only: Some(true), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, post_search_partial_url); // Liked / disliked only let post_search_liked_only = SearchCombinedQuery { type_: Some(SearchType::Posts), liked_only: Some(true), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; // Should only be 1 not 2, because liked only ignores your own content assert_length!(1, post_search_liked_only); let post_search_disliked_only = SearchCombinedQuery { type_: Some(SearchType::Posts), disliked_only: Some(true), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; // Should be zero because you disliked your own post assert_length!(0, post_search_disliked_only); // Test top sort let post_search_sort_top = SearchCombinedQuery { type_: Some(SearchType::Posts), sort: Some(SearchSortType::Top), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(3, post_search_sort_top); // Timmy_post_2 has a dislike, so it should be last if let SearchCombinedView::Post(v) = &post_search_sort_top[2] { assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] // Due to the joins which return children, double check to make sure the search term filters // aren't returning child content. IE a search for post title my_post won't return any comments. async fn no_children() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // Post searches should not return the child comments let post_no_children = SearchCombinedQuery { search_term: Some("timmy post prv 2".into()), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, post_no_children); // Community searches should not return posts or comments let community_no_children = SearchCombinedQuery { search_term: Some("asklemmy".into()), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, community_no_children); // Person searches should not return communities, posts, or comments let person_no_children = SearchCombinedQuery { search_term: Some("timmy_pcv".into()), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, person_no_children); cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn nsfw_post() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let nsfw_post_search = SearchCombinedQuery { type_: Some(SearchType::Posts), show_nsfw: Some(true), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(4, nsfw_post_search); // Make sure the first is the nsfw if let SearchCombinedView::Post(v) = &nsfw_post_search[0] { assert_eq!(data.nsfw_post.id, v.post.id); assert!(v.post.nsfw); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn nsfw_comment() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let nsfw_comment_search = SearchCombinedQuery { type_: Some(SearchType::Comments), show_nsfw: Some(true), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(4, nsfw_comment_search); // Make sure the first is the nsfw if let SearchCombinedView::Comment(v) = &nsfw_comment_search[0] { assert_eq!(data.comment_in_nsfw_post.id, v.comment.id); assert_eq!(data.nsfw_post.id, v.post.id); assert!(v.post.nsfw); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn private_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let unsubbed_private_search = SearchCombinedQuery { community_id: Some(data.private_community.id), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; assert_length!(0, unsubbed_private_search); // Approve timmy to the community let follow_form = CommunityFollowerForm::new( data.private_community.id, data.timmy.id, CommunityFollowerState::ApprovalRequired, ); CommunityActions::follow(pool, &follow_form).await?; CommunityActions::approve_private_community_follower( pool, data.private_community.id, data.timmy.id, data.sara.id, CommunityFollowerState::Accepted, ) .await?; let subbed_private_search = SearchCombinedQuery { community_id: Some(data.private_community.id), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; // Timmy subscribes to the comm and its accepted // 1 community, 1 post, and 1 comment assert_length!(3, subbed_private_search); // Check the content if let SearchCombinedView::Comment(v) = &subbed_private_search[0] { assert_eq!(data.timmy_comment_private_comm.id, v.comment.id); assert_eq!(data.timmy_post_private_comm.id, v.post.id); assert_eq!(data.private_community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Post(v) = &subbed_private_search[1] { assert_eq!(data.timmy_post_private_comm.id, v.post.id); assert_eq!(data.private_community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Community(v) = &subbed_private_search[2] { assert_eq!(data.private_community.id, v.community.id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn comment() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; // comment search let comment_search = SearchCombinedQuery { type_: Some(SearchType::Comments), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(3, comment_search); // Make sure the types are correct if let SearchCombinedView::Comment(v) = &comment_search[0] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Comment(v) = &comment_search[1] { assert_eq!(data.sara_comment.id, v.comment.id); assert_eq!(data.sara_post.id, v.post.id); assert_eq!(data.community_2.id, v.community.id); } else { panic!("wrong type"); } if let SearchCombinedView::Comment(v) = &comment_search[2] { assert_eq!(data.timmy_comment.id, v.comment.id); assert_eq!(data.timmy_post.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } // Filtered by id let comment_search_by_community = SearchCombinedQuery { community_id: Some(data.community.id), type_: Some(SearchType::Comments), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, comment_search_by_community); // Using a term let comment_search_by_name = SearchCombinedQuery { search_term: Some("gold".into()), type_: Some(SearchType::Comments), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(2, comment_search_by_name); // Liked / disliked only let comment_search_liked_only = SearchCombinedQuery { type_: Some(SearchType::Comments), liked_only: Some(true), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; assert_length!(1, comment_search_liked_only); let comment_search_disliked_only = SearchCombinedQuery { type_: Some(SearchType::Comments), disliked_only: Some(true), ..Default::default() } .list(pool, &Some(data.timmy_view.clone()), &data.site) .await?; assert_length!(1, comment_search_disliked_only); // Test top sort let comment_search_sort_top = SearchCombinedQuery { type_: Some(SearchType::Comments), sort: Some(SearchSortType::Top), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(3, comment_search_sort_top); // Sara comment 2 is disliked, so should be last if let SearchCombinedView::Comment(v) = &comment_search_sort_top[2] { assert_eq!(data.sara_comment_2.id, v.comment.id); assert_eq!(data.timmy_post_2.id, v.post.id); assert_eq!(data.community.id, v.community.id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } #[tokio::test] #[serial] async fn multi_community() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let data = init_data(pool).await?; let form = MultiCommunityInsertForm::new( data.timmy_view.person.id, data.instance.id, "multi".to_string(), String::new(), ); let multi = MultiCommunity::create(pool, &form).await?; // Multi-community search let search = SearchCombinedQuery { type_: Some(SearchType::MultiCommunities), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, search); // Make sure the types are correct if let SearchCombinedView::MultiCommunity(v) = &search[0] { assert_eq!(multi.id, v.multi.id); } else { panic!("wrong type"); } // Using a term let search_by_name = SearchCombinedQuery { search_term: Some("multi".into()), type_: Some(SearchType::MultiCommunities), ..Default::default() } .list(pool, &None, &data.site) .await?; assert_length!(1, search_by_name); if let SearchCombinedView::MultiCommunity(v) = &search_by_name[0] { assert_eq!(multi.id, v.multi.id); } else { panic!("wrong type"); } cleanup(data, pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/search_combined/src/lib.rs ================================================ use chrono::{DateTime, Utc}; use lemmy_db_schema::{ SearchSortType, SearchType, newtypes::CommunityId, source::{ combined::search::SearchCombined, comment::{Comment, CommentActions}, community::{Community, CommunityActions}, community_tag::CommunityTagsView, images::ImageDetails, multi_community::MultiCommunity, person::{Person, PersonActions}, post::{Post, PostActions}, }, }; use lemmy_db_schema_file::{PersonId, enums::ListingType}; use lemmy_db_views_comment::CommentView; use lemmy_db_views_community::{CommunityView, MultiCommunityView}; use lemmy_db_views_person::PersonView; use lemmy_db_views_post::PostView; use lemmy_diesel_utils::pagination::PaginationCursor; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{Queryable, Selectable}, lemmy_db_schema::utils::queries::selects::{ CreatorLocalHomeBanExpiresType, community_tags_fragment, creator_ban_expires_from_community, creator_banned_from_community, creator_is_admin, creator_is_moderator, creator_local_home_ban_expires, creator_local_home_banned, local_user_can_mod, post_community_tags_fragment, }, lemmy_db_views_local_user::LocalUserView, }; pub mod api; #[cfg(feature = "full")] pub mod impls; #[cfg(feature = "full")] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Queryable, Selectable)] #[diesel(check_for_backend(diesel::pg::Pg))] /// A combined search view pub(crate) struct SearchCombinedViewInternal { #[diesel(embed)] pub search_combined: SearchCombined, #[diesel(embed)] pub comment: Option, #[diesel(embed)] pub post: Option, #[diesel(embed)] pub item_creator: Option, #[diesel(embed)] pub community: Option, #[diesel(embed)] pub multi_community: Option, #[diesel(embed)] pub community_actions: Option, #[diesel(embed)] pub post_actions: Option, #[diesel(embed)] pub person_actions: Option, #[diesel(embed)] pub comment_actions: Option, #[diesel(embed)] pub image_details: Option, #[diesel(select_expression = creator_is_admin())] pub item_creator_is_admin: bool, #[diesel(select_expression = post_community_tags_fragment())] /// tags for this post pub tags: CommunityTagsView, #[diesel(select_expression = community_tags_fragment())] /// available tags in this community pub community_tags: CommunityTagsView, #[diesel(select_expression = local_user_can_mod())] pub can_mod: bool, #[diesel(select_expression = creator_local_home_banned())] pub creator_banned: bool, #[diesel( select_expression_type = CreatorLocalHomeBanExpiresType, select_expression = creator_local_home_ban_expires() )] pub creator_ban_expires_at: Option>, #[diesel(select_expression = creator_is_moderator())] pub creator_is_moderator: bool, #[diesel(select_expression = creator_banned_from_community())] pub creator_banned_from_community: bool, #[diesel(select_expression = creator_ban_expires_from_community())] pub creator_community_ban_expires_at: Option>, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] #[serde(tag = "type_", rename_all = "snake_case")] pub enum SearchCombinedView { Post(PostView), Comment(CommentView), Community(CommunityView), Person(PersonView), MultiCommunity(MultiCommunityView), } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Searches the site, given a search term, and some optional filters. pub struct Search { /// The search query. Can be a plain text, or an object ID which will be resolved /// (eg `https://lemmy.world/comment/1` or `!fediverse@lemmy.ml`). pub q: String, pub community_id: Option, pub community_name: Option, pub creator_id: Option, pub type_: Option, pub sort: Option, /// Filter to within a given time range, in seconds. /// IE 60 would give results for the past minute. pub time_range_seconds: Option, pub listing_type: Option, pub title_only: Option, pub post_url_only: Option, pub liked_only: Option, pub disliked_only: Option, /// If true, then show the nsfw posts (even if your user setting is to hide them) pub show_nsfw: Option, pub page_cursor: Option, pub limit: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The search response, containing lists of the return type possibilities pub struct SearchResponse { /// If `Search.q` contains an ActivityPub ID (eg `https://lemmy.world/comment/1`) or an /// identifier (eg `!fediverse@lemmy.ml`) then this field contains the resolved object. /// It should always be shown above other search results. pub resolve: Option, /// Items which contain the search string in post body, comment text, community sidebar etc. /// This is always empty when calling `/api/v4/resolve_object` pub search: Vec, /// the pagination cursor to use to fetch the next page pub next_page: Option, pub prev_page: Option, } ================================================ FILE: crates/db_views/site/Cargo.toml ================================================ [package] name = "lemmy_db_views_site" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_db_views_person/full", "lemmy_db_views_community/full", "anyhow", "extism", "i-love-jesus", ] ts-rs = [ "dep:ts-rs", "lemmy_db_schema/ts-rs", "lemmy_db_schema_file/ts-rs", "lemmy_db_views_community_follower/ts-rs", "lemmy_db_views_community_moderator/ts-rs", "lemmy_db_views_local_user/ts-rs", "lemmy_db_views_person/ts-rs", "lemmy_db_views_community/ts-rs", ] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_db_views_community_follower = { workspace = true } lemmy_db_views_community_moderator = { workspace = true } lemmy_db_views_local_user = { workspace = true } lemmy_db_views_person = { workspace = true } lemmy_diesel_utils = { workspace = true } lemmy_db_views_community = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } url = { workspace = true } extism = { workspace = true, optional = true } extism-convert = { workspace = true } anyhow = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } ================================================ FILE: crates/db_views/site/src/api.rs ================================================ use crate::SiteView; #[cfg(feature = "full")] use extism::FromBytes; use extism_convert::Json; use lemmy_db_schema::{ newtypes::{LanguageId, MultiCommunityId, OAuthProviderId, TaglineId}, source::{ comment::Comment, community::Community, instance::Instance, language::Language, local_site_url_blocklist::LocalSiteUrlBlocklist, local_user::LocalUser, login_token::LoginToken, oauth_provider::{AdminOAuthProvider, PublicOAuthProvider}, person::Person, post::Post, private_message::PrivateMessage, tagline::Tagline, }, }; use lemmy_db_schema_file::{ InstanceId, enums::{ CommentSortType, FederationMode, ImageMode, ListingType, PostListingMode, PostSortType, RegistrationMode, VoteShow, }, }; use lemmy_db_views_community::MultiCommunityView; use lemmy_db_views_community_follower::CommunityFollowerView; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_person::PersonView; use lemmy_diesel_utils::{pagination::PaginationCursor, sensitive::SensitiveString}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use url::Url; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct AdminAllowInstanceParams { pub instance: String, pub allow: bool, pub reason: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct AdminBlockInstanceParams { pub instance: String, pub block: bool, pub reason: String, /// A time that the block will expire, in unix epoch seconds. /// /// An i64 unix timestamp is used for a simpler API client implementation. pub expires_at: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Logging in with an OAuth 2.0 authorization pub struct AuthenticateWithOauth { pub code: String, pub oauth_provider_id: OAuthProviderId, pub redirect_uri: Url, pub show_nsfw: Option, /// Username is mandatory at registration time pub username: Option, /// An answer is mandatory if require application is enabled on the server pub answer: Option, pub pkce_code_verifier: Option, /// If this is true the login is valid forever, otherwise it expires after one week. pub stay_logged_in: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create an external auth method. pub struct CreateOAuthProvider { pub display_name: String, pub issuer: String, pub authorization_endpoint: String, pub token_endpoint: String, pub userinfo_endpoint: String, pub id_claim: String, pub client_id: String, pub client_secret: String, pub scopes: String, pub auto_verify_email: Option, pub account_linking_enabled: Option, pub use_pkce: Option, pub enabled: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Creates a site. Should be done after first running lemmy. pub struct CreateSite { pub name: String, pub sidebar: Option, pub summary: Option, pub community_creation_admin_only: Option, pub require_email_verification: Option, pub application_question: Option, pub private_instance: Option, pub default_theme: Option, pub default_post_listing_type: Option, pub default_post_listing_mode: Option, pub default_post_sort_type: Option, pub default_post_time_range_seconds: Option, pub default_items_per_page: Option, pub default_comment_sort_type: Option, pub legal_information: Option, pub application_email_admins: Option, pub discussion_languages: Option>, pub slur_filter_regex: Option, pub rate_limit_message_max_requests: Option, pub rate_limit_message_interval_seconds: Option, pub rate_limit_post_max_requests: Option, pub rate_limit_post_interval_seconds: Option, pub rate_limit_register_max_requests: Option, pub rate_limit_register_interval_seconds: Option, pub rate_limit_image_max_requests: Option, pub rate_limit_image_interval_seconds: Option, pub rate_limit_comment_max_requests: Option, pub rate_limit_comment_interval_seconds: Option, pub rate_limit_search_max_requests: Option, pub rate_limit_search_interval_seconds: Option, pub rate_limit_import_user_settings_max_requests: Option, pub rate_limit_import_user_settings_interval_seconds: Option, pub federation_enabled: Option, pub registration_mode: Option, pub oauth_registration: Option, pub content_warning: Option, pub reports_email_admins: Option, pub federation_signed_fetch: Option, pub post_upvotes: Option, pub post_downvotes: Option, pub comment_upvotes: Option, pub comment_downvotes: Option, pub disallow_nsfw_content: Option, pub disable_email_notifications: Option, pub suggested_multi_community_id: Option, pub image_mode: Option, pub image_proxy_bypass_domains: Option, pub image_upload_timeout_seconds: Option, pub image_max_thumbnail_size: Option, pub image_max_avatar_size: Option, pub image_max_banner_size: Option, pub image_max_upload_size: Option, pub image_allow_video_uploads: Option, pub image_upload_disabled: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete an external auth method. pub struct DeleteOAuthProvider { pub id: OAuthProviderId, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edit an external auth method. pub struct EditOAuthProvider { pub id: OAuthProviderId, pub display_name: Option, pub authorization_endpoint: Option, pub token_endpoint: Option, pub userinfo_endpoint: Option, pub id_claim: Option, pub client_secret: Option, pub scopes: Option, pub auto_verify_email: Option, pub account_linking_enabled: Option, pub use_pkce: Option, pub enabled: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Edits a site. pub struct EditSite { pub name: Option, /// A sidebar for the site, in markdown. pub sidebar: Option, /// A shorter, one line description of your site. pub summary: Option, /// Limits community creation to admins only. pub community_creation_admin_only: Option, /// Whether to require email verification. pub require_email_verification: Option, /// Your application question form. This is in markdown, and can be many questions. pub application_question: Option, /// Whether your instance is public, or private. pub private_instance: Option, /// The default theme. Usually "browser" pub default_theme: Option, /// The default post listing type, usually "local" pub default_post_listing_type: Option, /// Default value for listing mode, usually "list" pub default_post_listing_mode: Option, /// The default post sort, usually "active" pub default_post_sort_type: Option, /// A default time range limit to apply to post sorts, in seconds. 0 means none. pub default_post_time_range_seconds: Option, /// A default fetch limit for number of items returned. pub default_items_per_page: Option, /// The default comment sort, usually "hot" pub default_comment_sort_type: Option, /// An optional page of legal information pub legal_information: Option, /// Whether to email admins when receiving a new application. pub application_email_admins: Option, /// Whether to sign outgoing Activitypub fetches with private key of local instance. Some /// Fediverse instances and platforms require this. pub federation_signed_fetch: Option, /// A list of allowed discussion languages. pub discussion_languages: Option>, /// A regex string of items to filter. pub slur_filter_regex: Option, /// The number of messages allowed in a given time frame. pub rate_limit_message_max_requests: Option, pub rate_limit_message_interval_seconds: Option, /// The number of posts allowed in a given time frame. pub rate_limit_post_max_requests: Option, pub rate_limit_post_interval_seconds: Option, /// The number of registrations allowed in a given time frame. pub rate_limit_register_max_requests: Option, pub rate_limit_register_interval_seconds: Option, /// The number of image uploads allowed in a given time frame. pub rate_limit_image_max_requests: Option, pub rate_limit_image_interval_seconds: Option, /// The number of comments allowed in a given time frame. pub rate_limit_comment_max_requests: Option, pub rate_limit_comment_interval_seconds: Option, /// The number of searches allowed in a given time frame. pub rate_limit_search_max_requests: Option, pub rate_limit_search_interval_seconds: Option, /// The number of settings imports or exports allowed in a given time frame. pub rate_limit_import_user_settings_max_requests: Option, pub rate_limit_import_user_settings_interval_seconds: Option, /// Whether to enable federation. pub federation_enabled: Option, /// A list of blocked URLs pub blocked_urls: Option>, pub registration_mode: Option, /// Whether to email admins for new reports. pub reports_email_admins: Option, /// If present, nsfw content is visible by default. Should be displayed by frontends/clients /// when the site is first opened by a user. pub content_warning: Option, /// Whether or not external auth methods can auto-register users. pub oauth_registration: Option, /// What kind of post upvotes your site allows. pub post_upvotes: Option, /// What kind of post downvotes your site allows. pub post_downvotes: Option, /// What kind of comment upvotes your site allows. pub comment_upvotes: Option, /// What kind of comment downvotes your site allows. pub comment_downvotes: Option, /// Block NSFW content being created pub disallow_nsfw_content: Option, /// Dont send email notifications to users for new replies, mentions etc pub disable_email_notifications: Option, /// A multicommunity with suggested communities which is shown on the homepage. Sending a zero /// erases this field. pub suggested_multi_community_id: Option, /// A mode for setting how pictrs handles images. pub image_mode: Option, /// Allows bypassing proxy for specific image hosts when using [[ImageMode.ProxyAllImages]]. Use /// a comma-delimited string. /// /// Example: i.imgur.com,postimg.cc pub image_proxy_bypass_domains: Option, pub image_upload_timeout_seconds: Option, pub image_max_thumbnail_size: Option, pub image_max_avatar_size: Option, pub image_max_banner_size: Option, pub image_max_upload_size: Option, pub image_allow_video_uploads: Option, pub image_upload_disabled: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] pub enum GetFederatedInstancesKind { #[default] All, Linked, Allowed, Blocked, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct GetFederatedInstances { pub domain_filter: Option, pub kind: GetFederatedInstancesKind, pub page_cursor: Option, pub limit: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// An expanded response for a site. pub struct GetSiteResponse { pub site_view: SiteView, pub admins: Vec, pub version: String, pub all_languages: Vec, pub discussion_languages: Vec, /// If the site has any taglines, a random one is included here for displaying pub tagline: Option, /// A list of external auth methods your site supports. pub oauth_providers: Vec, pub admin_oauth_providers: Vec, pub blocked_urls: Vec, pub active_plugins: Vec, /// The number of seconds between the last application published, and approved / denied time. /// /// Useful for estimating when your application will be approved. pub last_application_duration_seconds: Option, pub captcha_enabled: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// The response for a site. pub struct SiteResponse { pub site_view: SiteView, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[cfg_attr(feature = "full", derive(FromBytes))] #[cfg_attr(feature = "full", encoding(Json))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A captcha response. pub struct CaptchaResponse { /// A Base64 encoded png pub png: String, /// A Base64 encoded wav audio pub wav: String, /// The UUID for the captcha item. pub uuid: String, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Changes your account password. pub struct ChangePassword { pub new_password: SensitiveString, pub new_password_verify: SensitiveString, pub old_password: SensitiveString, /// If this is true the login is valid forever, otherwise it expires after one week. pub stay_logged_in: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete your account. pub struct DeleteAccount { pub password: SensitiveString, pub delete_content: bool, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A wrapper for the captcha response. pub struct GetCaptchaResponse { /// Will be None if captchas are disabled. pub ok: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct GenerateTotpSecretResponse { pub totp_secret_url: SensitiveString, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct ListLoginsResponse { pub logins: Vec, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Logging into lemmy. /// /// Note: Banned users can still log in, to be able to do certain things like delete /// their account. pub struct Login { pub username_or_email: SensitiveString, pub password: SensitiveString, /// May be required, if totp is enabled for their account. pub totp_2fa_token: Option, /// If this is true the login is valid forever, otherwise it expires after one week. pub stay_logged_in: Option, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A response for your login. pub struct LoginResponse { /// This is None in response to `Register` if email verification is enabled, or the server /// requires registration applications. pub jwt: Option, /// If registration applications are required, this will return true for a signup response. pub registration_created: bool, /// If email verifications are required, this will return true for a signup response. pub verify_email_sent: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Your user info. pub struct MyUserInfo { pub local_user_view: LocalUserView, pub follows: Vec, pub moderates: Vec, pub multi_community_follows: Vec, pub community_blocks: Vec, pub instance_communities_blocks: Vec, pub instance_persons_blocks: Vec, pub person_blocks: Vec, pub keyword_blocks: Vec, pub discussion_languages: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Change your password after receiving a reset request. pub struct PasswordChangeAfterReset { pub token: SensitiveString, pub password: SensitiveString, pub password_verify: SensitiveString, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Reset your password via email. pub struct PasswordReset { pub email: SensitiveString, } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Make a request to resend your verification email. pub struct ResendVerificationEmail { pub email: SensitiveString, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Saves settings for your user. pub struct SaveUserSettings { /// Show nsfw posts. pub show_nsfw: Option, /// Blur nsfw posts. pub blur_nsfw: Option, /// Your user's theme. pub theme: Option, /// The default post listing type, usually "local" pub default_listing_type: Option, /// A post-view mode that changes how multiple post listings look. pub post_listing_mode: Option, /// The default post sort, usually "active" pub default_post_sort_type: Option, /// A default time range limit to apply to post sorts, in seconds. 0 means none. pub default_post_time_range_seconds: Option, /// A default fetch limit for number of items returned. pub default_items_per_page: Option, /// The default comment sort, usually "hot" pub default_comment_sort_type: Option, /// The language of the lemmy interface pub interface_language: Option, /// Your display name, which can contain strange characters, and does not need to be unique. pub display_name: Option, /// Your email. pub email: Option, /// Your bio / info, in markdown. pub bio: Option, /// Your matrix user id. Ex: @my_user:matrix.org pub matrix_user_id: Option, /// Whether to show or hide avatars. pub show_avatars: Option, /// Sends notifications to your email. pub send_notifications_to_email: Option, /// Whether this account is a bot account. Users can hide these accounts easily if they wish. pub bot_account: Option, /// Whether to show bot accounts. pub show_bot_accounts: Option, /// Whether to show read posts. pub show_read_posts: Option, /// A list of languages you are able to see discussion in. pub discussion_languages: Option>, // A list of keywords used for blocking posts having them in title,url or body. pub blocking_keywords: Option>, /// Open links in a new tab pub open_links_in_new_tab: Option, /// Enable infinite scroll pub infinite_scroll_enabled: Option, /// Whether user avatars or inline images in the UI that are gifs should be allowed to play or /// should be paused pub enable_animated_images: Option, /// Whether a user can send / receive private messages pub enable_private_messages: Option, /// Whether to auto-collapse bot comments. pub collapse_bot_comments: Option, /// Some vote display mode settings pub show_score: Option, pub show_upvotes: Option, pub show_downvotes: Option, pub show_upvote_percentage: Option, /// Whether to automatically mark fetched posts as read. pub auto_mark_fetched_posts_as_read: Option, /// Whether to hide posts containing images/videos. pub hide_media: Option, /// Whether to show vote totals given to others. pub show_person_votes: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct EditTotp { pub totp_token: String, pub enabled: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct EditTotpResponse { pub enabled: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Block an instance's persons. pub struct UserBlockInstancePersonsParams { pub instance_id: InstanceId, pub block: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Block an instance's communities. pub struct UserBlockInstanceCommunitiesParams { pub instance_id: InstanceId, pub block: bool, } #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Verify your email. pub struct VerifyEmail { pub token: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Create a tagline pub struct CreateTagline { pub content: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Delete a tagline pub struct DeleteTagline { pub id: TaglineId, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Fetches a list of taglines. pub struct ListTaglines { pub page_cursor: Option, pub limit: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct TaglineResponse { pub tagline: Tagline, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Update a tagline pub struct EditTagline { pub id: TaglineId, pub content: String, } #[derive(Serialize, Deserialize, Debug, Clone)] #[cfg_attr(feature = "full", derive(FromBytes))] #[cfg_attr(feature = "full", encoding(Json))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PluginMetadata { pub name: String, pub url: Option, pub description: Option, } impl PluginMetadata { pub fn new(name: &'static str, url: &'static str, description: &'static str) -> Self { Self { name: name.to_string(), url: url.parse().ok(), description: Some(description.to_string()), } } } #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Does an apub fetch for an object. pub struct ResolveObject { /// Can be the full url, or a shortened version like: !fediverse@lemmy.ml pub q: String, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] #[serde(tag = "type_", rename_all = "snake_case")] pub enum PostOrCommentOrPrivateMessage { Post(Post), Comment(Comment), PrivateMessage(PrivateMessage), } /// Backup of user data. This struct should never be changed so that the data can be used as a /// long-term backup in case the instance goes down unexpectedly. All fields are optional to allow /// importing partial backups. /// /// This data should not be parsed by apps/clients, but directly downloaded as a file. /// /// Be careful with any changes to this struct, to avoid breaking changes which could prevent /// importing older backups. #[derive(Debug, Serialize, Deserialize, Clone, Default)] #[serde(deny_unknown_fields)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct UserSettingsBackup { pub display_name: Option, pub bio: Option, pub avatar: Option, pub banner: Option, pub matrix_id: Option, pub bot_account: Option, // TODO: might be worth making a separate struct for settings backup, to avoid breakage in case // fields are renamed, and to avoid storing unnecessary fields like person_id or email pub settings: Option, #[serde(default)] pub followed_communities: Vec, #[serde(default)] pub saved_posts: Vec, #[serde(default)] pub saved_comments: Vec, #[serde(default)] pub blocked_communities: Vec, #[serde(default)] pub blocked_users: Vec, #[serde(default)] #[serde(alias = "blocked_instances")] // the name used by v0.19 pub blocked_instances_communities: Vec, #[serde(default)] pub blocked_instances_persons: Vec, #[serde(default)] pub blocking_keywords: Vec, #[serde(default)] pub discussion_languages: Vec, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Your exported data. pub struct ExportDataResponse { pub notifications: Vec, pub content: Vec, pub read_posts: Vec, pub liked: Vec, pub moderates: Vec, pub settings: UserSettingsBackup, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A response that completes successfully. pub struct SuccessResponse { pub success: bool, } impl Default for SuccessResponse { fn default() -> Self { SuccessResponse { success: true } } } /// Contains the amount of unread items of various types. For normal users this means the number of /// unread notifications, mods and admins get additional unread counts for reports, registration /// applications and pending follows to private communities. #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct UnreadCountsResponse { pub notification_count: i64, pub report_count: Option, pub pending_follow_count: Option, pub registration_application_count: Option, } ================================================ FILE: crates/db_views/site/src/impls.rs ================================================ use crate::{ FederatedInstanceView, SiteView, api::{GetFederatedInstances, GetFederatedInstancesKind, UserSettingsBackup}, }; use diesel::{ ExpressionMethods, JoinOnDsl, OptionalExtension, PgTextExpressionMethods, QueryDsl, SelectableHelper, }; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ source::{ actor_language::LocalUserLanguage, instance::{Instance, instance_keys as key}, keyword_block::LocalUserKeywordBlock, language::Language, local_user::LocalUser, person::Person, }, utils::limit_fetch, }; use lemmy_db_schema_file::{ InstanceId, schema::{ federation_allowlist, federation_blocklist, federation_queue_state, instance, local_site, local_site_rate_limit, site, }, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{CursorData, PagedResponse, PaginationCursorConversion, paginate_response}, traits::Crud, utils::fuzzy_search, }; use lemmy_utils::{ CacheLock, build_cache, error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult}, }; use std::{ collections::HashMap, sync::{Arc, LazyLock}, }; impl SiteView { pub async fn read_local(pool: &mut DbPool<'_>) -> LemmyResult { static CACHE: CacheLock = LazyLock::new(build_cache); CACHE .try_get_with((), async move { let conn = &mut get_conn(pool).await?; let local_site = site::table .inner_join(local_site::table) .inner_join(instance::table) .inner_join( local_site_rate_limit::table .on(local_site::id.eq(local_site_rate_limit::local_site_id)), ) .select(Self::as_select()) .first(conn) .await .optional()? .ok_or(LemmyErrorType::LocalSiteNotSetup)?; Ok(local_site) }) .await .map_err(|e: Arc| anyhow::anyhow!("err getting local site: {e:?}").into()) } /// A special site bot user, solely made for following non-local communities for /// multi-communities. pub async fn read_system_account(pool: &mut DbPool<'_>) -> LemmyResult { let site_view = SiteView::read_local(pool).await?; Person::read(pool, site_view.local_site.system_account).await } } pub async fn user_backup_list_to_user_settings_backup( local_user_view: LocalUserView, pool: &mut DbPool<'_>, ) -> LemmyResult { let lists = LocalUser::export_backup(pool, local_user_view.person.id).await?; let blocking_keywords = LocalUserKeywordBlock::read(pool, local_user_view.local_user.id).await?; let discussion_languages = LocalUserLanguage::read(pool, local_user_view.local_user.id).await?; let all_languages: HashMap<_, _> = Language::read_all(pool) .await? .into_iter() .map(|l| (l.id, l.code)) .collect(); let discussion_languages = discussion_languages .iter() .flat_map(|d| all_languages.get(d).cloned()) .collect(); let vec_into = |vec: Vec<_>| vec.into_iter().map(Into::into).collect(); Ok(UserSettingsBackup { display_name: local_user_view.person.display_name, bio: local_user_view.person.bio, avatar: local_user_view.person.avatar.map(Into::into), banner: local_user_view.person.banner.map(Into::into), matrix_id: local_user_view.person.matrix_user_id, bot_account: local_user_view.person.bot_account.into(), settings: Some(local_user_view.local_user), followed_communities: vec_into(lists.followed_communities), blocked_communities: vec_into(lists.blocked_communities), blocked_instances_communities: lists.blocked_instances_communities, blocked_instances_persons: lists.blocked_instances_persons, blocked_users: vec_into(lists.blocked_users), saved_posts: vec_into(lists.saved_posts), saved_comments: vec_into(lists.saved_comments), blocking_keywords, discussion_languages, }) } impl FederatedInstanceView { #[diesel::dsl::auto_type(no_type_alias)] fn joins() -> _ { instance::table // omit instance representing the local site .left_join(site::table.left_join(local_site::table)) .filter(local_site::id.is_null()) .left_join(federation_blocklist::table) .left_join(federation_allowlist::table) .left_join(federation_queue_state::table) } pub async fn list( pool: &mut DbPool<'_>, data: GetFederatedInstances, ) -> LemmyResult> { let limit = limit_fetch(data.limit, None)?; let mut query = Self::joins() .select(Self::as_select()) .limit(limit) .into_boxed(); if let Some(domain_filter) = &data.domain_filter { query = query.filter(instance::domain.ilike(fuzzy_search(domain_filter))) } query = match data.kind { GetFederatedInstancesKind::All => query, GetFederatedInstancesKind::Linked => { query.filter(federation_blocklist::instance_id.is_null()) } GetFederatedInstancesKind::Allowed => { query.filter(federation_allowlist::instance_id.is_not_null()) } GetFederatedInstancesKind::Blocked => { query.filter(federation_blocklist::instance_id.is_not_null()) } }; let mut pq = Self::paginate(query, &data.page_cursor, SortDirection::Desc, pool, None).await?; // Show recently updated instances and those with valid metadata first pq = pq .then_order_by(key::updated_at) .then_order_by(key::software) .then_order_by(key::id); let conn = &mut get_conn(pool).await?; let res = pq .get_results(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_response(res, limit, data.page_cursor) } pub async fn read(pool: &mut DbPool<'_>, instance_id: InstanceId) -> LemmyResult { let conn = &mut get_conn(pool).await?; Self::joins() .filter(instance::id.eq(instance_id)) .select(Self::as_select()) .get_result(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } impl PaginationCursorConversion for FederatedInstanceView { type PaginatedType = Instance; fn to_cursor(&self) -> CursorData { CursorData::new_id(self.instance.id.0) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { Instance::read(pool, InstanceId(cursor.id()?)).await } } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod tests { use crate::{ FederatedInstanceView, api::{GetFederatedInstances, GetFederatedInstancesKind}, }; use lemmy_db_schema::{ assert_length, source::{ federation_allowlist::{FederationAllowList, FederationAllowListForm}, federation_queue_state::FederationQueueState, instance::Instance, site::{Site, SiteInsertForm}, }, }; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use serial_test::serial; #[tokio::test] #[serial] async fn test_instance_list() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); // insert test data let instance0 = Instance::read_or_create(pool, "example0.com").await?; let instance1 = Instance::read_or_create(pool, "example1.com").await?; let site_form = SiteInsertForm::new("Example".to_string(), instance0.id); let site = Site::create(pool, &site_form).await?; let form = FederationAllowListForm::new(instance0.id); let allow = FederationAllowList::allow(pool, &form).await?; let queue_state = FederationQueueState { instance_id: instance0.id, fail_count: 5, last_successful_id: None, last_successful_published_time_at: None, last_retry_at: None, }; FederationQueueState::upsert(pool, &queue_state).await?; // run the query let data = GetFederatedInstances { domain_filter: None, kind: GetFederatedInstancesKind::Linked, page_cursor: None, limit: None, }; let list = FederatedInstanceView::list(pool, data).await?; assert_length!(2, list); // compare first result let list0 = &list[1]; assert_eq!(instance0.domain, list0.instance.domain); assert_eq!(Some(site), list0.site.clone()); assert_eq!( Some(queue_state.fail_count), list0.queue_state.clone().map(|q| q.fail_count) ); assert_eq!(Some(allow), list0.allowed); assert!(list0.blocked.is_none()); // compare second result let list1 = &list[0]; assert_eq!(instance1.domain, list1.instance.domain); assert!(list1.site.is_none()); assert!(list1.queue_state.is_none()); assert!(list1.allowed.is_none()); assert!(list1.blocked.is_none()); Instance::delete_all(pool).await?; Ok(()) } } ================================================ FILE: crates/db_views/site/src/lib.rs ================================================ #[cfg(feature = "full")] use diesel::{Queryable, Selectable}; use lemmy_db_schema::source::{ federation_allowlist::FederationAllowList, federation_blocklist::FederationBlockList, federation_queue_state::FederationQueueState, instance::Instance, local_site::LocalSite, local_site_rate_limit::LocalSiteRateLimit, site::Site, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; pub mod api; #[cfg(feature = "full")] pub mod impls; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A site view. pub struct SiteView { #[cfg_attr(feature = "full", diesel(embed))] pub site: Site, #[cfg_attr(feature = "full", diesel(embed))] pub local_site: LocalSite, #[cfg_attr(feature = "full", diesel(embed))] pub local_site_rate_limit: LocalSiteRateLimit, #[cfg_attr(feature = "full", diesel(embed))] pub instance: Instance, } #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct FederatedInstanceView { #[cfg_attr(feature = "full", diesel(embed))] pub instance: Instance, #[cfg_attr(feature = "full", diesel(embed))] pub site: Option, #[cfg_attr(feature = "full", diesel(embed))] pub queue_state: Option, #[cfg_attr(feature = "full", diesel(embed))] pub blocked: Option, #[cfg_attr(feature = "full", diesel(embed))] pub allowed: Option, } ================================================ FILE: crates/db_views/vote/Cargo.toml ================================================ [package] name = "lemmy_db_views_vote" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true [features] full = [ "lemmy_utils", "diesel", "diesel-async", "lemmy_db_schema/full", "lemmy_db_schema_file/full", "lemmy_diesel_utils/full", "i-love-jesus", ] ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } lemmy_diesel_utils = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } ts-rs = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } ================================================ FILE: crates/db_views/vote/src/impls.rs ================================================ use crate::{VoteView, VoteViewComment, VoteViewPost}; use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper}; use diesel_async::RunQueryDsl; use i_love_jesus::SortDirection; use lemmy_db_schema::{ newtypes::{CommentId, PostId}, source::{comment::CommentActions, post::PostActions}, utils::limit_fetch, }; use lemmy_db_schema_file::{ InstanceId, PersonId, aliases::creator_community_actions, joins::{creator_home_instance_actions_join, creator_local_instance_actions_join}, schema::{comment, comment_actions, community_actions, person, post, post_actions}, }; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, pagination::{ CursorData, PagedResponse, PaginationCursor, PaginationCursorConversion, paginate_response, }, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use serde::{Deserialize, Serialize}; impl VoteView { pub async fn list_for_post( pool: &mut DbPool<'_>, post_id: PostId, page_cursor: Option, limit: Option, local_instance_id: InstanceId, ) -> LemmyResult> { use lemmy_db_schema::source::post::post_actions_keys as key; let limit = limit_fetch(limit, None)?; let creator_community_actions_join = creator_community_actions.on( creator_community_actions .field(community_actions::community_id) .eq(post::community_id) .and( creator_community_actions .field(community_actions::person_id) .eq(post_actions::person_id), ), ); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); let query = post_actions::table .inner_join(person::table) .inner_join(post::table) .left_join(creator_community_actions_join) .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) .filter(post_actions::post_id.eq(post_id)) .filter(post_actions::vote_is_upvote.is_not_null()) .select(VoteViewPost::as_select()) .limit(limit) .into_boxed(); // Sorting by like score let query = VoteViewPost::paginate(query, &page_cursor, SortDirection::Asc, pool, None) .await? .then_order_by(key::vote_is_upvote) // Tie breaker .then_order_by(key::voted_at); let conn = &mut get_conn(pool).await?; let res = query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_vote_response(res, limit, page_cursor) } pub async fn list_for_comment( pool: &mut DbPool<'_>, comment_id: CommentId, page_cursor: Option, limit: Option, local_instance_id: InstanceId, ) -> LemmyResult> { use lemmy_db_schema::source::comment::comment_actions_keys as key; let limit = limit_fetch(limit, None)?; let creator_community_actions_join = creator_community_actions.on( creator_community_actions .field(community_actions::community_id) .eq(post::community_id) .and( creator_community_actions .field(community_actions::person_id) .eq(comment_actions::person_id), ), ); let creator_local_instance_actions_join: creator_local_instance_actions_join = creator_local_instance_actions_join(local_instance_id); let query = comment_actions::table .inner_join(person::table) .inner_join(comment::table.inner_join(post::table)) .left_join(creator_community_actions_join) .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) .filter(comment_actions::comment_id.eq(comment_id)) .filter(comment_actions::vote_is_upvote.is_not_null()) .select(VoteViewComment::as_select()) .limit(limit) .into_boxed(); // Sorting by like score let query = VoteViewComment::paginate(query, &page_cursor, SortDirection::Asc, pool, None) .await? .then_order_by(key::vote_is_upvote) // Tie breaker .then_order_by(key::voted_at); let conn = &mut get_conn(pool).await?; let res = query .load::(conn) .await .with_lemmy_type(LemmyErrorType::NotFound)?; paginate_vote_response(res, limit, page_cursor) } } // https://github.com/rust-lang/rust/issues/115590 #[expect(clippy::multiple_bound_locations)] fn paginate_vote_response< #[cfg(feature = "ts-rs")] T: ts_rs::TS, #[cfg(not(feature = "ts-rs"))] T, >( data: Vec, limit: i64, page_cursor: Option, ) -> LemmyResult> where T: PaginationCursorConversion + Serialize + for<'a> Deserialize<'a>, VoteView: From, { let res = paginate_response(data, limit, page_cursor)?; Ok(PagedResponse { items: res.items.into_iter().map(Into::into).collect(), next_page: res.next_page, prev_page: res.prev_page, }) } impl PaginationCursorConversion for VoteViewPost { type PaginatedType = PostActions; fn to_cursor(&self) -> CursorData { CursorData::new_multi([self.creator.id.0, self.post_id.0]) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let [creator_id, post_id] = cursor.multi()?; PostActions::read(pool, PostId(post_id), PersonId(creator_id)).await } } impl PaginationCursorConversion for VoteViewComment { type PaginatedType = CommentActions; fn to_cursor(&self) -> CursorData { CursorData::new_multi([self.creator.id.0, self.comment_id.0]) } async fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> LemmyResult { let [creator_id, comment_id] = cursor.multi()?; CommentActions::read(pool, CommentId(comment_id), PersonId(creator_id)).await } } #[cfg(test)] mod tests { use crate::VoteView; use lemmy_db_schema::{ source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm}, community::{Community, CommunityActions, CommunityInsertForm, CommunityPersonBanForm}, instance::Instance, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm}, }, traits::{Bannable, Likeable}, }; use lemmy_db_schema_file::InstanceId; use lemmy_diesel_utils::{connection::build_db_pool_for_tests, traits::Crud}; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn post_and_comment_vote_views() -> LemmyResult<()> { let pool = &build_db_pool_for_tests(); let pool = &mut pool.into(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "timmy_vv"); let inserted_timmy = Person::create(pool, &new_person).await?; let new_person_2 = PersonInsertForm::test_form(inserted_instance.id, "sara_vv"); let inserted_sara = Person::create(pool, &new_person_2).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "test community vv".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let new_post = PostInsertForm::new( "A test post vv".into(), inserted_timmy.id, inserted_community.id, ); let inserted_post = Post::create(pool, &new_post).await?; let comment_form = CommentInsertForm::new( inserted_timmy.id, inserted_post.id, "A test comment vv".into(), ); let inserted_comment = Comment::create(pool, &comment_form, None).await?; // Timmy upvotes his own post let timmy_post_vote_form = PostLikeForm::new(inserted_post.id, inserted_timmy.id, Some(true)); PostActions::like(pool, &timmy_post_vote_form).await?; // Sara downvotes timmy's post let sara_post_vote_form = PostLikeForm::new(inserted_post.id, inserted_sara.id, Some(false)); PostActions::like(pool, &sara_post_vote_form).await?; let mut expected_post_vote_views = [ VoteView { creator: inserted_sara.clone(), creator_banned: false, creator_banned_from_community: false, is_upvote: false, }, VoteView { creator: inserted_timmy.clone(), creator_banned: false, creator_banned_from_community: false, is_upvote: true, }, ]; expected_post_vote_views[1].creator.post_count = 1; expected_post_vote_views[1].creator.comment_count = 1; let read_post_vote_views = VoteView::list_for_post(pool, inserted_post.id, None, None, InstanceId(1)).await?; assert_eq!(read_post_vote_views.items, expected_post_vote_views); // Timothy votes down his own comment let timmy_comment_vote_form = CommentLikeForm::new(inserted_comment.id, inserted_timmy.id, Some(false)); CommentActions::like(pool, &timmy_comment_vote_form).await?; // Sara upvotes timmy's comment let sara_comment_vote_form = CommentLikeForm::new(inserted_comment.id, inserted_sara.id, Some(true)); CommentActions::like(pool, &sara_comment_vote_form).await?; let mut expected_comment_vote_views = [ VoteView { creator: inserted_timmy.clone(), creator_banned: false, creator_banned_from_community: false, is_upvote: false, }, VoteView { creator: inserted_sara.clone(), creator_banned: false, creator_banned_from_community: false, is_upvote: true, }, ]; expected_comment_vote_views[0].creator.post_count = 1; expected_comment_vote_views[0].creator.comment_count = 1; let read_comment_vote_views = VoteView::list_for_comment(pool, inserted_comment.id, None, None, InstanceId(1)).await?; assert_eq!(read_comment_vote_views.items, expected_comment_vote_views); // Ban timmy from that community let ban_timmy_form = CommunityPersonBanForm::new(inserted_community.id, inserted_timmy.id); CommunityActions::ban(pool, &ban_timmy_form).await?; // Make sure creator_banned_from_community is true let read_comment_vote_views_after_ban = VoteView::list_for_comment(pool, inserted_comment.id, None, None, InstanceId(1)).await?; assert!( read_comment_vote_views_after_ban .first() .is_some_and(|c| c.creator_banned_from_community) ); let read_post_vote_views_after_ban = VoteView::list_for_post(pool, inserted_post.id, None, None, InstanceId(1)).await?; assert!( read_post_vote_views_after_ban .get(1) .is_some_and(|p| p.creator_banned_from_community) ); // Cleanup Instance::delete(pool, inserted_instance.id).await?; Ok(()) } } ================================================ FILE: crates/db_views/vote/src/lib.rs ================================================ use lemmy_db_schema::{ newtypes::{CommentId, PostId}, source::person::Person, }; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] use { diesel::{ExpressionMethods, NullableExpressionMethods, Queryable, Selectable}, lemmy_db_schema::utils::queries::selects::creator_local_home_banned, lemmy_db_schema_file::{ aliases::creator_community_actions, schema::{comment, comment_actions, community_actions, post, post_actions}, }, }; #[cfg(feature = "full")] pub mod impls; /// Only used internally so no ts(export) #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] struct VoteViewPost { #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel(select_expression = creator_local_home_banned()))] pub creator_banned: bool, #[cfg_attr(feature = "full", diesel(select_expression = creator_community_actions .field(community_actions::received_ban_at) .nullable() .is_not_null()))] pub creator_banned_from_community: bool, #[cfg_attr(feature = "full", diesel(select_expression = post_actions::vote_is_upvote.assume_not_null()))] pub is_upvote: bool, #[cfg_attr(feature = "full", diesel(select_expression = post::id))] post_id: PostId, } /// Only used internally so no ts(export) #[derive(Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(Queryable, Selectable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] struct VoteViewComment { #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel(select_expression = creator_local_home_banned()))] pub creator_banned: bool, #[cfg_attr(feature = "full", diesel(select_expression = creator_community_actions .field(community_actions::received_ban_at) .nullable() .is_not_null()))] pub creator_banned_from_community: bool, #[cfg_attr(feature = "full", diesel(select_expression = comment_actions::vote_is_upvote.assume_not_null()))] pub is_upvote: bool, #[cfg_attr(feature = "full", diesel(select_expression = comment::id))] comment_id: CommentId, } #[skip_serializing_none] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// A vote view for checking a post or comments votes. pub struct VoteView { pub creator: Person, pub creator_banned: bool, pub creator_banned_from_community: bool, /// True means Upvote, False means Downvote. pub is_upvote: bool, } impl From for VoteView { fn from(v: VoteViewComment) -> Self { VoteView { creator: v.creator, creator_banned: v.creator_banned, creator_banned_from_community: v.creator_banned_from_community, is_upvote: v.is_upvote, } } } impl From for VoteView { fn from(v: VoteViewPost) -> Self { VoteView { creator: v.creator, creator_banned: v.creator_banned, creator_banned_from_community: v.creator_banned_from_community, is_upvote: v.is_upvote, } } } ================================================ FILE: crates/diesel_utils/Cargo.toml ================================================ [package] name = "lemmy_diesel_utils" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_diesel_utils" path = "src/lib.rs" doctest = false [lints] workspace = true [features] ts-rs = ["dep:ts-rs"] full = [ "diesel-async", "chrono", "diesel_migrations", "anyhow", "diesel_ltree", "tracing", "deadpool", "futures-util", "tokio", "tokio-postgres", "tokio-postgres-rustls", "rustls", "i-love-jesus", "lemmy_utils/full", "diesel", "activitypub_federation", "serde_urlencoded", "base64", "itertools", ] [dependencies] diesel = { workspace = true, optional = true } chrono = { workspace = true, optional = true } diesel_migrations = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } serde = { workspace = true } url = { workspace = true } activitypub_federation = { workspace = true, optional = true } diesel-derive-newtype = { workspace = true } diesel-async = { workspace = true, features = [ "deadpool", "postgres", ], optional = true } diesel_ltree = { workspace = true, optional = true } tracing = { workspace = true, optional = true } deadpool = { version = "0.12.3", features = ["rt_tokio_1"], optional = true } ts-rs = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } tokio = { workspace = true, optional = true } tokio-postgres = { workspace = true, optional = true } tokio-postgres-rustls = { workspace = true, optional = true } rustls = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } lemmy_utils = { workspace = true, features = ["full"], optional = true } serde_urlencoded = { version = "0.7.1", optional = true } base64 = { workspace = true, optional = true } serde_with = { workspace = true } itertools = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } diff = "0.1.13" itertools = { workspace = true } pathfinding = "4.14.0" unified-diff = { workspace = true } diesel_ltree = { workspace = true } lemmy_db_schema_file = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } pretty_assertions = { workspace = true } ================================================ FILE: crates/diesel_utils/build.rs ================================================ use std::path::Path; fn main() -> Result<(), Box> { let migrations_dir = Path::new("../../migrations/"); if !migrations_dir.exists() { return Err("Migrations dir not found".into()); } println!("cargo:rerun-if-changed={}", migrations_dir.display()); Ok(()) } ================================================ FILE: crates/diesel_utils/replaceable_schema/triggers.sql ================================================ -- A trigger is associated with a table instead of a schema, so they can't be in the `r` schema. This is -- okay if the function specified after `EXECUTE FUNCTION` is in `r`, since dropping the function drops the trigger. -- -- Triggers that update multiple tables should use this order: person_aggregates, comment_aggregates, -- post, community_aggregates, site_aggregates -- * The order matters because the updated rows are locked until the end of the transaction, and statements -- in a trigger don't use separate transactions. This means that updates closer to the beginning cause -- longer locks because the duration of each update extends the durations of the locks caused by previous -- updates. Long locks are worse on rows that have more concurrent transactions trying to update them. The -- listed order starts with tables that are less likely to have such rows. -- https://www.postgresql.org/docs/16/transaction-iso.html#XACT-READ-COMMITTED -- * Using the same order in every trigger matters because a deadlock is possible if multiple transactions -- update the same rows in a different order. -- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS -- -- -- Create triggers for both post and comments CREATE PROCEDURE r.post_or_comment (table_name text) LANGUAGE plpgsql AS $a$ BEGIN EXECUTE replace($b$ -- When a thing gets a vote, update its aggregates and its creator's aggregates CALL r.create_triggers ('thing_actions', $$ BEGIN WITH thing_diff AS ( UPDATE thing AS a SET score = a.score + diff.upvotes - diff.downvotes, upvotes = a.upvotes + diff.upvotes, downvotes = a.downvotes + diff.downvotes, controversy_rank = r.controversy_rank ((a.upvotes + diff.upvotes)::numeric, (a.downvotes + diff.downvotes)::numeric) FROM ( SELECT (thing_actions).thing_id, coalesce(sum(count_diff) FILTER (WHERE (thing_actions).vote_is_upvote), 0) AS upvotes, coalesce(sum(count_diff) FILTER (WHERE NOT (thing_actions).vote_is_upvote), 0) AS downvotes FROM select_old_and_new_rows AS old_and_new_rows WHERE (thing_actions).vote_is_upvote IS NOT NULL GROUP BY (thing_actions).thing_id) AS diff WHERE a.id = diff.thing_id AND (diff.upvotes, diff.downvotes) != (0, 0) RETURNING a.creator_id AS creator_id, diff.upvotes - diff.downvotes AS score) UPDATE person AS a SET thing_score = a.thing_score + diff.score FROM ( SELECT creator_id, sum(score) AS score FROM thing_diff GROUP BY creator_id) AS diff WHERE a.id = diff.creator_id AND diff.score != 0; RETURN NULL; END; $$); $b$, 'thing', table_name); END; $a$; CALL r.post_or_comment ('post'); CALL r.post_or_comment ('comment'); -- Create triggers that update counts in parent aggregates CREATE FUNCTION r.parent_comment_ids (path ltree) RETURNS SETOF int LANGUAGE sql IMMUTABLE parallel safe BEGIN ATOMIC SELECT comment_id::int FROM string_to_table (ltree2text (path), '.') AS comment_id -- Skip first and last LIMIT (nlevel (path) - 2) OFFSET 1; END; CALL r.create_triggers ('comment', $$ BEGIN -- Prevent infinite recursion IF ( SELECT count(*) FROM select_old_and_new_rows AS old_and_new_rows) = 0 THEN RETURN NULL; END IF; UPDATE person AS a SET comment_count = a.comment_count + diff.comment_count FROM ( SELECT (comment).creator_id, coalesce(sum(count_diff), 0) AS comment_count FROM select_old_and_new_rows AS old_and_new_rows WHERE r.is_counted (comment) GROUP BY (comment).creator_id) AS diff WHERE a.id = diff.creator_id AND diff.comment_count != 0; UPDATE comment AS a SET child_count = a.child_count + diff.child_count FROM ( SELECT parent_id, coalesce(sum(count_diff), 0) AS child_count FROM ( -- For each inserted or deleted comment, this outputs 1 row for each parent comment. -- For example, this: -- -- count_diff | (comment).path -- ------------+---------------- -- 1 | 0.5.6.7 -- 1 | 0.5.6.7.8 -- -- becomes this: -- -- count_diff | parent_id -- ------------+----------- -- 1 | 5 -- 1 | 6 -- 1 | 5 -- 1 | 6 -- 1 | 7 SELECT count_diff, parent_id FROM select_old_and_new_rows AS old_and_new_rows, LATERAL r.parent_comment_ids ((comment).path) AS parent_id) AS expanded_old_and_new_rows GROUP BY parent_id) AS diff WHERE a.id = diff.parent_id AND diff.child_count != 0; UPDATE post AS a SET comments = a.comments + diff.comments, newest_comment_time_at = GREATEST (a.newest_comment_time_at, diff.newest_comment_time_at), newest_comment_time_necro_at = GREATEST (a.newest_comment_time_necro_at, diff.newest_comment_time_necro_at) FROM ( SELECT post.id AS post_id, coalesce(sum(count_diff), 0) AS comments, -- Old rows are excluded using `count_diff = 1` max((comment).published_at) FILTER (WHERE count_diff = 1) AS newest_comment_time_at, max((comment).published_at) FILTER (WHERE count_diff = 1 -- Ignore comments from the post's creator AND post.creator_id != (comment).creator_id -- Ignore comments on old posts AND post.published_at > ((comment).published_at - '2 days'::interval)) AS newest_comment_time_necro_at FROM select_old_and_new_rows AS old_and_new_rows LEFT JOIN post ON post.id = (comment).post_id WHERE r.is_counted (comment) GROUP BY post.id) AS diff WHERE a.id = diff.post_id AND (diff.comments, GREATEST (a.newest_comment_time_at, diff.newest_comment_time_at), GREATEST (a.newest_comment_time_necro_at, diff.newest_comment_time_necro_at)) != (0, a.newest_comment_time_at, a.newest_comment_time_necro_at); UPDATE local_site AS a SET comments = a.comments + diff.comments FROM ( SELECT coalesce(sum(count_diff), 0) AS comments FROM select_old_and_new_rows AS old_and_new_rows WHERE r.is_counted (comment) AND (comment).local) AS diff WHERE diff.comments != 0; RETURN NULL; END; $$); CALL r.create_triggers ('post', $$ BEGIN UPDATE person AS a SET post_count = a.post_count + diff.post_count FROM ( SELECT (post).creator_id, coalesce(sum(count_diff), 0) AS post_count FROM select_old_and_new_rows AS old_and_new_rows WHERE r.is_counted (post) GROUP BY (post).creator_id) AS diff WHERE a.id = diff.creator_id AND diff.post_count != 0; UPDATE community AS a SET posts = a.posts + diff.posts, comments = a.comments + diff.comments FROM ( SELECT (post).community_id, coalesce(sum(count_diff), 0) AS posts, coalesce(sum(count_diff * (post).comments), 0) AS comments FROM select_old_and_new_rows AS old_and_new_rows WHERE r.is_counted (post) GROUP BY (post).community_id) AS diff WHERE a.id = diff.community_id AND (diff.posts, diff.comments) != (0, 0); UPDATE local_site AS a SET posts = a.posts + diff.posts FROM ( SELECT coalesce(sum(count_diff), 0) AS posts FROM select_old_and_new_rows AS old_and_new_rows WHERE r.is_counted (post) AND (post).local) AS diff WHERE diff.posts != 0; RETURN NULL; END; $$); CALL r.create_triggers ('community', $$ BEGIN UPDATE local_site AS a SET communities = a.communities + diff.communities FROM ( SELECT coalesce(sum(count_diff), 0) AS communities FROM select_old_and_new_rows AS old_and_new_rows WHERE r.is_counted (community) AND (community).local) AS diff WHERE diff.communities != 0; RETURN NULL; END; $$); -- Count subscribers for communities. -- subscribers should be updated only when a local community is followed by a local or remote person. -- subscribers_local should be updated only when a local person follows a local or remote community. CALL r.create_triggers ('community_actions', $$ BEGIN UPDATE community AS a SET subscribers = a.subscribers + diff.subscribers, subscribers_local = a.subscribers_local + diff.subscribers_local FROM ( SELECT (community_actions).community_id, coalesce(sum(count_diff) FILTER (WHERE community.local), 0) AS subscribers, coalesce(sum(count_diff) FILTER (WHERE person.local), 0) AS subscribers_local FROM select_old_and_new_rows AS old_and_new_rows LEFT JOIN community ON community.id = (community_actions).community_id LEFT JOIN person ON person.id = (community_actions).person_id WHERE (community_actions).followed_at IS NOT NULL GROUP BY (community_actions).community_id) AS diff WHERE a.id = diff.community_id AND (diff.subscribers, diff.subscribers_local) != (0, 0); RETURN NULL; END; $$); CALL r.create_triggers ('post_report', $$ BEGIN UPDATE post AS a SET report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count FROM ( SELECT (post_report).post_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (post_report).resolved AND NOT (post_report).violates_instance_rules), 0) AS unresolved_report_count FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (post_report).post_id) AS diff WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0) AND a.id = diff.post_id; RETURN NULL; END; $$); CALL r.create_triggers ('comment_report', $$ BEGIN UPDATE comment AS a SET report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count FROM ( SELECT (comment_report).comment_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (comment_report).resolved AND NOT (comment_report).violates_instance_rules), 0) AS unresolved_report_count FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (comment_report).comment_id) AS diff WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0) AND a.id = diff.comment_id; RETURN NULL; END; $$); CALL r.create_triggers ('community_report', $$ BEGIN UPDATE community AS a SET report_count = a.report_count + diff.report_count, unresolved_report_count = a.unresolved_report_count + diff.unresolved_report_count FROM ( SELECT (community_report).community_id, coalesce(sum(count_diff), 0) AS report_count, coalesce(sum(count_diff) FILTER (WHERE NOT (community_report).resolved), 0) AS unresolved_report_count FROM select_old_and_new_rows AS old_and_new_rows GROUP BY (community_report).community_id) AS diff WHERE (diff.report_count, diff.unresolved_report_count) != (0, 0) AND a.id = diff.community_id; RETURN NULL; END; $$); -- Change the order of some cascading deletions to make deletion triggers run before the deletion of rows that the triggers need to read CREATE FUNCTION r.delete_follow_before_person () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN DELETE FROM community_actions AS c WHERE c.person_id = OLD.id; RETURN OLD; END; $$; CREATE TRIGGER delete_follow BEFORE DELETE ON person FOR EACH ROW EXECUTE FUNCTION r.delete_follow_before_person (); -- Triggers that change values before insert or update CREATE FUNCTION r.comment_change_values () RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE id text = NEW.id::text; BEGIN -- Make `path` end with `id` if it doesn't already IF NOT (NEW.path ~ ('*.' || id)::lquery) THEN NEW.path = NEW.path || id; END IF; -- Set local ap_id IF NEW.local THEN NEW.ap_id = coalesce(NEW.ap_id, r.local_url ('/comment/' || id)); END IF; RETURN NEW; END $$; CREATE TRIGGER change_values BEFORE INSERT OR UPDATE ON comment FOR EACH ROW EXECUTE FUNCTION r.comment_change_values (); CREATE FUNCTION r.post_change_values () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Set local ap_id IF NEW.local THEN NEW.ap_id = coalesce(NEW.ap_id, r.local_url ('/post/' || NEW.id::text)); END IF; RETURN NEW; END $$; CREATE TRIGGER change_values BEFORE INSERT ON post FOR EACH ROW EXECUTE FUNCTION r.post_change_values (); CREATE FUNCTION r.private_message_change_values () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Set local ap_id IF NEW.local THEN NEW.ap_id = coalesce(NEW.ap_id, r.local_url ('/private_message/' || NEW.id::text)); END IF; RETURN NEW; END $$; CREATE TRIGGER change_values BEFORE INSERT ON private_message FOR EACH ROW EXECUTE FUNCTION r.private_message_change_values (); -- Combined tables triggers -- These insert (published_at, item_id) into X_combined tables -- Reports (comment_report, post_report, private_message_report) CREATE PROCEDURE r.create_report_combined_trigger (table_name text) LANGUAGE plpgsql AS $a$ BEGIN EXECUTE replace($b$ CREATE FUNCTION r.report_combined_thing_insert ( ) RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN INSERT INTO report_combined (published_at, thing_id) VALUES (NEW.published_at, NEW.id); RETURN NEW; END $$; CREATE FUNCTION r.report_combined_thing_update ( ) RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE report_combined SET resolved = NEW.resolved WHERE thing_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER report_combined_insert AFTER INSERT ON thing FOR EACH ROW EXECUTE FUNCTION r.report_combined_thing_insert ( ); CREATE TRIGGER report_combined_update AFTER UPDATE OF resolved ON thing FOR EACH ROW EXECUTE FUNCTION r.report_combined_thing_update ( ); $b$, 'thing', table_name); END; $a$; CALL r.create_report_combined_trigger ('post_report'); CALL r.create_report_combined_trigger ('comment_report'); CALL r.create_report_combined_trigger ('private_message_report'); CALL r.create_report_combined_trigger ('community_report'); -- person_content (comment, post) CREATE PROCEDURE r.create_person_content_combined_trigger (table_name text) LANGUAGE plpgsql AS $a$ BEGIN EXECUTE replace($b$ CREATE FUNCTION r.person_content_combined_thing_insert ( ) RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN INSERT INTO person_content_combined (published_at, thing_id, creator_id) VALUES (NEW.published_at, NEW.id, NEW.creator_id); RETURN NEW; END $$; CREATE TRIGGER person_content_combined AFTER INSERT ON thing FOR EACH ROW EXECUTE FUNCTION r.person_content_combined_thing_insert ( ); $b$, 'thing', table_name); END; $a$; CALL r.create_person_content_combined_trigger ('post'); CALL r.create_person_content_combined_trigger ('comment'); -- person_saved (comment, post) -- This one is a little different, because it triggers using x_actions.saved, -- Rather than any row insert -- TODO a hack because local is not currently on the post_view table -- https://github.com/LemmyNet/lemmy/pull/5616#discussion_r2064219628 CREATE PROCEDURE r.create_person_saved_combined_trigger (table_name text) LANGUAGE plpgsql AS $a$ BEGIN EXECUTE replace($b$ CREATE FUNCTION r.person_saved_combined_change_values_thing ( ) RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM person_saved_combined AS p WHERE p.person_id = OLD.person_id AND p.thing_id = OLD.thing_id; ELSIF (TG_OP = 'INSERT') THEN IF NEW.saved_at IS NOT NULL THEN INSERT INTO person_saved_combined (saved_at, person_id, thing_id, creator_id) SELECT NEW.saved_at, NEW.person_id, NEW.thing_id, t.creator_id FROM thing AS t WHERE t.id = NEW.thing_id; END IF; ELSIF (TG_OP = 'UPDATE') THEN IF NEW.saved_at IS NOT NULL THEN INSERT INTO person_saved_combined (saved_at, person_id, thing_id, creator_id) SELECT NEW.saved_at, NEW.person_id, NEW.thing_id, t.creator_id FROM thing AS t WHERE t.id = NEW.thing_id; -- If saved gets set as null, delete the row ELSE DELETE FROM person_saved_combined AS p WHERE p.person_id = NEW.person_id AND p.thing_id = NEW.thing_id; END IF; END IF; RETURN NULL; END $$; CREATE TRIGGER person_saved_combined AFTER INSERT OR DELETE OR UPDATE OF saved_at ON thing_actions FOR EACH ROW EXECUTE FUNCTION r.person_saved_combined_change_values_thing ( ); $b$, 'thing', table_name); END; $a$; CALL r.create_person_saved_combined_trigger ('post'); CALL r.create_person_saved_combined_trigger ('comment'); -- person_liked (comment, post) -- This one is a little different, because it triggers using x_actions.liked, -- Rather than any row insert -- TODO a hack because local is not currently on the post_view table -- https://github.com/LemmyNet/lemmy/pull/5616#discussion_r2064219628 CREATE PROCEDURE r.create_person_liked_combined_trigger (table_name text) LANGUAGE plpgsql AS $a$ BEGIN EXECUTE replace($b$ CREATE FUNCTION r.person_liked_combined_change_values_thing ( ) RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM person_liked_combined AS p WHERE p.person_id = OLD.person_id AND p.thing_id = OLD.thing_id; ELSIF (TG_OP = 'INSERT') THEN IF NEW.voted_at IS NOT NULL AND ( SELECT local FROM person WHERE id = NEW.person_id) = TRUE THEN INSERT INTO person_liked_combined (voted_at, vote_is_upvote, person_id, thing_id, creator_id) SELECT NEW.voted_at, NEW.vote_is_upvote, NEW.person_id, NEW.thing_id, t.creator_id FROM thing AS t WHERE t.id = NEW.thing_id; END IF; ELSIF (TG_OP = 'UPDATE') THEN IF NEW.voted_at IS NOT NULL AND ( SELECT local FROM person WHERE id = NEW.person_id) = TRUE THEN -- Here we have uniques on (person_id, post_id) and (person_id, comment_id) INSERT INTO person_liked_combined (voted_at, vote_is_upvote, person_id, thing_id, creator_id) SELECT NEW.voted_at, NEW.vote_is_upvote, NEW.person_id, NEW.thing_id, t.creator_id FROM thing AS t WHERE t.id = NEW.thing_id ON CONFLICT (person_id, thing_id) DO UPDATE SET voted_at = NEW.voted_at, vote_is_upvote = NEW.vote_is_upvote; -- If liked gets set as null, delete the row ELSE DELETE FROM person_liked_combined AS p WHERE p.person_id = NEW.person_id AND p.thing_id = NEW.thing_id; END IF; END IF; RETURN NULL; END $$; CREATE TRIGGER person_liked_combined AFTER INSERT OR DELETE OR UPDATE OF voted_at ON thing_actions FOR EACH ROW EXECUTE FUNCTION r.person_liked_combined_change_values_thing ( ); $b$, 'thing', table_name); END; $a$; CALL r.create_person_liked_combined_trigger ('post'); CALL r.create_person_liked_combined_trigger ('comment'); -- Prevent using delete instead of uplete on action tables CREATE FUNCTION r.require_uplete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF pg_trigger_depth() = 1 AND NOT starts_with (current_query(), '/**/') THEN RAISE 'using delete instead of uplete is not allowed for this table'; END IF; RETURN NULL; END $$; CREATE TRIGGER require_uplete BEFORE DELETE ON comment_actions FOR EACH STATEMENT EXECUTE FUNCTION r.require_uplete (); CREATE TRIGGER require_uplete BEFORE DELETE ON community_actions FOR EACH STATEMENT EXECUTE FUNCTION r.require_uplete (); CREATE TRIGGER require_uplete BEFORE DELETE ON instance_actions FOR EACH STATEMENT EXECUTE FUNCTION r.require_uplete (); CREATE TRIGGER require_uplete BEFORE DELETE ON person_actions FOR EACH STATEMENT EXECUTE FUNCTION r.require_uplete (); CREATE TRIGGER require_uplete BEFORE DELETE ON post_actions FOR EACH STATEMENT EXECUTE FUNCTION r.require_uplete (); -- search: (post, comment, community, person, multi_community) CREATE PROCEDURE r.create_search_combined_trigger (table_name text) LANGUAGE plpgsql AS $a$ BEGIN EXECUTE replace($b$ CREATE FUNCTION r.search_combined_thing_insert ( ) RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- TODO need to figure out how to do the other columns here INSERT INTO search_combined (published_at, thing_id) VALUES (NEW.published_at, NEW.id); RETURN NEW; END $$; CREATE TRIGGER search_combined AFTER INSERT ON thing FOR EACH ROW EXECUTE FUNCTION r.search_combined_thing_insert ( ); $b$, 'thing', table_name); END; $a$; CALL r.create_search_combined_trigger ('post'); CALL r.create_search_combined_trigger ('comment'); CALL r.create_search_combined_trigger ('community'); CALL r.create_search_combined_trigger ('person'); CALL r.create_search_combined_trigger ('multi_community'); -- You also need to triggers to update the `score` column. -- post | post::score -- comment | comment::score -- community | community::users_active_monthly -- person | person_aggregates::post_score -- multi-community | multi_community::subscribers -- -- Post score CREATE FUNCTION r.search_combined_post_score_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE search_combined SET score = NEW.score WHERE post_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER search_combined_post_score AFTER UPDATE OF score ON post FOR EACH ROW EXECUTE FUNCTION r.search_combined_post_score_update (); -- Comment score CREATE FUNCTION r.search_combined_comment_score_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE search_combined SET score = NEW.score WHERE comment_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER search_combined_comment_score AFTER UPDATE OF score ON comment FOR EACH ROW EXECUTE FUNCTION r.search_combined_comment_score_update (); -- Person score CREATE FUNCTION r.search_combined_person_score_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE search_combined SET score = NEW.post_score WHERE person_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER search_combined_person_score AFTER UPDATE OF post_score ON person FOR EACH ROW EXECUTE FUNCTION r.search_combined_person_score_update (); -- Community score CREATE FUNCTION r.search_combined_community_score_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE search_combined SET score = NEW.users_active_month WHERE community_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER search_combined_community_score AFTER UPDATE OF users_active_month ON community FOR EACH ROW EXECUTE FUNCTION r.search_combined_community_score_update (); -- Multi_community score CREATE FUNCTION r.search_combined_multi_community_score_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE search_combined SET score = NEW.subscribers WHERE multi_community_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER search_combined_multi_community_score AFTER UPDATE OF subscribers ON multi_community FOR EACH ROW EXECUTE FUNCTION r.search_combined_multi_community_score_update (); -- Increment / decrement multi_community counts CREATE FUNCTION r.multicommunity_community_increment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE multi_community SET communities = communities + 1 WHERE id = NEW.multi_community_id; RETURN NULL; END $$; CREATE FUNCTION r.multicommunity_community_decrement () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE multi_community SET communities = communities - 1 WHERE id = OLD.multi_community_id; RETURN NULL; END $$; CREATE TRIGGER multi_community_add_community AFTER INSERT ON multi_community_entry FOR EACH ROW EXECUTE FUNCTION r.multicommunity_community_increment (); CREATE TRIGGER multi_community_remove_community AFTER DELETE ON multi_community_entry FOR EACH ROW EXECUTE FUNCTION r.multicommunity_community_decrement (); -- Increment / decrement multi_community subscriber counts CREATE FUNCTION r.multicommunity_subscribers_increment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE multi_community AS m SET subscribers = subscribers + 1, subscribers_local = CASE WHEN p.local THEN subscribers_local + 1 ELSE subscribers_local END FROM person AS p WHERE m.id = NEW.multi_community_id AND p.id = NEW.person_id; RETURN NULL; END $$; CREATE FUNCTION r.multicommunity_subscribers_decrement () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE multi_community AS m SET subscribers = subscribers - 1, subscribers_local = CASE WHEN p.local THEN subscribers_local - 1 ELSE subscribers_local END FROM person AS p WHERE m.id = OLD.multi_community_id AND p.id = OLD.person_id; RETURN NULL; END $$; CREATE TRIGGER multi_community_update_add_subscribers AFTER UPDATE OF follow_state ON multi_community_follow FOR EACH ROW WHEN (OLD.follow_state != 'Accepted' AND NEW.follow_state = 'Accepted') EXECUTE FUNCTION r.multicommunity_subscribers_increment (); CREATE TRIGGER multi_community_update_remove_subscribers AFTER UPDATE OF follow_state ON multi_community_follow FOR EACH ROW WHEN (OLD.follow_state = 'Accepted' AND NEW.follow_state != 'Accepted') EXECUTE FUNCTION r.multicommunity_subscribers_decrement (); CREATE TRIGGER multi_community_add_subscribers AFTER INSERT ON multi_community_follow FOR EACH ROW WHEN (NEW.follow_state = 'Accepted') EXECUTE FUNCTION r.multicommunity_subscribers_increment (); CREATE TRIGGER multi_community_remove_subscribers AFTER DELETE ON multi_community_follow FOR EACH ROW WHEN (OLD.follow_state = 'Accepted') EXECUTE FUNCTION r.multicommunity_subscribers_decrement (); ================================================ FILE: crates/diesel_utils/replaceable_schema/utils.sql ================================================ -- Each calculation used in triggers should be a single SQL language -- expression so it can be inlined in migrations. CREATE FUNCTION r.controversy_rank (upvotes numeric, downvotes numeric) RETURNS real LANGUAGE sql IMMUTABLE PARALLEL SAFE RETURN CASE WHEN downvotes <= 0 OR upvotes <= 0 THEN 0 ELSE ( upvotes + downvotes) ^ CASE WHEN upvotes > downvotes THEN downvotes::float / upvotes::float ELSE upvotes::float / downvotes::float END END; CREATE FUNCTION r.hot_rank (score numeric, published_at timestamp with time zone) RETURNS real LANGUAGE sql IMMUTABLE PARALLEL SAFE RETURN -- after a week, it will default to 0. CASE WHEN ( now() - published_at) > '0 days' AND ( now() - published_at) < '7 days' THEN -- Use greatest(2,score), so that the hot_rank will be positive and not ignored. log ( greatest (2, score + 2)) / power (((EXTRACT(EPOCH FROM (now() - published_at)) / 3600) + 2), 1.8) ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future 0.0 END; CREATE FUNCTION r.scaled_rank (score numeric, published_at timestamp with time zone, interactions_month numeric) RETURNS real LANGUAGE sql IMMUTABLE PARALLEL SAFE -- Add 2 to avoid divide by zero errors -- Default for score = 1, active users = 1, and now, is (0.1728 / log(2 + 1)) = 0.3621 -- There may need to be a scale factor multiplied to interactions_month, to make -- the log curve less pronounced. This can be tuned in the future. RETURN ( r.hot_rank (score, published_at) / log(2 + interactions_month) ); -- For tables with `deleted` and `removed` columns, this function determines which rows to include in a count. CREATE FUNCTION r.is_counted (item record) RETURNS bool LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $$ BEGIN RETURN COALESCE(NOT (item.deleted OR item.removed), FALSE); END; $$; CREATE FUNCTION r.local_url (url_path text) RETURNS text LANGUAGE sql STABLE PARALLEL SAFE RETURN ( current_setting('lemmy.protocol_and_hostname') || url_path ); -- This function creates statement-level triggers for all operation types. It's designed this way -- because of these limitations: -- * A trigger that uses transition tables can only handle 1 operation type. -- * Transition tables must be relevant for the operation type (for example, `NEW TABLE` is -- not allowed for a `DELETE` trigger) -- * Transition tables are only provided to the trigger function, not to functions that it calls. -- -- This function can only be called once per table. The trigger function body is given as the 2nd argument -- and can contain these names, which are replaced with a `SELECT` statement in parenthesis if needed: -- * `select_old_rows` -- * `select_new_rows` -- * `select_old_and_new_rows` with 2 columns: -- 1. `count_diff`: `-1` for old rows and `1` for new rows, which can be used with `sum` to get the number -- to add to a count -- 2. (same name as the trigger's table): the old or new row as a composite value CREATE PROCEDURE r.create_triggers (table_name text, function_body text) LANGUAGE plpgsql AS $a$ DECLARE defs text := $$ -- Delete CREATE FUNCTION r.thing_delete_statement () RETURNS TRIGGER LANGUAGE plpgsql AS function_body_delete; CREATE TRIGGER delete_statement AFTER DELETE ON thing REFERENCING OLD TABLE AS select_old_rows FOR EACH STATEMENT EXECUTE FUNCTION r.thing_delete_statement ( ); -- Insert CREATE FUNCTION r.thing_insert_statement ( ) RETURNS TRIGGER LANGUAGE plpgsql AS function_body_insert; CREATE TRIGGER insert_statement AFTER INSERT ON thing REFERENCING NEW TABLE AS select_new_rows FOR EACH STATEMENT EXECUTE FUNCTION r.thing_insert_statement ( ); -- Update CREATE FUNCTION r.thing_update_statement ( ) RETURNS TRIGGER LANGUAGE plpgsql AS function_body_update; CREATE TRIGGER update_statement AFTER UPDATE ON thing REFERENCING OLD TABLE AS select_old_rows NEW TABLE AS select_new_rows FOR EACH STATEMENT EXECUTE FUNCTION r.thing_update_statement ( ); $$; select_old_and_new_rows text := $$ ( SELECT -1 AS count_diff, old_table::thing AS thing FROM select_old_rows AS old_table UNION ALL SELECT 1 AS count_diff, new_table::thing AS thing FROM select_new_rows AS new_table) $$; empty_select_new_rows text := $$ ( SELECT * FROM -- Real transition table select_old_rows WHERE FALSE) $$; empty_select_old_rows text := $$ ( SELECT * FROM -- Real transition table select_new_rows WHERE FALSE) $$; BEGIN function_body := replace(function_body, 'select_old_and_new_rows', select_old_and_new_rows); -- `select_old_rows` and `select_new_rows` are made available as empty tables if they don't already exist defs := replace(defs, 'function_body_delete', quote_literal(replace(function_body, 'select_new_rows', empty_select_new_rows))); defs := replace(defs, 'function_body_insert', quote_literal(replace(function_body, 'select_old_rows', empty_select_old_rows))); defs := replace(defs, 'function_body_update', quote_literal(function_body)); defs := replace(defs, 'thing', table_name); EXECUTE defs; END; $a$; -- Edit community aggregates to include voters as active users CREATE OR REPLACE FUNCTION r.community_aggregates_activity (i text) RETURNS TABLE ( count_ integer, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*)::integer, community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published_at > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT p.creator_id, p.community_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published_at > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT pa.person_id, p.community_id FROM post_actions pa INNER JOIN post p ON pa.post_id = p.id INNER JOIN person pe ON pa.person_id = pe.id WHERE pa.voted_at > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT ca.person_id, p.community_id FROM comment_actions ca INNER JOIN comment c ON ca.comment_id = c.id INNER JOIN post p ON c.post_id = p.id INNER JOIN person pe ON ca.person_id = pe.id WHERE ca.voted_at > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE) a GROUP BY community_id; END; $$; -- Community aggregate function for adding up total number of interactions CREATE OR REPLACE FUNCTION r.community_aggregates_interactions (i text) RETURNS TABLE ( count_ integer, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT COALESCE(sum(comments + upvotes + downvotes)::integer, 0) AS count_, community_id AS community_id_ FROM post WHERE published_at >= (CURRENT_TIMESTAMP - i::interval) GROUP BY community_id; END; $$; -- Edit site aggregates to include voters and people who have read posts as active users CREATE OR REPLACE FUNCTION r.site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published_at > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT p.creator_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published_at > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT pa.person_id FROM post_actions pa INNER JOIN person pe ON pa.person_id = pe.id WHERE pa.voted_at > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT ca.person_id FROM comment_actions ca INNER JOIN person pe ON ca.person_id = pe.id WHERE ca.voted_at > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE) a; RETURN count_; END; $$; ================================================ FILE: crates/diesel_utils/src/connection.rs ================================================ use deadpool::Runtime; use diesel::result::{ ConnectionError, ConnectionResult, Error::{self as DieselError, QueryBuilderError}, }; use diesel_async::{ AsyncConnection, pg::AsyncPgConnection, pooled_connection::{ AsyncDieselConnectionManager, ManagerConfig, deadpool::{Hook, HookError, Object as PooledConnection, Pool}, }, scoped_futures::ScopedBoxFuture, }; use futures_util::{FutureExt, future::BoxFuture}; use lemmy_utils::{ error::{LemmyError, LemmyResult}, settings::SETTINGS, }; use rustls::{ ClientConfig, DigitallySignedStruct, SignatureScheme, client::danger::{ DangerousClientConfigBuilder, HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, }, crypto::{self, verify_tls12_signature, verify_tls13_signature}, pki_types::{CertificateDer, ServerName, UnixTime}, }; use std::{ ops::{Deref, DerefMut}, sync::Arc, time::Duration, }; use tracing::error; pub type ActualDbPool = Pool; /// References a pool or connection. Functions must take `&mut DbPool<'_>` to allow implicit /// reborrowing. /// /// https://github.com/rust-lang/rfcs/issues/1403 pub enum DbPool<'a> { Pool(&'a ActualDbPool), Conn(&'a mut AsyncPgConnection), } pub enum DbConn<'a> { Pool(PooledConnection), Conn(&'a mut AsyncPgConnection), } pub async fn get_conn<'a, 'b: 'a>(pool: &'a mut DbPool<'b>) -> Result, DieselError> { Ok(match pool { DbPool::Pool(pool) => DbConn::Pool(pool.get().await.map_err(|e| QueryBuilderError(e.into()))?), DbPool::Conn(conn) => DbConn::Conn(conn), }) } impl DbConn<'_> { pub async fn run_transaction<'a, R, F>(&mut self, callback: F) -> LemmyResult where F: for<'r> FnOnce(&'r mut AsyncPgConnection) -> ScopedBoxFuture<'a, 'r, LemmyResult> + Send + 'a, R: Send + 'a, { self .deref_mut() .transaction::<_, LemmyError, _>(callback) .await } } impl Deref for DbConn<'_> { type Target = AsyncPgConnection; fn deref(&self) -> &Self::Target { match self { DbConn::Pool(conn) => conn.deref(), DbConn::Conn(conn) => conn.deref(), } } } impl DerefMut for DbConn<'_> { fn deref_mut(&mut self) -> &mut Self::Target { match self { DbConn::Pool(conn) => conn.deref_mut(), DbConn::Conn(conn) => conn.deref_mut(), } } } // Allows functions that take `DbPool<'_>` to be called in a transaction by passing `&mut // conn.into()` impl<'a> From<&'a mut AsyncPgConnection> for DbPool<'a> { fn from(value: &'a mut AsyncPgConnection) -> Self { DbPool::Conn(value) } } impl<'a, 'b: 'a> From<&'a mut DbConn<'b>> for DbPool<'a> { fn from(value: &'a mut DbConn<'b>) -> Self { DbPool::Conn(value.deref_mut()) } } impl<'a> From<&'a ActualDbPool> for DbPool<'a> { fn from(value: &'a ActualDbPool) -> Self { DbPool::Pool(value) } } /// Runs multiple async functions that take `&mut DbPool<'_>` as input and return `Result`. Only /// works when the `futures` crate is listed in `Cargo.toml`. /// /// `$pool` is the value given to each function. /// /// A `Result` is returned (not in a `Future`, so don't use `.await`). The `Ok` variant contains a /// tuple with the values returned by the given functions. /// /// The functions run concurrently if `$pool` has the `DbPool::Pool` variant. #[macro_export] macro_rules! try_join_with_pool { ($pool:ident => ($($func:expr),+)) => {{ // Check type let _: &mut $crate::connection::DbPool<'_> = $pool; match $pool { // Run concurrently with `try_join` $crate::connection::DbPool::Pool(__pool) => ::futures_util::try_join!( $(async { let mut __dbpool = $crate::connection::DbPool::Pool(__pool); ($func)(&mut __dbpool).await }),+ ), // Run sequentially $crate::connection::DbPool::Conn(__conn) => async { Ok(($({ let mut __dbpool = $crate::connection::DbPool::Conn(__conn); // `?` prevents the error type from being inferred in an `async` block, so `match` is used instead match ($func)(&mut __dbpool).await { ::core::result::Result::Ok(__v) => __v, ::core::result::Result::Err(__v) => return ::core::result::Result::Err(__v), } }),+)) }.await, } }}; } pub fn build_db_pool() -> LemmyResult { let db_url = SETTINGS.get_database_url_with_options()?; // diesel-async does not support any TLS connections out of the box, so we need to manually // provide a setup function which handles creating the connection let mut config = ManagerConfig::default(); config.custom_setup = Box::new(establish_connection); let manager = AsyncDieselConnectionManager::::new_with_config(&db_url, config); // Don't allow pool sizes below 2. See https://github.com/LemmyNet/lemmy/issues/5112 let pool_size = std::cmp::max(SETTINGS.database.pool_size, 2); let pool = Pool::builder(manager) .runtime(Runtime::Tokio1) .max_size(pool_size) .wait_timeout(Some(Duration::from_secs(1))) .create_timeout(Some(Duration::from_secs(5))) .recycle_timeout(Some(Duration::from_secs(5))) // Limit connection age to prevent use of prepared statements that have query plans based on // very old statistics .pre_recycle(Hook::sync_fn(|_conn, metrics| { // Preventing the first recycle can cause an infinite loop when trying to get a new connection // from the pool let conn_was_used = metrics.recycled.is_some(); if metrics.age() > Duration::from_secs(3 * 24 * 60 * 60) && conn_was_used { Err(HookError::Message("Connection is too old".into())) } else { Ok(()) } })) .build()?; crate::schema_setup::run(crate::schema_setup::Options::default().run(), &db_url)?; Ok(pool) } #[expect(clippy::expect_used)] pub fn build_db_pool_for_tests() -> ActualDbPool { build_db_pool().expect("db pool missing") } fn establish_connection(config: &str) -> BoxFuture<'_, ConnectionResult> { let fut = async { // We only support TLS with sslmode=require currently let conn = if config.contains("sslmode=require") { let rustls_config = DangerousClientConfigBuilder { cfg: ClientConfig::builder(), } .with_custom_certificate_verifier(Arc::new(NoCertVerifier {})) .with_no_client_auth(); let tls = tokio_postgres_rustls::MakeRustlsConnect::new(rustls_config); let (client, conn) = tokio_postgres::connect(config, tls) .await .map_err(|e| ConnectionError::BadConnection(e.to_string()))?; tokio::spawn(async move { if let Err(e) = conn.await { error!("Database connection failed: {e}"); } }); AsyncPgConnection::try_from(client).await? } else { AsyncPgConnection::establish(config).await? }; Ok(conn) }; fut.boxed() } #[derive(Debug)] struct NoCertVerifier {} impl ServerCertVerifier for NoCertVerifier { fn verify_server_cert( &self, _end_entity: &CertificateDer, _intermediates: &[CertificateDer], _server_name: &ServerName, _ocsp: &[u8], _now: UnixTime, ) -> Result { // Will verify all (even invalid) certs without any checks (sslmode=require) Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, message: &[u8], cert: &CertificateDer, dss: &DigitallySignedStruct, ) -> Result { verify_tls12_signature( message, cert, dss, &crypto::ring::default_provider().signature_verification_algorithms, ) } fn verify_tls13_signature( &self, message: &[u8], cert: &CertificateDer, dss: &DigitallySignedStruct, ) -> Result { verify_tls13_signature( message, cert, dss, &crypto::ring::default_provider().signature_verification_algorithms, ) } fn supported_verify_schemes(&self) -> Vec { crypto::ring::default_provider() .signature_verification_algorithms .supported_schemes() } } ================================================ FILE: crates/diesel_utils/src/dburl.rs ================================================ #[cfg(feature = "full")] use activitypub_federation::{ fetch::{collection_id::CollectionId, object_id::ObjectId}, traits::{Collection, Object}, }; #[cfg(feature = "full")] use diesel::{ backend::Backend, deserialize::{FromSql, FromSqlRow}, expression::AsExpression, pg::Pg, serialize::{Output, ToSql}, sql_types::Text, }; use serde::{Deserialize, Serialize}; use std::{ fmt::{Display, Formatter}, ops::Deref, }; use url::Url; #[repr(transparent)] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)] #[cfg_attr(feature = "full", derive(AsExpression, FromSqlRow))] #[cfg_attr(feature = "full", diesel(sql_type = diesel::sql_types::Text))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct DbUrl(pub Box); impl DbUrl { pub fn to_lowercase(&self) -> String { self.as_str().to_lowercase() } } impl DbUrl { pub fn inner(&self) -> &Url { &self.0 } } impl Display for DbUrl { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { self.clone().0.fmt(f) } } // the project doesn't compile with From #[expect(clippy::from_over_into)] impl Into for Url { fn into(self) -> DbUrl { DbUrl(Box::new(self)) } } #[expect(clippy::from_over_into)] impl Into for DbUrl { fn into(self) -> Url { *self.0 } } #[cfg(feature = "full")] impl From for ObjectId where T: Object + Send + 'static, for<'de2> ::Kind: Deserialize<'de2>, { fn from(value: DbUrl) -> Self { let url: Url = value.into(); ObjectId::from(url) } } #[cfg(feature = "full")] impl From for CollectionId where T: Collection + Send + 'static, for<'de2> ::Kind: Deserialize<'de2>, { fn from(value: DbUrl) -> Self { let url: Url = value.into(); CollectionId::from(url) } } #[cfg(feature = "full")] impl From> for DbUrl where T: Collection, for<'de2> ::Kind: Deserialize<'de2>, { fn from(value: CollectionId) -> Self { let url: Url = value.into(); url.into() } } impl Deref for DbUrl { type Target = Url; fn deref(&self) -> &Self::Target { &self.0 } } #[cfg(feature = "full")] impl ToSql for DbUrl { fn to_sql(&self, out: &mut Output) -> diesel::serialize::Result { >::to_sql(&self.0.to_string(), &mut out.reborrow()) } } #[cfg(feature = "full")] impl FromSql for DbUrl where String: FromSql, { fn from_sql(value: DB::RawValue<'_>) -> diesel::deserialize::Result { let str = String::from_sql(value)?; Ok(DbUrl(Box::new(Url::parse(&str)?))) } } #[cfg(feature = "full")] impl From> for DbUrl where Kind: Object + Send + 'static, for<'de2> ::Kind: serde::Deserialize<'de2>, { fn from(id: ObjectId) -> Self { DbUrl(Box::new(id.into())) } } ================================================ FILE: crates/diesel_utils/src/lib.rs ================================================ #[cfg(feature = "full")] pub mod connection; pub mod dburl; pub mod pagination; #[cfg(feature = "full")] pub mod schema_setup; pub mod sensitive; #[cfg(feature = "full")] pub mod traits; #[cfg(feature = "full")] pub mod utils; ================================================ FILE: crates/diesel_utils/src/main.rs ================================================ /// Very minimal wrapper around `lemmy_diesel_utils::run` to allow running migrations without /// compiling everything. fn main() -> anyhow::Result<()> { if std::env::args().len() > 1 { anyhow::bail!("To set parameters for running migrations, use the lemmy_server command."); } lemmy_diesel_utils::schema_setup::run( lemmy_diesel_utils::schema_setup::Options::default().run(), &std::env::var("LEMMY_DATABASE_URL")?, )?; Ok(()) } ================================================ FILE: crates/diesel_utils/src/pagination.rs ================================================ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::{ ops::{Deref, DerefMut}, sync::LazyLock, }; #[cfg(feature = "full")] use { crate::connection::DbPool, base64::{ Engine, alphabet::Alphabet, engine::{GeneralPurpose, general_purpose::NO_PAD}, }, i_love_jesus::{PaginatedQueryBuilder, SortDirection}, itertools::Itertools, lemmy_utils::error::LemmyErrorType, lemmy_utils::error::LemmyResult, }; /// Use base 64 engine with custom alphabet based on base64::engine::general_purpose::URL_SAFE /// with randomized character order, to prevent clients from parsing or modifying cursor data. #[cfg(feature = "full")] #[expect(clippy::expect_used)] static BASE64_ENGINE: LazyLock = LazyLock::new(|| { let alphabet = Alphabet::new("AphruVFwvCetlckdZ2g-foxXBGNbyHnD96qUj3KL_YsE7P1OQiaIR0z4T58mMWJS") .expect("create base64 alphabet"); GeneralPurpose::new(&alphabet, NO_PAD) }); #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] pub struct CursorData(String); #[cfg(feature = "full")] impl CursorData { pub fn new_id(id: i32) -> Self { Self(id.to_string()) } pub fn id(self) -> LemmyResult { Ok(self.0.parse()?) } pub fn new_with_prefix(prefix: char, id: i32) -> Self { Self(format!("{prefix},{id}")) } pub fn id_and_prefix(self) -> LemmyResult<(char, i32)> { let (prefix, id) = self .0 .split_once(',') .ok_or(LemmyErrorType::CouldntParsePaginationToken)?; let prefix = prefix .chars() .next() .ok_or(LemmyErrorType::CouldntParsePaginationToken)?; Ok((prefix, id.parse()?)) } pub fn new_plain(data: String) -> Self { Self(data) } pub fn plain(self) -> String { self.0 } pub fn new_multi(data: [i32; N]) -> Self { Self(data.into_iter().join(",")) } pub fn multi(self) -> LemmyResult<[i32; N]> { Ok( self .0 .split(",") .flat_map(|id| id.parse::().ok()) .collect::>() .try_into() .map_err(|_e| LemmyErrorType::CouldntParsePaginationToken)?, ) } } #[cfg(feature = "full")] pub trait PaginationCursorConversion { type PaginatedType: Send; fn to_cursor(&self) -> CursorData; fn from_cursor( cursor: CursorData, pool: &mut DbPool<'_>, ) -> impl Future> + Send; /// Paginate a db query. fn paginate( query: Q, cursor: &Option, sort_direction: SortDirection, pool: &mut DbPool<'_>, // this is only used by PostView for optimization page_before_or_equal: Option, ) -> impl std::future::Future>> + Send { async move { let (page_after, page_back, recovery) = if let Some(cursor) = cursor { let internal = cursor.clone().into_internal()?; let object = Self::from_cursor(internal.data, pool).await?; (Some(object), Some(internal.back), internal.recovery) } else { (None, None, false) }; let mut query = PaginatedQueryBuilder::new(query, sort_direction); if page_back.unwrap_or_default() { if recovery { query = query.before_or_equal(page_after); } else { query = query.before(page_after); } } else if recovery { query = query.after_or_equal(page_after); } else { query = query.after(page_after); } if page_back.unwrap_or_default() { query = query .after_or_equal(page_before_or_equal) .limit_and_offset_from_end(); } else { query = query.before_or_equal(page_before_or_equal); } Ok(query) } } } /// To get the next or previous page, pass this string unchanged as `page_cursor` in a new request /// to the same endpoint. /// /// Do not attempt to parse or modify the cursor string. The format is internal and may change in /// minor Lemmy versions. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PaginationCursor(String); #[cfg(feature = "full")] impl PaginationCursor { fn into_internal(self) -> LemmyResult { let decoded = BASE64_ENGINE.decode(self.0)?; Ok(serde_urlencoded::from_str(&String::from_utf8(decoded)?)?) } fn from_internal(other: PaginationCursorInternal) -> LemmyResult { let encoded = BASE64_ENGINE.encode(serde_urlencoded::to_string(other)?); Ok(Self(encoded)) } // only used for PostView optimization pub fn is_back(self) -> LemmyResult { Ok(self.into_internal()?.back) } } /// The actual data which is stored inside a cursor, not accessible outside this file. /// Uses serde rename to keep the cursor string short. #[skip_serializing_none] #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] struct PaginationCursorInternal { #[serde(rename = "b")] back: bool, #[serde(rename = "d")] data: CursorData, #[serde(rename = "r")] /// Allows to recover from empty pages without skipping an item by including the pointed to item /// in responses. recovery: bool, } /// This response contains only a single page of items. To get the next page, take the /// cursor string from `next_page` and pass it to the same API endpoint via `page_cursor` /// parameter. For going to the previous page, use `prev_page` instead. #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct PagedResponse<#[cfg(feature = "ts-rs")] T: ts_rs::TS, #[cfg(not(feature = "ts-rs"))] T> { pub items: Vec, pub next_page: Option, pub prev_page: Option, } #[cfg(feature = "full")] impl<#[cfg(feature = "ts-rs")] T: ts_rs::TS, #[cfg(not(feature = "ts-rs"))] T> Deref for PagedResponse { type Target = Vec; fn deref(&self) -> &Vec { &self.items } } #[cfg(feature = "full")] impl<#[cfg(feature = "ts-rs")] T: ts_rs::TS, #[cfg(not(feature = "ts-rs"))] T> DerefMut for PagedResponse { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.items } } #[cfg(feature = "full")] impl<#[cfg(feature = "ts-rs")] T: ts_rs::TS, #[cfg(not(feature = "ts-rs"))] T> IntoIterator for PagedResponse { type Item = T; type IntoIter = std::vec::IntoIter; // Required method fn into_iter(self) -> Self::IntoIter { self.items.into_iter() } } /// Add prev/next cursors to query result. #[cfg(feature = "full")] // https://github.com/rust-lang/rust/issues/115590 #[expect(clippy::multiple_bound_locations)] pub fn paginate_response<#[cfg(feature = "ts-rs")] T: ts_rs::TS, #[cfg(not(feature = "ts-rs"))] T>( data: Vec, limit: i64, request_cursor: Option, ) -> LemmyResult> where T: PaginationCursorConversion + Serialize + for<'a> Deserialize<'a>, { let make_cursor = |item: Option<&T>, back: bool| -> LemmyResult> { if let Some(item) = item { let data = item.to_cursor(); let cursor = PaginationCursorInternal { data, back, recovery: false, }; Ok(Some(PaginationCursor::from_internal(cursor)?)) } else { Ok(None) } }; let mut prev_page = make_cursor(data.first(), true)?; let mut next_page = make_cursor(data.last(), false)?; if let Ok(request_cursor) = &request_cursor .map(PaginationCursor::into_internal) .transpose() { // Need to convert here because diesel takes i64 for limit while vec length is usize. let limit: usize = limit.try_into().unwrap_or_default(); // Hide next and back buttons when possible. let back = request_cursor.as_ref().map(|r| r.back); match (data.len() < limit, back) { (false, None) => { prev_page = None; // no page before first } (true, None) => { prev_page = None; // no page before first next_page = None; } (true, Some(true)) => { prev_page = None; } (true, Some(false)) => { next_page = None; } (false, Some(_)) => {} }; // When a page_cursor points to the very last or first item, the response for that cursor // contains no items and therefore ordinarily no cursors. Simply changing the direction of the // request_cursor would allow users to escape these empty pages, but would skip the item that // the cursor points to. Marking the cursor as recovery cursor allows to include this item, and // as long as the list remains unchanged, to recover at the start or end of the list. The // easiest way to reproduce this is to press next on the first page, then back twice. if data.is_empty() && let Some(PaginationCursorInternal { back, data, recovery: false, }) = request_cursor { if *back { next_page = Some(PaginationCursor::from_internal(PaginationCursorInternal { back: false, data: data.clone(), recovery: true, })?); } else { prev_page = Some(PaginationCursor::from_internal(PaginationCursorInternal { back: true, data: data.clone(), recovery: true, })?); } } } Ok(PagedResponse { items: data, next_page, prev_page, }) } #[cfg(test)] mod test { use super::*; #[test] fn test_cursor() -> LemmyResult<()> { let data = CursorData::new_id(1); do_test_cursor(data)?; let data = CursorData::new_multi([1, 2]); do_test_cursor(data)?; Ok(()) } fn do_test_cursor(data: CursorData) -> LemmyResult<()> { let cursor = PaginationCursorInternal { back: true, data: data.clone(), recovery: false, }; let encoded = PaginationCursor::from_internal(cursor.clone())?; let cursor2 = encoded.into_internal()?; assert_eq!(cursor, cursor2); assert_eq!(data, cursor2.data); Ok(()) } #[test] fn test_internal_format() -> LemmyResult<()> { assert_eq!( serde_urlencoded::to_string(PaginationCursorInternal { back: true, data: CursorData::new_plain("test".into()), recovery: false, })?, "b=true&d=test&r=false" ); Ok(()) } } ================================================ FILE: crates/diesel_utils/src/schema_setup/diff_check.rs ================================================ #![cfg(test)] #![expect(clippy::expect_used)] use itertools::Itertools; use lemmy_utils::settings::SETTINGS; use pathfinding::matrix::Matrix; use std::{ borrow::Cow, io::Write, process::{Command, Stdio}, }; // It's not possible to call `export_snapshot()` for each dump and run the dumps in parallel with // the `--snapshot` flag. Don't waste your time!!!! /// Returns almost all things currently in the database, represented as SQL statements that would /// recreate them. pub(crate) fn get_dump() -> String { let db_url = SETTINGS.get_database_url(); let output = Command::new("pg_dump") .args([ // Specify database URL "--dbname", &db_url, // Allow differences in row data and old fast tables "--schema-only", "--exclude-table=comment_aggregates_fast", "--exclude-table=community_aggregates_fast", "--exclude-table=post_aggregates_fast", "--exclude-table=user_fast", // Ignore some things to reduce the amount of queries done by pg_dump "--no-owner", "--no-privileges", "--no-comments", "--no-publications", "--no-security-labels", "--no-subscriptions", "--no-table-access-method", "--no-tablespaces", "--no-large-objects", // Use a fake restrict key, rather than an auto-generated one. // See https://github.com/sqlc-dev/sqlc/issues/4065 "--restrict-key", "empty", ]) .stderr(Stdio::inherit()) .output() .expect("failed to start pg_dump process"); if !output.status.success() { std::io::stdout() .write_all(&output.stdout) .expect("write to stdout"); std::process::exit(1); } String::from_utf8(output.stdout).expect("pg_dump output is not valid UTF-8 text") } /// Checks dumps returned by [`get_dump`] and panics if they differ in a way that indicates a /// mistake in whatever was run in between the dumps. /// /// The panic message shows `label_of_change_from_0_to_1` and a diff from `dumps[0]` to `dumps[1]`. /// For example, if something only exists in `dumps[1]`, then the diff represents the addition of /// that thing. /// /// `label_of_change_from_0_to_1` must say something about the change from `dumps[0]` to `dumps[1]`, /// not `dumps[1]` to `dumps[0]`. This requires the two `dumps` elements being in an order that fits /// with `label_of_change_from_0_to_1`. This does not necessarily match the order in which the dumps /// were created. pub(crate) fn check_dump_diff(dumps: [&str; 2], label_of_change_from_0_to_1: &str) { let [sorted_statements_in_0, sorted_statements_in_1] = dumps.map(|dump| { dump .split("\n\n") .map(str::trim_start) .filter(|&chunk| !(is_ignored_trigger(chunk) || is_view(chunk) || is_comment(chunk))) .map(remove_ignored_uniqueness_from_statement) .sorted_unstable() .collect::>() }); let mut statements_only_in_0 = Vec::new(); let mut statements_only_in_1 = Vec::new(); for diff in diff::slice(&sorted_statements_in_0, &sorted_statements_in_1) { match diff { diff::Result::Left(statement) => statements_only_in_0.push(&**statement), diff::Result::Right(statement) => statements_only_in_1.push(&**statement), diff::Result::Both(_, _) => {} } } if !(statements_only_in_0.is_empty() && statements_only_in_1.is_empty()) { let (a, b): (String, String) = select_pairs([&statements_only_in_0, &statements_only_in_1]) .flat_map(|[a, b]| [(a, b), ("\n\n", "\n\n")]) .unzip(); let diff = unified_diff::diff(a.as_bytes(), "", b.as_bytes(), "", 10000); panic!( "{label_of_change_from_0_to_1}\n\n{}", String::from_utf8_lossy(&diff) ); } } fn is_ignored_trigger(chunk: &str) -> bool { [ "refresh_comment_like", "refresh_comment", "refresh_community_follower", "refresh_community_user_ban", "refresh_community", "refresh_post_like", "refresh_post", "refresh_private_message", "refresh_user", ] .into_iter() .any(|trigger_name| { [("CREATE FUNCTION public.", '('), ("CREATE TRIGGER ", ' ')] .into_iter() .any(|(before, after)| { chunk .strip_prefix(before) .and_then(|s| s.strip_prefix(trigger_name)) .is_some_and(|s| s.starts_with(after)) }) }) } fn is_view(chunk: &str) -> bool { [ "CREATE VIEW ", "CREATE OR REPLACE VIEW ", "CREATE MATERIALIZED VIEW ", ] .into_iter() .any(|prefix| chunk.starts_with(prefix)) } fn is_comment(s: &str) -> bool { s.lines().all(|line| line.starts_with("--")) } fn remove_ignored_uniqueness_from_statement(statement: &str) -> Cow<'_, str> { // Sort column names, so differences in column order are ignored if statement.starts_with("CREATE TABLE ") { let mut lines = statement .lines() .map(|line| line.strip_suffix(',').unwrap_or(line)) .collect::>(); sort_within_sections(&mut lines, |line| { match line.chars().next() { // CREATE Some('C') => 0, // Indented column name Some(' ') => 1, // End of column list Some(')') => 2, _ => panic!("unrecognized part of `CREATE TABLE` statement: {line}"), } }); Cow::Owned(lines.join("\n")) } else { Cow::Borrowed(statement) } } fn sort_within_sections(vec: &mut [&T], mut section: impl FnMut(&T) -> u8) { vec.sort_unstable_by_key(|&i| (section(i), i)); } /// For each string in list 0, makes a guess of which string in list 1 is a variant of it (or vice /// versa). fn select_pairs<'a>([a, b]: [&'a [&'a str]; 2]) -> impl Iterator { let len = std::cmp::max(a.len(), b.len()); let get_candidate_pair_at = |(row, column)| [a.get(row), b.get(column)].map(|item| *item.unwrap_or(&"")); let difference_amounts = Matrix::from_fn(len, len, |position| { amount_of_difference_between(get_candidate_pair_at(position)) }); pathfinding::kuhn_munkres::kuhn_munkres_min(&difference_amounts) .1 .into_iter() .enumerate() .map(get_candidate_pair_at) } /// Computes string distance, using the already required [`diff`] crate to avoid adding another /// dependency. fn amount_of_difference_between([a, b]: [&str; 2]) -> isize { diff::chars(a, b) .into_iter() .filter(|i| !matches!(i, diff::Result::Both(_, _))) .fold(0, |count, _| count.saturating_add(1)) } /// Makes sure the after dump does not contain any DEFERRABLE constraints. pub(crate) fn deferr_constraint_check(dump: &str) { if dump.contains(" DEFERR") { panic!("Schema should not have DEFER constraints.") } } // `#[cfg(test)]` would be redundant here mod tests { #[test] fn test_select_pairs() { let x = "Cupcake"; let x_variant = "Cupcaaaaake"; let y = "eee"; let y_variant = "ee"; let z = "bruh"; assert_eq!( super::select_pairs([&[x, y, z], &[y_variant, x_variant]]).collect::>(), vec![[x, x_variant], [y, y_variant], [z, ""]] ); } } ================================================ FILE: crates/diesel_utils/src/schema_setup/mod.rs ================================================ mod diff_check; use anyhow::{Context, anyhow}; use chrono::TimeDelta; use diesel::{ BoolExpressionMethods, Connection, ExpressionMethods, PgConnection, QueryDsl, RunQueryDsl, connection::SimpleConnection, dsl::exists, migration::{Migration, MigrationVersion}, pg::Pg, select, update, }; use diesel_migrations::MigrationHarness; use std::time::Instant; use tracing::debug; diesel::table! { pg_namespace (nspname) { nspname -> Text, } } diesel::table! { previously_run_sql (id) { id -> Bool, content -> Text, } } fn migrations() -> diesel_migrations::EmbeddedMigrations { // Using `const` here is required by the borrow checker const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations::embed_migrations!(); MIGRATIONS } /// This SQL code sets up the `r` schema, which contains things that can be safely dropped and /// replaced instead of being changed using migrations. It may not create or modify things outside /// of the `r` schema (indicated by `r.` before the name), unless a comment says otherwise. fn replaceable_schema() -> String { [ "CREATE SCHEMA r;", include_str!("../../replaceable_schema/utils.sql"), include_str!("../../replaceable_schema/triggers.sql"), ] .join("\n") } const REPLACEABLE_SCHEMA_PATH: &str = "crates/diesel_utils/replaceable_schema"; struct MigrationHarnessWrapper<'a> { conn: &'a mut PgConnection, #[cfg(test)] enable_diff_check: bool, options: &'a Options, } impl MigrationHarnessWrapper<'_> { fn run_migration_inner( &mut self, migration: &dyn Migration, ) -> diesel::migration::Result> { let start_time = Instant::now(); let result = self.conn.run_migration(migration); let duration = TimeDelta::from_std(start_time.elapsed()) .map(|d| d.to_string()) .unwrap_or_default(); let name = migration.name(); self.options.print(&format!("{duration} run {name}")); result } } impl MigrationHarness for MigrationHarnessWrapper<'_> { fn run_migration( &mut self, migration: &dyn Migration, ) -> diesel::migration::Result> { #[cfg(test)] if self.enable_diff_check { let before = diff_check::get_dump(); self.run_migration_inner(migration)?; self.revert_migration(migration)?; let after = diff_check::get_dump(); diff_check::check_dump_diff( [&after, &before], &format!( "These changes need to be applied in migrations/{}/down.sql:", migration.name() ), ); } self.run_migration_inner(migration) } fn revert_migration( &mut self, migration: &dyn Migration, ) -> diesel::migration::Result> { let start_time = Instant::now(); let result = self.conn.revert_migration(migration); let duration = TimeDelta::from_std(start_time.elapsed()) .map(|d| d.to_string()) .unwrap_or_default(); let name = migration.name(); self.options.print(&format!("{duration} revert {name}")); result } fn applied_migrations(&mut self) -> diesel::migration::Result>> { self.conn.applied_migrations() } } #[derive(Default, Clone, Copy)] pub struct Options { #[cfg(test)] enable_diff_check: bool, revert: bool, run: bool, print_output: bool, limit: Option, } impl Options { #[cfg(test)] fn enable_diff_check(mut self) -> Self { self.enable_diff_check = true; self } pub fn run(mut self) -> Self { self.run = true; self } pub fn revert(mut self) -> Self { self.revert = true; self } pub fn limit(mut self, limit: u64) -> Self { self.limit = Some(limit); self } /// If print_output is true, use println!. /// Otherwise, use debug! pub fn print_output(mut self) -> Self { self.print_output = true; self } fn print(&self, text: &str) { if self.print_output { println!("{text}"); } else { debug!("{text}"); } } } /// Checked by tests #[derive(PartialEq, Eq, Debug)] pub enum Branch { EarlyReturn, ReplaceableSchemaRebuilt, ReplaceableSchemaNotRebuilt, } pub fn run(options: Options, db_url: &str) -> anyhow::Result { // Migrations don't support async connection, and this function doesn't need to be async let conn = &mut PgConnection::establish(db_url)?; // If possible, skip getting a lock and recreating the "r" schema, so // lemmy_server processes in a horizontally scaled setup can start without causing locks if !options.revert && options.run && options.limit.is_none() && !conn .has_pending_migration(migrations()) .map_err(convert_err)? { // The condition above implies that the migration that creates the previously_run_sql table was // already run let sql_unchanged = exists( previously_run_sql::table.filter(previously_run_sql::content.eq(replaceable_schema())), ); let schema_exists = exists(pg_namespace::table.find("r")); if select(sql_unchanged.and(schema_exists)).get_result(conn)? { return Ok(Branch::EarlyReturn); } } // Block concurrent attempts to run migrations until `conn` is closed, and disable the // trigger that prevents the Diesel CLI from running migrations options.print("Waiting for lock..."); conn.batch_execute("SELECT pg_advisory_lock(0);")?; options.print("Running Database migrations (This may take a long time)..."); // Drop `r` schema, so migrations don't need to be made to work both with and without things in // it existing revert_replaceable_schema(conn)?; run_selected_migrations(conn, &options).map_err(convert_err)?; // Only run replaceable_schema if newest migration was applied let output = if (options.run && options.limit.is_none()) || !conn .has_pending_migration(migrations()) .map_err(convert_err)? { #[cfg(test)] if options.enable_diff_check { let before = diff_check::get_dump(); run_replaceable_schema(conn)?; revert_replaceable_schema(conn)?; let after = diff_check::get_dump(); diff_check::check_dump_diff( [&before, &after], "The code in crates/diesel_utils/replaceable_schema incorrectly created or modified things outside of the `r` schema, causing these changes to be left behind after dropping the schema:", ); diff_check::deferr_constraint_check(&after); } run_replaceable_schema(conn)?; Branch::ReplaceableSchemaRebuilt } else { Branch::ReplaceableSchemaNotRebuilt }; options.print("Database migrations complete."); Ok(output) } fn run_replaceable_schema(conn: &mut PgConnection) -> anyhow::Result<()> { conn.transaction(|conn| { conn .batch_execute(&replaceable_schema()) .with_context(|| format!("Failed to run SQL files in {REPLACEABLE_SCHEMA_PATH}"))?; let num_rows_updated = update(previously_run_sql::table) .set(previously_run_sql::content.eq(replaceable_schema())) .execute(conn)?; debug_assert_eq!(num_rows_updated, 1); Ok(()) }) } fn revert_replaceable_schema(conn: &mut PgConnection) -> anyhow::Result<()> { conn .batch_execute("DROP SCHEMA IF EXISTS r CASCADE;") .with_context(|| format!("Failed to revert SQL files in {REPLACEABLE_SCHEMA_PATH}"))?; // Value in `previously_run_sql` table is not set here because the table might not exist, // and that's fine because the existence of the `r` schema is also checked Ok(()) } fn run_selected_migrations( conn: &mut PgConnection, options: &Options, ) -> diesel::migration::Result<()> { let mut wrapper = MigrationHarnessWrapper { conn, options, #[cfg(test)] enable_diff_check: options.enable_diff_check, }; if options.revert { if let Some(limit) = options.limit { for _ in 0..limit { wrapper.revert_last_migration(migrations())?; } } else { wrapper.revert_all_migrations(migrations())?; } } if options.run { if let Some(limit) = options.limit { for _ in 0..limit { wrapper.run_next_migration(migrations())?; } } else { wrapper.run_pending_migrations(migrations())?; } } Ok(()) } /// Makes `diesel::migration::Result` work with `anyhow` and `LemmyError` fn convert_err(e: Box) -> anyhow::Error { anyhow!(e) } #[cfg(test)] #[expect(clippy::indexing_slicing, clippy::unwrap_used)] mod tests { use super::{ Branch::{EarlyReturn, ReplaceableSchemaNotRebuilt, ReplaceableSchemaRebuilt}, *, }; use diesel::{ dsl::{not, sql}, sql_types, }; use diesel_ltree::Ltree; use lemmy_utils::{error::LemmyResult, settings::SETTINGS}; use serial_test::serial; // The number of migrations that should be run to set up some test data. // Currently, this includes migrations until // 2020-04-07-135912_add_user_community_apub_constraints, since there are some mandatory apub // fields need to be added. const INITIAL_MIGRATIONS_COUNT: u64 = 40; // Test data IDs const TEST_USER_ID_1: i32 = 101; const USER1_NAME: &str = "test_user_1"; const USER1_ACTOR_ID: &str = "test_user_1@fedi.example"; const USER1_PREFERRED_NAME: &str = "preferred_1"; const USER1_EMAIL: &str = "email1@example.com"; const USER1_PASSWORD: &str = "test_password_1"; const USER1_PUBLIC_KEY: &str = "test_public_key_1"; const TEST_USER_ID_2: i32 = 102; const USER2_NAME: &str = "test_user_2"; const USER2_ACTOR_ID: &str = "test_user_2@fedi.example"; const USER2_PREFERRED_NAME: &str = "preferred2"; const USER2_EMAIL: &str = "email2@example.com"; const USER2_PASSWORD: &str = "test_password_2"; const USER2_PUBLIC_KEY: &str = "test_public_key_2"; const TEST_COMMUNITY_ID_1: i32 = 101; const COMMUNITY_NAME: &str = "test_community_1"; const COMMUNITY_TITLE: &str = "Test Community 1"; const COMMUNITY_DESCRIPTION: &str = "This is a test community."; const CATEGORY_ID: i32 = 4; // Should be a valid category "Movies" const COMMUNITY_ACTOR_ID: &str = "https://fedi.example/community/12345"; const COMMUNITY_PUBLIC_KEY: &str = "test_public_key_community_1"; const TEST_POST_ID_1: i32 = 101; const POST_NAME: &str = "Post Title"; const POST_URL: &str = "https://fedi.example/post/12345"; const POST_BODY: &str = "Post Body."; const POST_AP_ID: &str = "https://fedi.example/post/12345"; const TEST_COMMENT_ID_1: i32 = 101; const COMMENT1_CONTENT: &str = "Comment"; const COMMENT1_AP_ID: &str = "https://fedi.example/comment/12345"; const TEST_COMMENT_ID_2: i32 = 102; const COMMENT2_CONTENT: &str = "Reply"; const COMMENT2_AP_ID: &str = "https://fedi.example/comment/12346"; #[test] #[serial] fn test_schema_setup() -> LemmyResult<()> { let o = Options::default(); let db_url = SETTINGS.get_database_url(); let conn = &mut PgConnection::establish(&db_url)?; // Start with consistent state by dropping everything conn.batch_execute("DROP OWNED BY CURRENT_USER;")?; // Run initial migrations to prepare basic tables assert_eq!( run(o.run().limit(INITIAL_MIGRATIONS_COUNT), &db_url)?, ReplaceableSchemaNotRebuilt ); // Insert the test data insert_test_data(conn)?; // Run all migrations, and make sure that changes can be correctly reverted assert_eq!( run(o.run().enable_diff_check(), &db_url)?, ReplaceableSchemaRebuilt ); // Check the test data we inserted before after running migrations check_test_data(conn)?; // Check the current schema assert_eq!( get_foreign_keys_with_missing_indexes(conn)?, Vec::::new(), "each foreign key needs an index so that deleting the referenced row does not scan the whole referencing table" ); // Check for early return assert_eq!(run(o.run(), &db_url)?, EarlyReturn); // Test `limit` assert_eq!( run(o.revert().limit(1), &db_url)?, ReplaceableSchemaNotRebuilt ); assert_eq!( conn .pending_migrations(migrations()) .map_err(convert_err)? .len(), 1 ); assert_eq!(run(o.run().limit(1), &db_url)?, ReplaceableSchemaRebuilt); // Get a new connection, workaround for error `cache lookup failed for function 26633` // on `migrations/2025-10-15-114811-0000_merge-modlog-tables/down.sql`. let conn = &mut PgConnection::establish(&db_url)?; // This should throw an error saying to use lemmy_server instead of diesel CLI conn.batch_execute("DROP OWNED BY CURRENT_USER;")?; assert!(matches!( conn.run_pending_migrations(migrations()), Err(e) if e.to_string().contains("lemmy_server") )); // Diesel CLI's way of running migrations shouldn't break the custom migration runner assert_eq!(run(o.run(), &db_url)?, ReplaceableSchemaRebuilt); Ok(()) } fn insert_test_data(conn: &mut PgConnection) -> LemmyResult<()> { // Users conn.batch_execute(&format!( "INSERT INTO user_ (id, name, actor_id, preferred_username, password_encrypted, email, public_key) \ VALUES ({}, '{}', '{}', '{}', '{}', '{}', '{}')", TEST_USER_ID_1, USER1_NAME, USER1_ACTOR_ID, USER1_PREFERRED_NAME, USER1_PASSWORD, USER1_EMAIL, USER1_PUBLIC_KEY ))?; conn.batch_execute(&format!( "INSERT INTO user_ (id, name, actor_id, preferred_username, password_encrypted, email, public_key) \ VALUES ({}, '{}', '{}', '{}', '{}', '{}', '{}')", TEST_USER_ID_2, USER2_NAME, USER2_ACTOR_ID, USER2_PREFERRED_NAME, USER2_PASSWORD, USER2_EMAIL, USER2_PUBLIC_KEY ))?; // Community conn.batch_execute(&format!( "INSERT INTO community (id, actor_id, public_key, name, title, description, category_id, creator_id) \ VALUES ({}, '{}', '{}', '{}', '{}', '{}', {}, {})", TEST_COMMUNITY_ID_1, COMMUNITY_ACTOR_ID, COMMUNITY_PUBLIC_KEY, COMMUNITY_NAME, COMMUNITY_TITLE, COMMUNITY_DESCRIPTION, CATEGORY_ID, TEST_USER_ID_1 ))?; conn.batch_execute(&format!( "INSERT INTO community_moderator (community_id, user_id) \ VALUES ({}, {})", TEST_COMMUNITY_ID_1, TEST_USER_ID_1 ))?; // Post conn.batch_execute(&format!( "INSERT INTO post (id, name, url, body, creator_id, community_id, ap_id) \ VALUES ({}, '{}', '{}', '{}', {}, {}, '{}')", TEST_POST_ID_1, POST_NAME, POST_URL, POST_BODY, TEST_USER_ID_1, TEST_COMMUNITY_ID_1, POST_AP_ID ))?; // Comment conn.batch_execute(&format!( "INSERT INTO comment (id, creator_id, post_id, parent_id, content, ap_id) \ VALUES ({}, {}, {}, NULL, '{}', '{}')", TEST_COMMENT_ID_1, TEST_USER_ID_2, TEST_POST_ID_1, COMMENT1_CONTENT, COMMENT1_AP_ID ))?; conn.batch_execute(&format!( "INSERT INTO comment (id, creator_id, post_id, parent_id, content, ap_id) \ VALUES ({}, {}, {}, {}, '{}', '{}')", TEST_COMMENT_ID_2, TEST_USER_ID_1, TEST_POST_ID_1, TEST_COMMENT_ID_1, COMMENT2_CONTENT, COMMENT2_AP_ID ))?; conn.batch_execute(&format!( "INSERT INTO comment_like (user_id, comment_id, post_id, score) \ VALUES ({}, {}, {}, {})", TEST_USER_ID_1, TEST_COMMENT_ID_1, TEST_POST_ID_1, 1 ))?; Ok(()) } fn check_test_data(conn: &mut PgConnection) -> LemmyResult<()> { use lemmy_db_schema_file::schema::{comment, community, notification, person, post}; // Check users let users: Vec<(i32, String, Option, String, String)> = person::table .select(( person::id, person::name, person::display_name, person::ap_id, person::public_key, )) .order_by(person::id) .load(conn) .map_err(|e| anyhow!("Failed to read users: {}", e))?; assert_eq!(users.len(), 2); assert_eq!(users[0].0, TEST_USER_ID_1); assert_eq!(users[0].1, USER1_NAME); assert_eq!(users[0].2.clone().unwrap(), USER1_PREFERRED_NAME); assert_eq!(users[0].3, USER1_ACTOR_ID); assert_eq!(users[0].4, USER1_PUBLIC_KEY); assert_eq!(users[1].0, TEST_USER_ID_2); assert_eq!(users[1].1, USER2_NAME); assert_eq!(users[1].2.clone().unwrap(), USER2_PREFERRED_NAME); assert_eq!(users[1].3, USER2_ACTOR_ID); assert_eq!(users[1].4, USER2_PUBLIC_KEY); // Check communities let communities: Vec<(i32, String, String, String)> = community::table .select(( community::id, community::name, community::ap_id, community::public_key, )) .load(conn) .map_err(|e| anyhow!("Failed to read communities: {}", e))?; assert_eq!(communities.len(), 1); assert_eq!(communities[0].0, TEST_COMMUNITY_ID_1); assert_eq!(communities[0].1, COMMUNITY_NAME); assert_eq!(communities[0].2, COMMUNITY_ACTOR_ID); assert_eq!(communities[0].3, COMMUNITY_PUBLIC_KEY); let posts: Vec<(i32, String, String, Option, i32, i32)> = post::table .select(( post::id, post::name, post::ap_id, post::body, post::community_id, post::creator_id, )) .load(conn) .map_err(|e| anyhow!("Failed to read posts: {}", e))?; assert_eq!(posts.len(), 1); assert_eq!(posts[0].0, TEST_POST_ID_1); assert_eq!(posts[0].1, POST_NAME); assert_eq!(posts[0].2, POST_AP_ID); assert_eq!(posts[0].3.clone().unwrap(), POST_BODY); assert_eq!(posts[0].4, TEST_COMMUNITY_ID_1); assert_eq!(posts[0].5, TEST_USER_ID_1); let comments: Vec<(i32, String, String, i32, i32, Ltree, i32)> = comment::table .select(( comment::id, comment::content, comment::ap_id, comment::post_id, comment::creator_id, comment::path, comment::upvotes, )) .order_by(comment::id) .load(conn) .map_err(|e| anyhow!("Failed to read comments: {}", e))?; assert_eq!(comments.len(), 2); assert_eq!(comments[0].0, TEST_COMMENT_ID_1); assert_eq!(comments[0].1, COMMENT1_CONTENT); assert_eq!(comments[0].2, COMMENT1_AP_ID); assert_eq!(comments[0].3, TEST_POST_ID_1); assert_eq!(comments[0].4, TEST_USER_ID_2); assert_eq!( comments[0].5, Ltree(format!("0.{}", TEST_COMMENT_ID_1).to_string()) ); assert_eq!(comments[0].6, 1); // One upvote assert_eq!(comments[1].0, TEST_COMMENT_ID_2); assert_eq!(comments[1].1, COMMENT2_CONTENT); assert_eq!(comments[1].2, COMMENT2_AP_ID); assert_eq!(comments[1].3, TEST_POST_ID_1); assert_eq!(comments[1].4, TEST_USER_ID_1); assert_eq!( comments[1].5, Ltree(format!("0.{}.{}", TEST_COMMENT_ID_1, TEST_COMMENT_ID_2).to_string()) ); assert_eq!(comments[1].6, 0); // Zero upvotes // Check comment replies let replies: Vec<(Option, i32)> = notification::table .select((notification::comment_id, notification::recipient_id)) .order_by(notification::comment_id) .load(conn) .map_err(|e| anyhow!("Failed to read comment replies: {}", e))?; assert_eq!(replies.len(), 2); assert_eq!(replies[0].0, Some(TEST_COMMENT_ID_1)); assert_eq!(replies[0].1, TEST_USER_ID_1); assert_eq!(replies[1].0, Some(TEST_COMMENT_ID_2)); assert_eq!(replies[1].1, TEST_USER_ID_2); Ok(()) } const FOREIGN_KEY: &str = "f"; fn get_foreign_keys_with_missing_indexes(conn: &mut PgConnection) -> LemmyResult> { diesel::table! { pg_constraint (table_oid, name, kind, column_numbers) { #[sql_name = "conrelid"] table_oid -> Oid, #[sql_name = "conname"] name -> Text, #[sql_name = "contype"] kind -> Text, #[sql_name = "conkey"] column_numbers -> Array, } } diesel::table! { pg_index (table_oid, key_length, column_numbers) { #[sql_name = "indrelid"] table_oid -> Oid, #[sql_name = "indnkeyatts"] key_length -> Int2, #[sql_name = "indkey"] column_numbers -> Array, } } diesel::allow_tables_to_appear_in_same_query!(pg_constraint, pg_index); let matching_index = pg_index::table .filter(pg_index::table_oid.eq(pg_constraint::table_oid)) // Check if the index's key (not columns listed with `INCLUDE`) starts with the foreign key. // TODO: use Diesel array slice function when it's added. .filter(sql::( "((pg_index.indkey[:pg_index.indnkeyatts])[:array_length(pg_constraint.conkey, 1)] = pg_constraint.conkey)" )); let res = pg_constraint::table .select(pg_constraint::name) .filter(pg_constraint::kind.eq(FOREIGN_KEY)) .filter(not(exists(matching_index))) .load(conn)?; Ok(res) } } ================================================ FILE: crates/diesel_utils/src/sensitive.rs ================================================ #[cfg(feature = "full")] use diesel_derive_newtype::DieselNewType; use serde::{Deserialize, Serialize}; use std::{fmt::Debug, ops::Deref}; #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] #[serde(transparent)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub struct SensitiveString(String); impl SensitiveString { pub fn into_inner(self) -> String { self.0 } } impl Debug for SensitiveString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Sensitive").finish() } } impl AsRef<[u8]> for SensitiveString { fn as_ref(&self) -> &[u8] { self.0.as_ref() } } impl Deref for SensitiveString { type Target = str; fn deref(&self) -> &Self::Target { &self.0 } } impl From for SensitiveString { fn from(t: String) -> Self { SensitiveString(t) } } ================================================ FILE: crates/diesel_utils/src/traits.rs ================================================ use crate::connection::{DbPool, get_conn}; use diesel::{ associations::HasTable, dsl, query_builder::{DeleteStatement, IntoUpdateTarget}, query_dsl::methods::{FindDsl, LimitDsl}, }; use diesel_async::{ AsyncPgConnection, RunQueryDsl, methods::{ExecuteDsl, LoadQuery}, }; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use std::future::Future; /// Returned by `diesel::delete` type Delete = DeleteStatement<::Table, ::WhereClause>; /// Returned by `Self::table().find(id)` type Find = dsl::Find<::Table, ::IdType>; // Trying to create default implementations for `create` and `update` results in a lifetime mess and // weird compile errors. https://github.com/rust-lang/rust/issues/102211 pub trait Crud: HasTable + Sized where Self::Table: FindDsl, Find: LimitDsl + IntoUpdateTarget + Send, Delete>: ExecuteDsl + Send + 'static, // Used by `RunQueryDsl::first` dsl::Limit>: LoadQuery<'static, AsyncPgConnection, Self> + Send + 'static, { type InsertForm; type UpdateForm; type IdType: Send; fn create( pool: &mut DbPool<'_>, form: &Self::InsertForm, ) -> impl Future> + Send; fn read(pool: &mut DbPool<'_>, id: Self::IdType) -> impl Future> + Send where Self: Send, { async { let query: Find = Self::table().find(id); let conn = &mut *get_conn(pool).await?; query .first(conn) .await .with_lemmy_type(LemmyErrorType::NotFound) } } /// when you want to null out a column, you have to send Some(None)), since sending None means you /// just don't want to update that column. fn update( pool: &mut DbPool<'_>, id: Self::IdType, form: &Self::UpdateForm, ) -> impl Future> + Send; fn delete( pool: &mut DbPool<'_>, id: Self::IdType, ) -> impl Future> + Send { async { let query: Delete> = diesel::delete(Self::table().find(id)); let conn = &mut *get_conn(pool).await?; query .execute(conn) .await .with_lemmy_type(LemmyErrorType::Deleted) } } } ================================================ FILE: crates/diesel_utils/src/utils.rs ================================================ use crate::dburl::DbUrl; use diesel::{ Expression, IntoSql, dsl, helper_types::AsExprOf, pg::{Pg, data_types::PgInterval}, query_builder::{Query, QueryFragment, QueryId}, query_dsl::methods::LimitDsl, result::Error::{self as DieselError}, sql_types::{self, Timestamptz}, }; use futures_util::future::BoxFuture; use i_love_jesus::CursorKey; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, utils::validation::clean_url, }; use url::Url; /// Necessary to be able to use cursors with the lower SQL function pub struct LowerKey(pub K); impl CursorKey for LowerKey where K: CursorKey, { type SqlType = sql_types::Text; type CursorValue = functions::lower; type SqlValue = functions::lower; fn get_cursor_value(cursor: &C) -> Self::CursorValue { functions::lower(K::get_cursor_value(cursor)) } fn get_sql_value() -> Self::SqlValue { functions::lower(K::get_sql_value()) } } /// Necessary to be able to use cursors with the subpath SQL function pub struct Subpath(pub K); impl CursorKey for Subpath where K: CursorKey, { type SqlType = diesel_ltree::sql_types::Ltree; type CursorValue = diesel_ltree::subpath; type SqlValue = diesel_ltree::subpath; fn get_cursor_value(cursor: &C) -> Self::CursorValue { diesel_ltree::subpath(K::get_cursor_value(cursor), 0, -1) } fn get_sql_value() -> Self::SqlValue { diesel_ltree::subpath(K::get_sql_value(), 0, -1) } } pub struct CoalesceKey(pub A, pub B); impl CursorKey for CoalesceKey where A: CursorKey>, B: CursorKey, { type SqlType = B::SqlType; type CursorValue = functions::coalesce; type SqlValue = functions::coalesce; fn get_cursor_value(cursor: &C) -> Self::CursorValue { // TODO: for slight optimization, use unwrap_or_else here (this requires the CursorKey trait to // be changed to allow non-binded CursorValue) functions::coalesce(A::get_cursor_value(cursor), B::get_cursor_value(cursor)) } fn get_sql_value() -> Self::SqlValue { functions::coalesce(A::get_sql_value(), B::get_sql_value()) } } /// Includes an SQL comment before `T`, which can be used to label auto_explain output #[derive(QueryId)] pub struct Commented { comment: String, inner: T, } impl Commented { pub fn new(inner: T) -> Self { Commented { comment: String::new(), inner, } } /// Adds `text` to the comment if `condition` is true fn text_if(mut self, text: &str, condition: bool) -> Self { if condition { if !self.comment.is_empty() { self.comment.push_str(", "); } self.comment.push_str(text); } self } /// Adds `text` to the comment pub fn text(self, text: &str) -> Self { self.text_if(text, true) } } impl Query for Commented { type SqlType = T::SqlType; } impl> QueryFragment for Commented { fn walk_ast<'b>( &'b self, mut out: diesel::query_builder::AstPass<'_, 'b, Pg>, ) -> Result<(), DieselError> { for line in self.comment.lines() { out.push_sql("\n-- "); out.push_sql(line); } out.push_sql("\n"); self.inner.walk_ast(out.reborrow()) } } impl LimitDsl for Commented { type Output = Commented; fn limit(self, limit: i64) -> Self::Output { Commented { comment: self.comment, inner: self.inner.limit(limit), } } } pub fn fuzzy_search(q: &str) -> String { let replaced = q .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") .replace(' ', "%"); format!("%{replaced}%") } /// Takes an API optional text input, and converts it to an optional diesel DB update. pub fn diesel_string_update(opt: Option<&str>) -> Option> { match opt { // An empty string is an erase Some("") => Some(None), Some(str) => Some(Some(str.into())), None => None, } } /// Takes an API optional number, and converts it to an optional diesel DB update. Zero means erase. pub fn diesel_opt_number_update(opt: Option) -> Option> { match opt { // Zero is an erase Some(0) => Some(None), Some(num) => Some(Some(num)), None => None, } } /// Takes an API optional text input, and converts it to an optional diesel DB update (for non /// nullable properties). pub fn diesel_required_string_update(opt: Option<&str>) -> Option { match opt { // An empty string is no change Some("") => None, Some(str) => Some(str.into()), None => None, } } /// Takes an optional API URL-type input, and converts it to an optional diesel DB update. /// Also cleans the url params. pub fn diesel_url_update(opt: Option<&str>) -> LemmyResult>> { match opt { // An empty string is an erase Some("") => Ok(Some(None)), Some(str_url) => Url::parse(str_url) .map(|u| Some(Some(clean_url(&u).into()))) .with_lemmy_type(LemmyErrorType::InvalidUrl), None => Ok(None), } } /// Takes an optional API URL-type input, and converts it to an optional diesel DB update (for non /// nullable properties). Also cleans the url params. pub fn diesel_required_url_update(opt: Option<&str>) -> LemmyResult> { match opt { // An empty string is no change Some("") => Ok(None), Some(str_url) => Url::parse(str_url) .map(|u| Some(clean_url(&u).into())) .with_lemmy_type(LemmyErrorType::InvalidUrl), None => Ok(None), } } /// Takes an optional API URL-type input, and converts it to an optional diesel DB create. /// Also cleans the url params. pub fn diesel_url_create(opt: Option<&str>) -> LemmyResult> { match opt { Some(str_url) => Url::parse(str_url) .map(|u| Some(clean_url(&u).into())) .with_lemmy_type(LemmyErrorType::InvalidUrl), None => Ok(None), } } pub mod functions { use diesel::{ define_sql_function, sql_types::{Int4, Text, Timestamptz}, }; define_sql_function! { #[sql_name = "r.hot_rank"] fn hot_rank(score: Int4, time: Timestamptz) -> Float; } define_sql_function! { #[sql_name = "r.scaled_rank"] fn scaled_rank(score: Int4, time: Timestamptz, interactions_month: Int4) -> Float; } define_sql_function!(fn lower(x: Text) -> Text); define_sql_function!(fn random() -> Text); define_sql_function!(fn random_smallint() -> SmallInt); // really this function is variadic, this just adds the two-argument version define_sql_function!(fn coalesce(x: diesel::sql_types::Nullable, y: T) -> T); define_sql_function! { #[aggregate] fn json_agg(obj: T) -> Json } define_sql_function!(#[sql_name = "coalesce"] fn coalesce_2_nullable(x: diesel::sql_types::Nullable, y: diesel::sql_types::Nullable) -> diesel::sql_types::Nullable); define_sql_function!(#[sql_name = "coalesce"] fn coalesce_3_nullable(x: diesel::sql_types::Nullable, y: diesel::sql_types::Nullable, z: diesel::sql_types::Nullable) -> diesel::sql_types::Nullable); } pub fn now() -> AsExprOf { // https://github.com/diesel-rs/diesel/issues/1514 diesel::dsl::now.into_sql::() } pub fn seconds_to_pg_interval(seconds: i32) -> PgInterval { PgInterval::from_microseconds(i64::from(seconds) * 1_000_000) } /// Output of `IntoSql::into_sql` for a type that implements `AsRecord` pub type AsRecordOutput = dsl::AsExprOf::SqlType>>; pub type ResultFuture<'a, T> = BoxFuture<'a, Result>; #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn test_fuzzy_search() { let test = "This %is% _a_ fuzzy search"; assert_eq!( fuzzy_search(test), "%This%\\%is\\%%\\_a\\_%fuzzy%search%".to_string() ); } #[test] fn test_diesel_option_overwrite() { assert_eq!(diesel_string_update(None), None); assert_eq!(diesel_string_update(Some("")), Some(None)); assert_eq!( diesel_string_update(Some("test")), Some(Some("test".to_string())) ); } #[test] fn test_diesel_option_overwrite_to_url() -> LemmyResult<()> { assert!(matches!(diesel_url_update(None), Ok(None))); assert!(matches!(diesel_url_update(Some("")), Ok(Some(None)))); assert!(diesel_url_update(Some("invalid_url")).is_err()); let example_url = "https://example.com"; assert!(matches!( diesel_url_update(Some(example_url)), Ok(Some(Some(url))) if url == Url::parse(example_url)?.into() )); Ok(()) } } ================================================ FILE: crates/email/Cargo.toml ================================================ [package] name = "lemmy_email" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_email" path = "src/lib.rs" doctest = false test = false [lints] workspace = true [features] full = [] [dependencies] lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_schema_file = { workspace = true } uuid = { workspace = true, features = ["v4"] } rosetta-i18n = { workspace = true } html2text = { workspace = true } lettre = { version = "0.11.19", default-features = false, features = [ "builder", "pool", "smtp-transport", "tokio1-rustls-tls", ] } lemmy_diesel_utils = { workspace = true } [dev-dependencies] [build-dependencies] rosetta-build = { version = "0.1.3", default-features = false } ================================================ FILE: crates/email/build.rs ================================================ use std::fs::read_dir; fn main() -> Result<(), Box> { let mut config = rosetta_build::config(); for path in read_dir("translations/backend/")? { let path = path?.path(); if let Some(name) = path.file_name() { let mut lang = name.to_string_lossy().to_string().replace(".json", ""); // Rename Chinese simplified variant because there is no translation zh if lang == "zh_Hans" { lang = "zh".to_string(); } // Rosetta doesnt support these language variants. if lang.contains('_') { continue; } let path = path.to_string_lossy(); rosetta_build::config() .source(&lang, path.clone()) .fallback(&lang) .generate()?; config = config.source(lang, path); } } config.fallback("en").generate()?; Ok(()) } ================================================ FILE: crates/email/src/account.rs ================================================ use crate::{send::send_email, user_email, user_language}; use lemmy_db_schema::source::{ email_verification::{EmailVerification, EmailVerificationForm}, local_site::LocalSite, password_reset_request::PasswordResetRequest, }; use lemmy_db_schema_file::enums::RegistrationMode; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::{connection::DbPool, sensitive::SensitiveString}; use lemmy_utils::{ error::LemmyResult, settings::structs::Settings, utils::markdown::markdown_to_html, }; pub async fn send_password_reset_email( user: &LocalUserView, pool: &mut DbPool<'_>, settings: &'static Settings, ) -> LemmyResult<()> { // Generate a random token let token = uuid::Uuid::new_v4().to_string(); let lang = user_language(&user.local_user); let subject = lang.password_reset_subject(&user.person.name); let protocol_and_hostname = settings.get_protocol_and_hostname(); let reset_link = format!("{}/password_change/{}", protocol_and_hostname, &token); let email = user_email(user)?; let body = lang.password_reset_body(reset_link, &user.person.name); send_email(subject, email, user.person.name.clone(), body, settings); // Insert the row after successful send, to avoid using daily reset limit while // email sending is broken. let local_user_id = user.local_user.id; PasswordResetRequest::create(pool, local_user_id, token.clone()).await?; Ok(()) } /// Send a verification email pub async fn send_verification_email( local_site: &LocalSite, user: &LocalUserView, new_email: SensitiveString, pool: &mut DbPool<'_>, settings: &'static Settings, ) -> LemmyResult<()> { let form = EmailVerificationForm { local_user_id: user.local_user.id, email: new_email.to_string(), verification_token: uuid::Uuid::new_v4().to_string(), }; let verify_link = format!( "{}/verify_email/{}", settings.get_protocol_and_hostname(), &form.verification_token ); EmailVerification::create(pool, &form).await?; let lang = user_language(&user.local_user); let subject = lang.verify_email_subject(&settings.hostname); // If an application is required, use a translation that includes that warning. let body = if local_site.registration_mode == RegistrationMode::RequireApplication { lang.verify_email_body_with_application(&settings.hostname, &user.person.name, verify_link) } else { lang.verify_email_body(&settings.hostname, &user.person.name, verify_link) }; send_email(subject, new_email, user.person.name.clone(), body, settings); Ok(()) } /// Returns true if email was sent. pub async fn send_verification_email_if_required( local_site: &LocalSite, user: &LocalUserView, pool: &mut DbPool<'_>, settings: &'static Settings, ) -> LemmyResult { if !user.local_user.admin && local_site.require_email_verification && !user.local_user.email_verified { let email = user_email(user)?; send_verification_email(local_site, user, email, pool, settings).await?; Ok(true) } else { Ok(false) } } pub fn send_application_approved_email( user: &LocalUserView, settings: &'static Settings, ) -> LemmyResult<()> { let lang = user_language(&user.local_user); let subject = lang.registration_approved_subject(&user.person.name); let email = user_email(user)?; let body = lang.registration_approved_body(&settings.hostname); send_email(subject, email, user.person.name.clone(), body, settings); Ok(()) } pub fn send_application_denied_email( user: &LocalUserView, deny_reason: Option, settings: &'static Settings, ) -> LemmyResult<()> { let lang = user_language(&user.local_user); let subject = lang.registration_denied_subject(&user.person.name); let email = user_email(user)?; let body = match deny_reason { Some(deny_reason) => { let markdown = markdown_to_html(&deny_reason); lang.registration_denied_reason_body(&settings.hostname, &markdown) } None => lang.registration_denied_body(&settings.hostname), }; send_email(subject, email, user.person.name.clone(), body, settings); Ok(()) } pub fn send_email_verified_email( user: &LocalUserView, settings: &'static Settings, ) -> LemmyResult<()> { let lang = user_language(&user.local_user); let subject = lang.email_verified_subject(&user.person.name); let email = user_email(user)?; let body = lang.email_verified_body(); send_email( subject, email, user.person.name.clone(), body.to_string(), settings, ); Ok(()) } ================================================ FILE: crates/email/src/admin.rs ================================================ use crate::{send::send_email, user_language}; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::connection::DbPool; use lemmy_utils::{error::LemmyResult, settings::structs::Settings}; /// Send a new applicant email notification to all admins pub async fn send_new_applicant_email_to_admins( applicant_username: &str, pool: &mut DbPool<'_>, settings: &'static Settings, ) -> LemmyResult<()> { // Collect the admins with emails let admins = LocalUserView::list_admins_with_emails(pool).await?; let applications_link = &format!( "{}/registration_applications", settings.get_protocol_and_hostname(), ); for admin in admins { let lang = user_language(&admin.local_user); if let Some(email) = admin.local_user.email { let subject = lang.new_application_subject(&settings.hostname, applicant_username); let body = lang.new_application_body(applications_link); send_email(subject, email, admin.person.name, body, settings); } } Ok(()) } /// Send a report to all admins pub async fn send_new_report_email_to_admins( reporter_username: &str, reported_username: &str, pool: &mut DbPool<'_>, settings: &'static Settings, ) -> LemmyResult<()> { // Collect the admins with emails let admins = LocalUserView::list_admins_with_emails(pool).await?; let reports_link = &format!("{}/reports", settings.get_protocol_and_hostname(),); for admin in admins { let lang = user_language(&admin.local_user); if let Some(email) = admin.local_user.email { let subject = lang.new_report_subject(&settings.hostname, reported_username, reporter_username); let body = lang.new_report_body(reports_link); send_email(subject, email, admin.person.name, body, settings); } } Ok(()) } ================================================ FILE: crates/email/src/lib.rs ================================================ use lemmy_db_schema::source::local_user::LocalUser; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::sensitive::SensitiveString; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use rosetta_i18n::{Language, LanguageId}; use translations::Lang; pub mod account; pub mod admin; pub mod notifications; mod send; /// Avoid warnings for unused 0.19 translations #[expect(mismatched_lifetime_syntaxes)] pub mod translations { rosetta_i18n::include_translations!(); } fn inbox_link(settings: &Settings) -> String { format!("{}/inbox", settings.get_protocol_and_hostname()) } #[expect(clippy::expect_used)] pub fn user_language(local_user: &LocalUser) -> Lang { let lang_id = LanguageId::new(&local_user.interface_language); Lang::from_language_id(&lang_id).unwrap_or_else(|| { let en = LanguageId::new("en"); Lang::from_language_id(&en).expect("default language") }) } fn user_email(local_user_view: &LocalUserView) -> LemmyResult { local_user_view .local_user .email .clone() .ok_or(LemmyErrorType::EmailRequired.into()) } ================================================ FILE: crates/email/src/notifications.rs ================================================ use crate::{inbox_link, send::send_email, user_language}; use lemmy_db_schema::source::{comment::Comment, community::Community, person::Person, post::Post}; use lemmy_db_schema_file::enums::ModlogKind; use lemmy_db_views_local_user::LocalUserView; use lemmy_diesel_utils::dburl::DbUrl; use lemmy_utils::{settings::structs::Settings, utils::markdown::markdown_to_html}; pub enum NotificationEmailData<'a> { Mention { content: String, person: &'a Person, }, PostSubscribed { post: &'a Post, comment: &'a Comment, }, CommunitySubscribed { post: &'a Post, community: &'a Community, }, Reply { comment: &'a Comment, person: &'a Person, parent_comment: Option, post: &'a Post, }, PrivateMessage { sender: &'a Person, content: &'a String, }, ModAction { kind: ModlogKind, reason: Option<&'a str>, is_revert: bool, }, } pub fn send_notification_email( local_user_view: LocalUserView, link: DbUrl, data: NotificationEmailData, settings: &'static Settings, ) { if local_user_view.banned || !local_user_view.local_user.send_notifications_to_email { return; } let inbox_link = inbox_link(settings); let lang = user_language(&local_user_view.local_user); let (subject, body) = match data { NotificationEmailData::Mention { content, person } => { let content = markdown_to_html(&content); ( lang.notification_mentioned_by_subject(&person.name), lang.notification_mentioned_by_body(&link, &content, &inbox_link, &person.name), ) } NotificationEmailData::PostSubscribed { post, comment } => { let content = markdown_to_html(&comment.content); ( lang.notification_post_subscribed_subject(&post.name), lang.notification_post_subscribed_body(&content, &link, inbox_link), ) } NotificationEmailData::CommunitySubscribed { post, community } => { let content = post .body .as_ref() .map(|b| markdown_to_html(b)) .unwrap_or_default(); ( lang.notification_community_subscribed_subject(&post.name, &community.title), lang.notification_community_subscribed_body(&content, &link, inbox_link), ) } NotificationEmailData::Reply { comment, person, parent_comment: Some(parent_comment), post, } => { let content = markdown_to_html(&comment.content); ( lang.notification_comment_reply_subject(&person.name), lang.notification_comment_reply_body( link, &content, &inbox_link, &parent_comment.content, &post.name, &person.name, ), ) } NotificationEmailData::Reply { comment, person, parent_comment: None, post, } => { let content = markdown_to_html(&comment.content); ( lang.notification_post_reply_subject(&person.name), lang.notification_post_reply_body(link, &content, &inbox_link, &post.name, &person.name), ) } NotificationEmailData::PrivateMessage { sender, content } => { let sender_name = &sender.name; let content = markdown_to_html(content); ( lang.notification_private_message_subject(sender_name), lang.notification_private_message_body(inbox_link, &content, sender_name), ) } NotificationEmailData::ModAction { kind, reason, is_revert, } => { // Some actions like AdminAdd and ModAddToCommunity dont have any reason let reason = reason.unwrap_or_default(); if is_revert { ( lang.notification_mod_action_subject(kind).clone(), lang.notification_mod_action_body(reason, inbox_link), ) } else { ( lang.notification_mod_action_reverted_subject(kind).clone(), lang.notification_mod_action_reverted_body(reason, inbox_link), ) } } }; if let Some(user_email) = local_user_view.local_user.email { send_email( subject, user_email, local_user_view.person.name, body, settings, ); } } ================================================ FILE: crates/email/src/send.rs ================================================ use lemmy_diesel_utils::sensitive::SensitiveString; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType}, settings::structs::Settings, spawn_try_task, }; use lettre::{ Address, AsyncTransport, Message, message::{Mailbox, MultiPart}, transport::smtp::extension::ClientId, }; use std::{str::FromStr, sync::OnceLock}; use uuid::Uuid; type AsyncSmtpTransport = lettre::AsyncSmtpTransport; pub(crate) fn send_email( subject: String, to_email: SensitiveString, to_username: String, html: String, settings: &'static Settings, ) { spawn_try_task(async move { static MAILER: OnceLock = OnceLock::new(); let email_config = settings.email.clone().ok_or(LemmyErrorType::NoEmailSetup)?; #[expect(clippy::expect_used)] let mailer = MAILER.get_or_init(|| { AsyncSmtpTransport::from_url(&email_config.connection) .expect("init email transport") .hello_name(ClientId::Domain(settings.hostname.clone())) .build() }); // use usize::MAX as the line wrap length, since lettre handles the wrapping for us let plain_text = html2text::from_read(html.as_bytes(), usize::MAX)?; let smtp_from_address = &email_config.smtp_from_address; let email = Message::builder() .from( smtp_from_address .parse() .with_lemmy_type(LemmyErrorType::InvalidEmailAddress( smtp_from_address.into(), ))?, ) .to(Mailbox::new( Some(to_username.clone()), Address::from_str(&to_email) .with_lemmy_type(LemmyErrorType::InvalidEmailAddress(to_email.into_inner()))?, )) .message_id(Some(format!("<{}@{}>", Uuid::new_v4(), settings.hostname))) .subject(subject) .multipart(MultiPart::alternative_plain_html(plain_text, html.clone())) .with_lemmy_type(LemmyErrorType::EmailSendFailed)?; mailer .send(email) .await .with_lemmy_type(LemmyErrorType::EmailSendFailed)?; Ok(()) }) } ================================================ FILE: crates/routes/Cargo.toml ================================================ [package] name = "lemmy_routes" publish = false version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] doctest = false [lints] workspace = true # dummy to make `./scripts/test.sh lemmy_routes` work [features] full = [] ts-rs = ["dep:ts-rs"] [dependencies] lemmy_db_views_community = { workspace = true, features = ["full"] } lemmy_db_views_post = { workspace = true, features = ["full"] } lemmy_db_views_local_image = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_notification = { workspace = true, features = ["full"] } lemmy_db_views_modlog = { workspace = true, features = ["full"] } lemmy_db_views_person_content_combined = { workspace = true, features = [ "full", ] } lemmy_db_views_site = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true, features = ["full"] } lemmy_db_schema_file = { workspace = true } activitypub_federation = { workspace = true } lemmy_email = { workspace = true } actix-web = { workspace = true, features = ["cookies"] } chrono = { workspace = true } futures = { workspace = true } reqwest = { workspace = true, features = ["stream"] } reqwest-middleware = { workspace = true, features = ["form", "query"] } serde = { workspace = true } url = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } futures-util.workspace = true http.workspace = true diesel.workspace = true diesel-async.workspace = true clokwerk = "0.4.0" prometheus = { version = "0.14.0", features = [ "process", ], default-features = false } rss = "2.0.12" actix-web-prom = "0.10.0" actix-cors = "0.7.1" rand = "0.10.0" percent-encoding = "2.3.2" diesel-uplete.workspace = true lemmy_diesel_utils = { workspace = true } rosetta-i18n = { workspace = true } strum = { workspace = true } ts-rs = { workspace = true, optional = true } [dev-dependencies] pretty_assertions.workspace = true serial_test.workspace = true ================================================ FILE: crates/routes/src/feeds/mod.rs ================================================ mod negotiate_content; use actix_web::{Error, HttpRequest, HttpResponse, Result, error::ErrorBadRequest, web}; use chrono::{DateTime, Utc}; use lemmy_api_utils::{ context::LemmyContext, utils::{check_private_instance, local_user_view_from_jwt}, }; use lemmy_db_schema::{ PersonContentType, source::{ community::Community, multi_community::MultiCommunity, notification::Notification, person::Person, }, traits::ApubActor, }; use lemmy_db_schema_file::enums::{ListingType, ModlogKind, NotificationType, PostSortType}; use lemmy_db_views_modlog::{ModlogView, impls::ModlogQuery}; use lemmy_db_views_notification::{NotificationData, NotificationView, impls::NotificationQuery}; use lemmy_db_views_person_content_combined::impls::PersonContentCombinedQuery; use lemmy_db_views_post::{PostView, impls::PostQuery}; use lemmy_db_views_site::SiteView; use lemmy_email::{translations::Lang, user_language}; use lemmy_utils::{ cache_header::cache_1hour, error::LemmyResult, settings::structs::Settings, utils::markdown::markdown_to_html, }; use negotiate_content::get_lang_or_negotiate; use rss::{ Category, Channel, EnclosureBuilder, Guid, Item, extension::{ExtensionBuilder, ExtensionMap, dublincore::DublinCoreExtension}, }; use serde::Deserialize; use std::{collections::BTreeMap, sync::LazyLock}; const RSS_FETCH_LIMIT: i64 = 20; #[derive(Deserialize)] struct Params { sort: Option, limit: Option, } impl Params { fn sort_type(&self) -> PostSortType { self.sort.unwrap_or_default() } fn get_limit(&self) -> i64 { self.limit.unwrap_or(RSS_FETCH_LIMIT) } } pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/feeds") .route("/u/{user_name}.xml", web::get().to(get_feed_user)) .route("/c/{community_name}.xml", web::get().to(get_feed_community)) .route( "/m/{multi_name}.xml", web::get().to(get_feed_multi_community), ) .route("/front/{jwt}.xml", web::get().to(get_feed_front)) .route("/modlog/{jwt}.xml", web::get().to(get_feed_modlog)) .route("/notifications/{jwt}.xml", web::get().to(get_feed_notifs)) // Also redirect inbox to notifications. This should probably be deprecated tho. .service(web::redirect( "/inbox/{jwt}.xml", "/notifications/{jwt}.xml", )) .route("/all.xml", web::get().to(get_all_feed).wrap(cache_1hour())) .route( "/local.xml", web::get().to(get_local_feed).wrap(cache_1hour()), ), ); } static RSS_NAMESPACE: LazyLock> = LazyLock::new(|| { let mut h = BTreeMap::new(); h.insert( "dc".to_string(), rss::extension::dublincore::NAMESPACE.to_string(), ); h.insert( "media".to_string(), "http://search.yahoo.com/mrss/".to_string(), ); h }); async fn get_all_feed( req: HttpRequest, web::Query(info): web::Query, context: web::Data, ) -> Result { let lang = get_lang_or_negotiate(&req, &context).await?; get_feed_data( &context, ListingType::All, info.sort_type(), info.get_limit(), lang, ) .await } async fn get_local_feed( req: HttpRequest, web::Query(info): web::Query, context: web::Data, ) -> Result { let lang = get_lang_or_negotiate(&req, &context).await?; get_feed_data( &context, ListingType::Local, info.sort_type(), info.get_limit(), lang, ) .await } async fn get_feed_data( context: &LemmyContext, listing_type: ListingType, sort_type: PostSortType, limit: i64, lang: Lang, ) -> Result { let site_view = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&None, &site_view.local_site)?; let posts = PostQuery { listing_type: Some(listing_type), sort: Some(sort_type), limit: Some(limit), ..Default::default() } .list(&site_view.site, &mut context.pool()) .await? .items; let title = format!( "{} - {}", site_view.site.name, if listing_type == ListingType::Local { lang.local() } else { lang.all() } ); let link = context.settings().get_protocol_and_hostname(); let items = create_post_items(posts, context.settings(), lang)?; Ok(send_feed_response(title, link, None, items, site_view)) } async fn get_feed_user( req: HttpRequest, web::Query(info): web::Query, name: web::Path, context: web::Data, ) -> Result { let (name, domain) = split_name(&name); let person = Person::read_from_name(&mut context.pool(), name, domain, false) .await? .ok_or(ErrorBadRequest("not_found"))?; let site_view = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&None, &site_view.local_site)?; let lang = get_lang_or_negotiate(&req, &context).await?; let content = PersonContentCombinedQuery { creator_id: person.id, type_: Some(PersonContentType::Posts), page_cursor: None, limit: Some(info.get_limit()), no_limit: None, } .list(&mut context.pool(), None, site_view.site.instance_id) .await? .items; let posts = content .iter() // Filter map to collect posts .filter_map(|f| f.to_post_view()) .cloned() .collect::>(); let title = format!("{} - {}", site_view.site.name, person.name); let link = person.ap_id.to_string(); let items = create_post_items(posts, context.settings(), lang)?; Ok(send_feed_response( title, link, person.bio, items, site_view, )) } /// Takes a user/community name either in the format `name` or `name@example.com`. Splits /// it on `@` and returns a tuple of name and optional domain. fn split_name(name: &str) -> (&str, Option<&str>) { if let Some(split) = name.split_once('@') { (split.0, Some(split.1)) } else { (name, None) } } async fn get_feed_community( req: HttpRequest, web::Query(info): web::Query, name: web::Path, context: web::Data, ) -> Result { let (name, domain) = split_name(&name); let community = Community::read_from_name(&mut context.pool(), name, domain, false) .await? .ok_or(ErrorBadRequest("not_found"))?; if !community.visibility.can_view_without_login() { return Err(ErrorBadRequest("not_found")); } let site_view = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&None, &site_view.local_site)?; let lang = get_lang_or_negotiate(&req, &context).await?; let posts = PostQuery { sort: Some(info.sort_type()), community_id: Some(community.id), limit: Some(info.get_limit()), ..Default::default() } .list(&site_view.site, &mut context.pool()) .await? .items; let title = format!("{} - {}", site_view.site.name, community.name); let link = community.ap_id.to_string(); let items = create_post_items(posts, context.settings(), lang)?; Ok(send_feed_response( title, link, community.summary, items, site_view, )) } async fn get_feed_multi_community( req: HttpRequest, web::Query(info): web::Query, name: web::Path, context: web::Data, ) -> Result { let (name, domain) = split_name(&name); let multi_community = MultiCommunity::read_from_name(&mut context.pool(), name, domain, false) .await? .ok_or(ErrorBadRequest("not_found"))?; let site_view = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&None, &site_view.local_site)?; let lang = get_lang_or_negotiate(&req, &context).await?; let posts = PostQuery { sort: Some(info.sort_type()), multi_community_id: Some(multi_community.id), limit: Some(info.get_limit()), ..Default::default() } .list(&site_view.site, &mut context.pool()) .await? .items; let title = format!("{} - {}", site_view.site.name, multi_community.name); let link = multi_community.ap_id.to_string(); let items = create_post_items(posts, context.settings(), lang)?; Ok(send_feed_response( title, link, multi_community.summary, items, site_view, )) } async fn get_feed_front( req: HttpRequest, web::Query(info): web::Query, context: web::Data, ) -> Result { let jwt: String = req.match_info().get("jwt").unwrap_or("none").parse()?; let site_view = SiteView::read_local(&mut context.pool()).await?; let local_user = local_user_view_from_jwt(&jwt, &context).await?; let lang = user_language(&local_user.local_user); check_private_instance(&Some(local_user.clone()), &site_view.local_site)?; let posts = PostQuery { listing_type: Some(ListingType::Subscribed), local_user: Some(&local_user.local_user), sort: Some(info.sort_type()), limit: Some(info.get_limit()), ..Default::default() } .list(&site_view.site, &mut context.pool()) .await? .items; let title = format!("{} - {}", site_view.site.name, lang.subscribed()); let link = context.settings().get_protocol_and_hostname(); let items = create_post_items(posts, context.settings(), lang)?; Ok(send_feed_response(title, link, None, items, site_view)) } fn send_feed_response( title: String, link: String, description: Option, items: Vec, site_view: SiteView, ) -> HttpResponse { let mut channel = Channel { namespaces: RSS_NAMESPACE.clone(), title, link, items, ..Default::default() }; let description = description.or(site_view.site.summary); if let Some(desc) = description { channel.set_description(markdown_to_html(&desc)); } HttpResponse::Ok() .content_type("application/rss+xml") .body(channel.to_string()) } async fn get_feed_notifs( req: HttpRequest, _info: web::Query, context: web::Data, ) -> Result { let jwt: String = req.match_info().get("jwt").unwrap_or("none").parse()?; let site_view = SiteView::read_local(&mut context.pool()).await?; let local_user = local_user_view_from_jwt(&jwt, &context).await?; let show_bot_accounts = Some(local_user.local_user.show_bot_accounts); let lang = user_language(&local_user.local_user); check_private_instance(&Some(local_user.clone()), &site_view.local_site)?; let notifications = NotificationQuery { show_bot_accounts, ..Default::default() } .list(&mut context.pool(), &local_user.person) .await? .items; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); let title = format!("{} - {}", site_view.site.name, lang.notifications()); let link = format!("{protocol_and_hostname}/notifications"); let items = create_reply_and_mention_items(notifications, &context, lang)?; Ok(send_feed_response(title, link, None, items, site_view)) } /// Gets your ModeratorView modlog async fn get_feed_modlog( req: HttpRequest, _info: web::Query, context: web::Data, ) -> Result { let jwt: String = req.match_info().get("jwt").unwrap_or("none").parse()?; let site_view = SiteView::read_local(&mut context.pool()).await?; let local_user = local_user_view_from_jwt(&jwt, &context).await?; let lang = user_language(&local_user.local_user); check_private_instance(&Some(local_user.clone()), &site_view.local_site)?; let modlog = ModlogQuery { listing_type: Some(ListingType::ModeratorView), local_user: Some(&local_user.local_user), hide_modlog_names: Some(false), ..Default::default() } .list(&mut context.pool()) .await? .items; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); let title = format!("{} - {}", local_user.person.name, lang.modlog()); let link = format!("{protocol_and_hostname}/modlog"); let items = create_modlog_items(modlog, context.settings(), lang)?; Ok(send_feed_response(title, link, None, items, site_view)) } fn create_reply_and_mention_items( notifs: Vec, context: &LemmyContext, lang: Lang, ) -> LemmyResult> { let reply_items: Vec = notifs .iter() .flat_map(|v| { match &v.data { NotificationData::Post(post) => { let mention_url = post.post.local_url(context.settings()).ok()?; Some(build_item( &post.creator, &post.post.published_at, mention_url.as_str(), &post.post.body.clone().unwrap_or_default(), &v.notification, context.settings(), lang, )) } NotificationData::Comment(comment) => { let reply_url = comment.comment.local_url(context.settings()).ok()?; Some(build_item( &comment.creator, &comment.comment.published_at, reply_url.as_str(), &comment.comment.content, &v.notification, context.settings(), lang, )) } NotificationData::PrivateMessage(pm) => { let notifs_url = format!( "{}/notifications", context.settings().get_protocol_and_hostname() ); Some(build_item( &pm.creator, &pm.private_message.published_at, ¬ifs_url, &pm.private_message.content, &v.notification, context.settings(), lang, )) } // skip modlog items NotificationData::ModAction(_) => None, } }) .collect::>>()?; Ok(reply_items) } fn create_modlog_items( modlog: Vec, settings: &Settings, lang: Lang, ) -> LemmyResult> { // All of these go to your modlog url let modlog_url = format!( "{}/modlog?listing_type=ModeratorView", settings.get_protocol_and_hostname() ); let modlog_items: Vec = modlog .iter() .map(|r| { let u = |x: Option| x.unwrap_or_else(|| "unknown".to_string()); let target_instance_domain = u(r.target_instance.as_ref().map(|i| i.domain.clone())); let target_person_name = u(r.target_person.as_ref().map(|i| i.name.clone())); let target_community_name = u(r.target_community.as_ref().map(|i| i.name.clone())); let target_post_name = u(r.target_post.as_ref().map(|i| i.name.clone())); let target_comment_content = u(r.target_comment.as_ref().map(|i| i.content.clone())); match r.modlog.kind { ModlogKind::AdminAllowInstance => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.admin_disallowed_instance_x(&target_instance_domain) } else { lang.admin_allowed_instance_x(&target_instance_domain) }, settings, ), ModlogKind::AdminBlockInstance => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.admin_unblocked_instance_x(&target_instance_domain) } else { lang.admin_blocked_instance_x(&target_instance_domain) }, settings, ), ModlogKind::AdminPurgeComment => { build_modlog_item(r, &modlog_url, lang.admin_purged_comment(), settings) } ModlogKind::AdminPurgeCommunity => { build_modlog_item(r, &modlog_url, lang.admin_purged_community(), settings) } ModlogKind::AdminPurgePerson => { build_modlog_item(r, &modlog_url, lang.admin_purged_person(), settings) } ModlogKind::AdminPurgePost => { build_modlog_item(r, &modlog_url, lang.admin_purged_post(), settings) } ModlogKind::AdminAdd => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.added_admin_x(&target_person_name) } else { lang.removed_admin_x(&target_person_name) }, settings, ), ModlogKind::ModAddToCommunity => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.added_mod_x_to_community_y(&target_community_name, &target_person_name) } else { lang.removed_mod_x_from_community_y(&target_community_name, &target_person_name) }, settings, ), ModlogKind::AdminBan => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.unbanned_user_x(&target_person_name) } else { lang.banned_user_x(&target_person_name) }, settings, ), ModlogKind::ModBanFromCommunity => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.unbanned_user_x_from_community_y(&target_community_name, &target_person_name) } else { lang.banned_user_x_from_community_y(&target_community_name, &target_person_name) }, settings, ), ModlogKind::ModFeaturePostCommunity => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.featured_post_x(&target_post_name) } else { lang.unfeatured_post_x(&target_post_name) }, settings, ), ModlogKind::AdminFeaturePostSite => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.featured_post_x(&target_post_name) } else { lang.unfeatured_post_x(&target_post_name) }, settings, ), ModlogKind::ModChangeCommunityVisibility => build_modlog_item( r, &modlog_url, lang.changed_community_x_visibility(&target_community_name), settings, ), ModlogKind::ModLockPost => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.unlocked_post_x(&target_post_name) } else { lang.locked_post_x(&target_post_name) }, settings, ), ModlogKind::ModRemoveComment => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.restored_comment_x(&target_comment_content) } else { lang.removed_comment_x(&target_comment_content) }, settings, ), ModlogKind::AdminRemoveCommunity => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.restored_community_x(&target_community_name) } else { lang.removed_community_x(&target_community_name) }, settings, ), ModlogKind::ModRemovePost => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.restored_post_x(&target_post_name) } else { lang.removed_post_x(&target_post_name) }, settings, ), ModlogKind::ModTransferCommunity => build_modlog_item( r, &modlog_url, lang.transferred_community_x_to_user_y(&target_community_name, &target_person_name), settings, ), ModlogKind::ModLockComment => build_modlog_item( r, &modlog_url, if r.modlog.is_revert { lang.unlocked_comment_x(&target_comment_content) } else { lang.locked_comment_x(&target_comment_content) }, settings, ), ModlogKind::ModWarnComment => build_modlog_item( r, &modlog_url, format!( "Warned user {} about comment {}", &&target_person_name, &&target_comment_content ), settings, ), ModlogKind::ModWarnPost => build_modlog_item( r, &modlog_url, format!( "Warned user {} about post {}", &&target_person_name, &&target_post_name ), settings, ), } }) .collect::>>()?; Ok(modlog_items) } fn build_modlog_item>( view: &ModlogView, url: &str, action: T, settings: &Settings, ) -> LemmyResult { let guid = Some(Guid { permalink: true, value: view.modlog.id.0.to_string(), }); let author = if let Some(mod_) = &view.moderator { Some(format!( "/u/{} (link)", mod_.name, mod_.actor_url(settings)? )) } else { None }; Ok(Item { title: Some(action.into()), author, pub_date: Some(view.modlog.published_at.to_rfc2822()), link: Some(url.to_owned()), guid, description: view.modlog.reason.clone(), ..Default::default() }) } fn build_item( creator: &Person, published: &DateTime, url: &str, content: &str, notification: &Notification, settings: &Settings, lang: Lang, ) -> LemmyResult { let guid = Some(Guid { permalink: true, value: url.to_owned(), }); let description = Some(markdown_to_html(content)); let title = match notification.kind { NotificationType::Mention => lang.mention_from_x(creator.name.clone()), NotificationType::Reply => lang.reply_from_x(creator.name.clone()), NotificationType::Subscribed => lang.subscribed().to_string(), NotificationType::PrivateMessage => lang.private_message_from_x(creator.name.clone()), NotificationType::ModAction => lang.mod_action().to_string(), }; Ok(Item { title: Some(title), author: Some(format!( "/u/{} (link)", creator.name, creator.actor_url(settings)? )), pub_date: Some(published.to_rfc2822()), comments: Some(url.to_owned()), link: Some(url.to_owned()), guid, description, ..Default::default() }) } fn create_post_items( posts: Vec, settings: &Settings, lang: Lang, ) -> LemmyResult> { let mut items: Vec = Vec::new(); for p in posts { let post_url = p.post.local_url(settings)?; let community_url = &p.community.actor_url(settings)?; let dublin_core_ext = Some(DublinCoreExtension { creators: vec![p.creator.ap_id.to_string()], ..DublinCoreExtension::default() }); let guid = Some(Guid { permalink: true, value: post_url.to_string(), }); let mut description = lang.submitted_post_with_meta_info( p.creator.actor_url(settings)?, &p.community.name, community_url, &p.creator.name, p.post.comments, p.post.score, &post_url, ); // If its a url post, add it to the description // and see if we can parse it as a media enclosure. let enclosure_opt = p.post.url.map(|url| { let mime_type = p .post .url_content_type .unwrap_or_else(|| "application/octet-stream".to_string()); // If the url directly links to an image, wrap it in an tag for display. let link_html = if mime_type.starts_with("image/") { format!("
") } else { format!("
{url}") }; description.push_str(&link_html); let mut enclosure_bld = EnclosureBuilder::default(); enclosure_bld.url(url.as_str().to_string()); enclosure_bld.mime_type(mime_type); enclosure_bld.length("0".to_string()); enclosure_bld.build() }); if let Some(body) = p.post.body { let html = markdown_to_html(&body); description.push_str(&html); } let mut extensions = ExtensionMap::new(); // If there's a thumbnail URL, add a media:content tag to display it. // See https://www.rssboard.org/media-rss#media-content for details. if let Some(url) = p.post.thumbnail_url { let mut thumbnail_ext = ExtensionBuilder::default(); thumbnail_ext.name("media:content".to_string()); thumbnail_ext.attrs(BTreeMap::from([ ("url".to_string(), url.to_string()), ("medium".to_string(), "image".to_string()), ])); extensions.insert( "media".to_string(), BTreeMap::from([("content".to_string(), vec![thumbnail_ext.build()])]), ); } let category = Category { name: p.community.title, domain: Some(p.community.ap_id.to_string()), }; let i = Item { title: Some(p.post.name), pub_date: Some(p.post.published_at.to_rfc2822()), comments: Some(post_url.to_string()), guid, description: Some(description), dublin_core_ext, link: Some(post_url.to_string()), extensions, enclosure: enclosure_opt, categories: vec![category], ..Default::default() }; items.push(i); } Ok(items) } ================================================ FILE: crates/routes/src/feeds/negotiate_content.rs ================================================ use actix_web::{Error, HttpRequest, http::header::*, web}; use lemmy_api_utils::{ context::LemmyContext, utils::{local_user_view_from_jwt, read_auth_token}, }; use lemmy_email::{translations::Lang, user_language}; use rosetta_i18n::{Language, LanguageId}; pub(crate) async fn get_lang_or_negotiate( req: &HttpRequest, context: &web::Data, ) -> Result { let jwt = read_auth_token(req)?; let lang = if let Some(jwt) = jwt { let local_user_view = local_user_view_from_jwt(&jwt, context).await?; user_language(&local_user_view.local_user) } else if req.headers().contains_key(ACCEPT_LANGUAGE) { negotiate_lang(req).unwrap_or(Lang::En) } else { Lang::En }; Ok(lang) } fn negotiate_lang(req: &HttpRequest) -> Option { let client_langs = AcceptLanguage::parse(req).ok()?; client_langs.ranked().iter().find_map(|cl| { let l = cl.item().map(|l| LanguageId::new(l.primary_language()))?; Lang::from_language_id(&l) }) } #[cfg(test)] #[expect(clippy::unwrap_used)] mod tests { use super::*; use actix_web::test::TestRequest; fn parse_lang_items( accept_language_header_value: &str, ) -> Vec>> { accept_language_header_value .split(',') .map(|s| s.parse().unwrap()) .collect() } #[test] fn test_negotiate_language_lang_supported_by_server() { let req = TestRequest::default() .insert_header(AcceptLanguage(parse_lang_items( "fj, sm, lo, da, en-GB;q=0.8, en;q=0.7", ))) .to_http_request(); let resolved_lang = negotiate_lang(&req).unwrap(); // This test will fail if support for Fijian language is introduced // Fix: Remove it and simply move one of the other (rare) languages to the top of the list assert_eq!(resolved_lang, Lang::Da); } #[test] fn test_negotiate_language_lang_unsupported_by_server() { let req = TestRequest::default() .insert_header(AcceptLanguage(parse_lang_items("fj, sm, lo, km"))) .to_http_request(); let resolved_lang = negotiate_lang(&req); // This test will fail if support for Fijian language is introduced // Fix: Remove it and simply move one of the other (rare) languages to the top of the list assert!(resolved_lang.is_none()); } #[test] fn test_negotiate_language_wildcard_alone() { let req = TestRequest::default() .insert_header(AcceptLanguage(parse_lang_items("*"))) .to_http_request(); let resolved_lang = negotiate_lang(&req); assert!(resolved_lang.is_none()); } #[test] fn test_negotiate_language_wildcard_with_langs_after() { let req = TestRequest::default() .insert_header(AcceptLanguage(parse_lang_items("*, fr"))) .to_http_request(); let resolved_lang = negotiate_lang(&req); assert!(resolved_lang.is_some()); } } ================================================ FILE: crates/routes/src/images/delete.rs ================================================ use super::utils::delete_old_image; use actix_web::web::*; use lemmy_api_utils::{ context::LemmyContext, request::{delete_image_alias, purge_image_from_pictrs}, utils::{is_admin, is_mod_or_admin}, }; use lemmy_db_schema::source::{ community::{Community, CommunityUpdateForm}, images::LocalImage, person::{Person, PersonUpdateForm}, site::{Site, SiteUpdateForm}, }; use lemmy_db_views_community::api::CommunityIdQuery; use lemmy_db_views_local_image::api::DeleteImageParams; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::{SiteView, api::SuccessResponse}; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; pub async fn delete_site_icon( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let site = SiteView::read_local(&mut context.pool()).await?.site; is_admin(&local_user_view)?; delete_old_image(&site.icon, &context).await?; let form = SiteUpdateForm { icon: Some(None), ..Default::default() }; Site::update(&mut context.pool(), site.id, &form).await?; Ok(Json(SuccessResponse::default())) } pub async fn delete_site_banner( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let site = SiteView::read_local(&mut context.pool()).await?.site; is_admin(&local_user_view)?; delete_old_image(&site.banner, &context).await?; let form = SiteUpdateForm { banner: Some(None), ..Default::default() }; Site::update(&mut context.pool(), site.id, &form).await?; Ok(Json(SuccessResponse::default())) } pub async fn delete_community_icon( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let community = Community::read(&mut context.pool(), data.id).await?; is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?; delete_old_image(&community.icon, &context).await?; let form = CommunityUpdateForm { icon: Some(None), ..Default::default() }; Community::update(&mut context.pool(), community.id, &form).await?; Ok(Json(SuccessResponse::default())) } pub async fn delete_community_banner( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { let community = Community::read(&mut context.pool(), data.id).await?; is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?; delete_old_image(&community.icon, &context).await?; let form = CommunityUpdateForm { icon: Some(None), ..Default::default() }; Community::update(&mut context.pool(), community.id, &form).await?; Ok(Json(SuccessResponse::default())) } pub async fn delete_user_avatar( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { delete_old_image(&local_user_view.person.avatar, &context).await?; let form = PersonUpdateForm { avatar: Some(None), ..Default::default() }; Person::update(&mut context.pool(), local_user_view.person.id, &form).await?; Ok(Json(SuccessResponse::default())) } pub async fn delete_user_banner( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { delete_old_image(&local_user_view.person.banner, &context).await?; let form = PersonUpdateForm { banner: Some(None), ..Default::default() }; Person::update(&mut context.pool(), local_user_view.person.id, &form).await?; Ok(Json(SuccessResponse::default())) } /// Deletes an image for a specific user. pub async fn delete_image( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { LocalImage::validate_by_alias_and_user( &mut context.pool(), &data.filename, local_user_view.person.id, ) .await?; delete_image_alias(&data.filename, &context).await?; Ok(Json(SuccessResponse::default())) } /// Deletes any image, only for admins. pub async fn delete_image_admin( Json(data): Json, context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { is_admin(&local_user_view)?; // Use purge, since it should remove any other aliases. purge_image_from_pictrs(&data.filename, &context).await?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/routes/src/images/download.rs ================================================ use super::utils::{adapt_request, convert_header}; use actix_web::{ HttpRequest, HttpResponse, Responder, body::{BodyStream, BoxBody}, http::StatusCode, web::{Data, *}, }; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::images::RemoteImage; use lemmy_db_views_local_image::api::{ImageGetParams, ImageProxyParams}; use lemmy_db_views_site::SiteView; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use std::str::FromStr; use strum::{Display, EnumString}; use url::Url; pub async fn get_image( filename: Path, Query(params): Query, req: HttpRequest, context: Data, ) -> LemmyResult { let name = &filename.into_inner(); // If there are no query params, the URL is original let pictrs_url = context.settings().pictrs()?.url; let processed_url = if params.file_type.is_none() && params.max_size.is_none() { format!("{}image/original/{}", pictrs_url, name) } else { let file_type = file_type(params.file_type, name).unwrap_or_default(); let mut url = format!("{}image/process.{}?src={}", pictrs_url, file_type, name); if let Some(size) = params.max_size { url = format!("{url}&thumbnail={size}",); } url }; do_get_image(processed_url, req, &context).await } pub async fn image_proxy( Query(params): Query, req: HttpRequest, context: Data, ) -> LemmyResult, HttpResponse>> { let url = Url::parse(¶ms.url)?; let encoded_url = utf8_percent_encode(¶ms.url, NON_ALPHANUMERIC).to_string(); // Check that url corresponds to a federated image so that this can't be abused as a proxy // for arbitrary purposes. RemoteImage::validate(&mut context.pool(), url.clone().into()).await?; let pictrs_config = context.settings().pictrs()?; let processed_url = if params.file_type.is_none() && params.max_size.is_none() { format!("{}image/original?proxy={}", pictrs_config.url, encoded_url) } else { let file_type = file_type(params.file_type, url.path()).unwrap_or_default(); let mut url = format!( "{}image/process.{}?proxy={}", pictrs_config.url, file_type, encoded_url ); if let Some(size) = params.max_size { url = format!("{url}&thumbnail={size}",); } url }; let proxy_bypass_domains = SiteView::read_local(&mut context.pool()) .await? .local_site .image_proxy_bypass_domains .map(|e| e.split(',').map(ToString::to_string).collect::>()) .unwrap_or_default(); let bypass_proxy = proxy_bypass_domains .iter() .any(|s| url.domain().is_some_and(|d| d == s)); if bypass_proxy { // Bypass proxy and redirect user to original image Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req))) } else { // Proxy the image data through Lemmy Ok(Either::Right( do_get_image(processed_url, req, &context).await?, )) } } pub(super) async fn do_get_image( url: String, req: HttpRequest, context: &LemmyContext, ) -> LemmyResult { let mut client_req = adapt_request(&req, url, context); if let Some(addr) = req.head().peer_addr { client_req = client_req.header("X-Forwarded-For", addr.to_string()); } if let Some(addr) = req.head().peer_addr { client_req = client_req.header("X-Forwarded-For", addr.to_string()); } let res = client_req.send().await?; if res.status() == http::StatusCode::NOT_FOUND { return Ok(HttpResponse::NotFound().finish()); } let mut client_res = HttpResponse::build(StatusCode::from_u16(res.status().as_u16())?); for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") { client_res.insert_header(convert_header(name, value)); } Ok(client_res.body(BodyStream::new(res.bytes_stream()))) } #[derive(EnumString, Display, PartialEq, Debug, Default)] #[strum(ascii_case_insensitive, serialize_all = "snake_case")] enum PictrsFileType { Apng, Avif, Gif, #[default] Jpg, Jxl, Png, Webp, } /// Take file type from param, name, or use jpg if nothing is given fn file_type(file_type: Option, name: &str) -> LemmyResult { let type_str = file_type .clone() .unwrap_or_else(|| name.split('.').next_back().unwrap_or("jpg").to_string()); PictrsFileType::from_str(&type_str).with_lemmy_type(LemmyErrorType::NotAnImageType) } #[cfg(test)] mod tests { use crate::images::download::{PictrsFileType, file_type}; use lemmy_utils::error::LemmyResult; #[tokio::test] async fn image_file_type_tests() -> LemmyResult<()> { // Make sure files type outputs are getting lower-cased assert_eq!(PictrsFileType::Jpg.to_string(), "jpg".to_string()); let file_url = "a8a7f07f-3ef2-40fa-849c-ae952f68f3ec.jpg"; // Make sure wrong-cased file type requests are okay assert_eq!( PictrsFileType::Jpg, file_type(Some("JPg".to_string()), file_url)? ); // Make sure converts are working assert_eq!( PictrsFileType::Avif, file_type(Some("AVif".to_string()), file_url)? ); // Make sure wrong file type requests are okay with unwrap_or_default assert_eq!( PictrsFileType::Jpg, file_type(Some("jpeg".to_string()), file_url).unwrap_or_default() ); assert_eq!( PictrsFileType::Jpg, file_type(Some("nonsense".to_string()), file_url).unwrap_or_default() ); // Make sure missing file type requests are okay assert_eq!(PictrsFileType::Jpg, file_type(None, file_url)?); // jpeg let file_url = "a8a7f07f-3ef2-40fa-849c-ae952f68f3ec.jpeg"; // Make sure jpeg one is okay assert_eq!( PictrsFileType::Jpg, file_type(None, file_url).unwrap_or_default() ); // Make sure proxy ones are okay let proxy_url = "https://test.tld/pictrs/image/6d3b2f3f-7b29-4d9a-868e-b269423f4d6c.WEbP"; assert_eq!(PictrsFileType::Webp, file_type(None, proxy_url)?); Ok(()) } } ================================================ FILE: crates/routes/src/images/mod.rs ================================================ use actix_web::web::*; use lemmy_api_utils::context::LemmyContext; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::LemmyResult; pub mod delete; pub mod download; pub mod upload; mod utils; pub async fn pictrs_health(context: Data) -> LemmyResult> { let pictrs_config = context.settings().pictrs()?; let url = format!("{}healthz", pictrs_config.url); context .pictrs_client() .get(url) .send() .await? .error_for_status()?; Ok(Json(SuccessResponse::default())) } ================================================ FILE: crates/routes/src/images/upload.rs ================================================ use super::utils::{adapt_request, delete_old_image, make_send}; use UploadType::*; use actix_web::{self, HttpRequest, web::*}; use lemmy_api_utils::{ context::LemmyContext, request::PictrsResponse, utils::{is_admin, is_mod_or_admin}, }; use lemmy_db_schema::source::{ community::{Community, CommunityUpdateForm}, images::{LocalImage, LocalImageForm}, local_site::LocalSite, person::{Person, PersonUpdateForm}, site::{Site, SiteUpdateForm}, }; use lemmy_db_views_community::api::CommunityIdQuery; use lemmy_db_views_local_image::api::UploadImageResponse; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use reqwest::Body; use std::time::Duration; pub enum UploadType { Avatar, Banner, Other, } pub async fn upload_image( req: HttpRequest, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; if local_site.image_upload_disabled { return Err(LemmyErrorType::ImageUploadDisabled.into()); } Ok(Json( do_upload_image(req, body, Other, &local_user_view, &local_site, &context).await?, )) } pub async fn upload_user_avatar( req: HttpRequest, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let image = do_upload_image(req, body, Avatar, &local_user_view, &local_site, &context).await?; delete_old_image(&local_user_view.person.avatar, &context).await?; let form = PersonUpdateForm { avatar: Some(Some(image.image_url.clone().into())), ..Default::default() }; Person::update(&mut context.pool(), local_user_view.person.id, &form).await?; Ok(Json(image)) } pub async fn upload_user_banner( req: HttpRequest, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let image = do_upload_image(req, body, Banner, &local_user_view, &local_site, &context).await?; delete_old_image(&local_user_view.person.banner, &context).await?; let form = PersonUpdateForm { banner: Some(Some(image.image_url.clone().into())), ..Default::default() }; Person::update(&mut context.pool(), local_user_view.person.id, &form).await?; Ok(Json(image)) } pub async fn upload_community_icon( req: HttpRequest, query: Query, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let community: Community = Community::read(&mut context.pool(), query.id).await?; is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?; let image = do_upload_image(req, body, Avatar, &local_user_view, &local_site, &context).await?; delete_old_image(&community.icon, &context).await?; let form = CommunityUpdateForm { icon: Some(Some(image.image_url.clone().into())), ..Default::default() }; Community::update(&mut context.pool(), community.id, &form).await?; Ok(Json(image)) } pub async fn upload_community_banner( req: HttpRequest, query: Query, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let community: Community = Community::read(&mut context.pool(), query.id).await?; is_mod_or_admin(&mut context.pool(), &local_user_view, community.id).await?; let image = do_upload_image(req, body, Banner, &local_user_view, &local_site, &context).await?; delete_old_image(&community.banner, &context).await?; let form = CommunityUpdateForm { banner: Some(Some(image.image_url.clone().into())), ..Default::default() }; Community::update(&mut context.pool(), community.id, &form).await?; Ok(Json(image)) } pub async fn upload_site_icon( req: HttpRequest, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { is_admin(&local_user_view)?; let SiteView { site, local_site, .. } = SiteView::read_local(&mut context.pool()).await?; let image = do_upload_image(req, body, Avatar, &local_user_view, &local_site, &context).await?; delete_old_image(&site.icon, &context).await?; let form = SiteUpdateForm { icon: Some(Some(image.image_url.clone().into())), ..Default::default() }; Site::update(&mut context.pool(), site.id, &form).await?; Ok(Json(image)) } pub async fn upload_site_banner( req: HttpRequest, body: Payload, local_user_view: LocalUserView, context: Data, ) -> LemmyResult> { is_admin(&local_user_view)?; let SiteView { site, local_site, .. } = SiteView::read_local(&mut context.pool()).await?; let image = do_upload_image(req, body, Banner, &local_user_view, &local_site, &context).await?; delete_old_image(&site.banner, &context).await?; let form = SiteUpdateForm { banner: Some(Some(image.image_url.clone().into())), ..Default::default() }; Site::update(&mut context.pool(), site.id, &form).await?; Ok(Json(image)) } async fn do_upload_image( req: HttpRequest, body: Payload, upload_type: UploadType, local_user_view: &LocalUserView, local_site: &LocalSite, context: &Data, ) -> LemmyResult { let pictrs_url = context.settings().pictrs()?.url; let max_upload_size = local_site.image_max_upload_size.to_string(); let image_url = format!("{}image", pictrs_url); let mut client_req = adapt_request(&req, image_url, context); // Set pictrs parameters to downscale images and restrict file types. // https://git.asonix.dog/asonix/pict-rs/#api client_req = match upload_type { Avatar => { let max_size = local_site.image_max_avatar_size.to_string(); client_req.query(&[ ("resize", max_size.as_ref()), ("allow_animation", "false"), ("allow_video", "false"), ]) } Banner => { let max_size = local_site.image_max_banner_size.to_string(); client_req.query(&[ ("resize", max_size.as_ref()), ("allow_animation", "false"), ("allow_video", "false"), ]) } Other => { let mut query = vec![( "allow_video", local_site.image_allow_video_uploads.to_string(), )]; query.push(("resize", max_upload_size)); client_req.query(&query) } }; if let Some(addr) = req.head().peer_addr { client_req = client_req.header("X-Forwarded-For", addr.to_string()) }; // Make HTTP request to pict-rs with the user provided image data. let res = client_req .timeout(Duration::from_secs( local_site.image_upload_timeout_seconds.try_into()?, )) .body(Body::wrap_stream(make_send(body))) .send() .await // Dont check for status code here and dont call `error_for_status()`. If the upload failed, // this is handled below as `images.files` is empty. .with_lemmy_type(LemmyErrorType::PictrsInvalidImageUpload( "HTTP request to pict-rs failed".to_string(), ))?; let mut images = res.json::().await?; for image in &images.files { // Pictrs allows uploading multiple images in a single request. Lemmy doesnt need this, // but still a user may upload multiple and so we need to store all links in db for // to allow deletion via web ui. let form = LocalImageForm { pictrs_alias: image.file.clone(), person_id: local_user_view.person.id, thumbnail_for_post_id: None, }; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); let thumbnail_url = image.image_url(&protocol_and_hostname)?; // Also store the details for the image let details_form = image.details.build_image_details_form(&thumbnail_url); LocalImage::create(&mut context.pool(), &form, &details_form).await?; } let image = images .files .pop() .ok_or(LemmyErrorType::PictrsInvalidImageUpload(images.msg))?; let url = image.image_url(&context.settings().get_protocol_and_hostname())?; Ok(UploadImageResponse { image_url: url, filename: image.file, }) } ================================================ FILE: crates/routes/src/images/utils.rs ================================================ use actix_web::{ HttpRequest, http::{ Method, header::{ACCEPT_ENCODING, HOST, HeaderName}, }, web::Data, }; use diesel::NotFound; use futures::stream::{Stream, StreamExt}; use http::HeaderValue; use lemmy_api_utils::{context::LemmyContext, request::delete_image_alias}; use lemmy_diesel_utils::dburl::DbUrl; use lemmy_utils::{REQWEST_TIMEOUT, error::LemmyResult}; use reqwest_middleware::RequestBuilder; pub(super) fn adapt_request( request: &HttpRequest, url: String, context: &LemmyContext, ) -> RequestBuilder { // remove accept-encoding header so that pictrs doesn't compress the response const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST]; let client_request = context .pictrs_client() .request(convert_method(request.method()), url) .timeout(REQWEST_TIMEOUT); request .headers() .iter() .fold(client_request, |client_req, (key, value)| { if INVALID_HEADERS.contains(key) { client_req } else { // TODO: remove as_str and as_bytes conversions after actix-web upgrades to http 1.0 client_req.header(key.as_str(), value.as_bytes()) } }) } pub(super) fn make_send(mut stream: S) -> impl Stream + Send + Unpin + 'static where S: Stream + Unpin + 'static, S::Item: Send, { // NOTE: the 8 here is arbitrary let (tx, rx) = tokio::sync::mpsc::channel(8); // NOTE: spawning stream into a new task can potentially hit this bug: // - https://github.com/actix/actix-web/issues/1679 // // Since 4.0.0-beta.2 this issue is incredibly less frequent. I have not personally reproduced it. // That said, it is still technically possible to encounter. actix_web::rt::spawn(async move { while let Some(res) = stream.next().await { if tx.send(res).await.is_err() { break; } } }); SendStream { rx } } struct SendStream { rx: tokio::sync::mpsc::Receiver, } impl Stream for SendStream where T: Send, { type Item = T; fn poll_next( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { std::pin::Pin::new(&mut self.rx).poll_recv(cx) } } // TODO: remove these conversions after actix-web upgrades to http 1.0 #[expect(clippy::expect_used)] pub(super) fn convert_method(method: &Method) -> http::Method { http::Method::from_bytes(method.as_str().as_bytes()).expect("method can be converted") } pub(super) fn convert_header<'a>( name: &'a http::HeaderName, value: &'a HeaderValue, ) -> (&'a str, &'a [u8]) { (name.as_str(), value.as_bytes()) } /// When adding a new avatar, banner or similar image, delete the old one. pub(super) async fn delete_old_image( old_image: &Option, context: &Data, ) -> LemmyResult<()> { if let Some(old_image) = old_image { let alias = old_image.as_str().split('/').next_back().ok_or(NotFound)?; delete_image_alias(alias, context).await?; } Ok(()) } ================================================ FILE: crates/routes/src/lib.rs ================================================ pub mod feeds; pub mod images; pub mod middleware; pub mod nodeinfo; pub mod utils; pub mod webfinger; ================================================ FILE: crates/routes/src/middleware/idempotency.rs ================================================ use actix_web::{ Error, HttpMessage, HttpResponse, body::EitherBody, dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, http::Method, }; use futures_util::future::LocalBoxFuture; use lemmy_db_schema::newtypes::LocalUserId; use lemmy_db_views_local_user::LocalUserView; use std::{ collections::HashSet, future::{Ready, ready}, hash::{Hash, Hasher}, sync::{Arc, LazyLock, RwLock}, time::{Duration, Instant}, }; /// https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-01.html const IDEMPOTENCY_HEADER: &str = "Idempotency-Key"; /// Delete idempotency keys older than this const CLEANUP_INTERVAL_SECS: u32 = 120; /// Smaller than `std::time::Instant` because it uses a smaller integer for seconds and doesn't /// store nanoseconds #[derive(PartialEq, Debug, Clone, Copy, Hash)] struct InstantSecs { pub secs: u32, } static START_TIME: LazyLock = LazyLock::new(Instant::now); #[expect(clippy::expect_used)] impl InstantSecs { pub fn now() -> Self { InstantSecs { secs: u32::try_from(START_TIME.elapsed().as_secs()) .expect("server has been running for over 136 years"), } } } #[derive(Debug)] struct Entry { user_id: LocalUserId, key: String, // Creation time is ignored for Eq, Hash and only used to cleanup old entries created: InstantSecs, } impl PartialEq for Entry { fn eq(&self, other: &Self) -> bool { self.user_id == other.user_id && self.key == other.key } } impl Eq for Entry {} impl Hash for Entry { fn hash(&self, state: &mut H) { self.user_id.hash(state); self.key.hash(state); } } #[derive(Clone)] pub struct IdempotencySet { set: Arc>>, } impl Default for IdempotencySet { fn default() -> Self { let set: Arc>> = Default::default(); let set_ = set.clone(); tokio::spawn(async move { let interval = Duration::from_secs(CLEANUP_INTERVAL_SECS.into()); let state_weak_ref = Arc::downgrade(&set_); // Run at every interval to delete entries older than the interval. // This loop stops when all other references to `state` are dropped. while let Some(state) = state_weak_ref.upgrade() { tokio::time::sleep(interval).await; let now = InstantSecs::now(); #[expect(clippy::expect_used)] let mut lock = state.write().expect("lock failed"); lock.retain(|e| e.created.secs > now.secs.saturating_sub(CLEANUP_INTERVAL_SECS)); lock.shrink_to_fit(); } }); Self { set } } } pub struct IdempotencyMiddleware { idempotency_set: IdempotencySet, } impl IdempotencyMiddleware { pub fn new(idempotency_set: IdempotencySet) -> Self { Self { idempotency_set } } } impl Transform for IdempotencyMiddleware where S: Service, Error = Error>, S::Future: 'static, B: 'static, { type Response = ServiceResponse>; type Error = Error; type InitError = (); type Transform = IdempotencyService; type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { ready(Ok(IdempotencyService { service, idempotency_set: self.idempotency_set.clone(), })) } } pub struct IdempotencyService { service: S, idempotency_set: IdempotencySet, } impl Service for IdempotencyService where S: Service, Error = Error>, S::Future: 'static, B: 'static, { type Response = ServiceResponse>; type Error = Error; type Future = LocalBoxFuture<'static, Result>; forward_ready!(service); #[expect(clippy::expect_used)] fn call(&self, req: ServiceRequest) -> Self::Future { let is_post_or_put = req.method() == Method::POST || req.method() == Method::PUT; let idempotency = req .headers() .get(IDEMPOTENCY_HEADER) .map(|i| i.to_str().unwrap_or_default().to_string()) // Ignore values longer than 32 chars .and_then(|i| (i.len() <= 32).then_some(i)) // Only use idempotency for POST and PUT requests .and_then(|i| is_post_or_put.then_some(i)); let user_id = { let ext = req.extensions(); ext.get().map(|u: &LocalUserView| u.local_user.id) }; if let (Some(key), Some(user_id)) = (idempotency, user_id) { let value = Entry { user_id, key, created: InstantSecs::now(), }; if self .idempotency_set .set .read() .expect("lock failed") .contains(&value) { // Duplicate request, return error let (req, _pl) = req.into_parts(); let response = HttpResponse::UnprocessableEntity() .finish() .map_into_right_body(); return Box::pin(async { Ok(ServiceResponse::new(req, response)) }); } else { // New request, store key and continue self .idempotency_set .set .write() .expect("lock failed") .insert(value); } } let fut = self.service.call(req); Box::pin(async move { fut.await.map(ServiceResponse::map_into_left_body) }) } } ================================================ FILE: crates/routes/src/middleware/mod.rs ================================================ pub mod idempotency; pub mod session; ================================================ FILE: crates/routes/src/middleware/session.rs ================================================ use actix_web::{ Error, HttpMessage, body::MessageBody, dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready}, http::header::{CACHE_CONTROL, HeaderValue}, }; use core::future::Ready; use futures_util::future::LocalBoxFuture; use lemmy_api_utils::{ context::LemmyContext, utils::{local_user_view_from_jwt, read_auth_token}, }; use std::{future::ready, rc::Rc}; #[derive(Clone)] pub struct SessionMiddleware { context: LemmyContext, } impl SessionMiddleware { pub fn new(context: LemmyContext) -> Self { SessionMiddleware { context } } } impl Transform for SessionMiddleware where S: Service, Error = Error> + 'static, S::Future: 'static, B: MessageBody + 'static, { type Response = ServiceResponse; type Error = Error; type Transform = SessionService; type InitError = (); type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { ready(Ok(SessionService { service: Rc::new(service), context: self.context.clone(), })) } } pub struct SessionService { service: Rc, context: LemmyContext, } impl Service for SessionService where S: Service, Error = Error> + 'static, S::Future: 'static, B: 'static, { type Response = ServiceResponse; type Error = Error; type Future = LocalBoxFuture<'static, Result>; forward_ready!(service); fn call(&self, req: ServiceRequest) -> Self::Future { let svc = self.service.clone(); let context = self.context.clone(); Box::pin(async move { let jwt = read_auth_token(req.request())?; if let Some(jwt) = &jwt { // Ignore any invalid auth so the site can still be used // This means it is be impossible to get any error message for invalid jwt. Need // to use `/api/v4/account/validate_auth` for that. let local_user_view = local_user_view_from_jwt(jwt, &context).await.ok(); if let Some(local_user_view) = local_user_view { req.extensions_mut().insert(local_user_view); } } let mut res = svc.call(req).await?; // Add cache-control header if none is present if !res.headers().contains_key(CACHE_CONTROL) { // If user is authenticated, mark as private. Otherwise cache // up to one minute. let cache_value = if jwt.is_some() { "private" } else { "public, max-age=60" }; res .headers_mut() .insert(CACHE_CONTROL, HeaderValue::from_static(cache_value)); } Ok(res) }) } } #[cfg(test)] mod tests { use actix_web::test::TestRequest; use lemmy_api_utils::{claims::Claims, context::LemmyContext}; use lemmy_db_schema::source::{ instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; use serial_test::serial; #[tokio::test] #[serial] async fn test_session_auth() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let inserted_instance = Instance::read_or_create(&mut context.pool(), "my_domain.tld").await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "Gerry9812"); let inserted_person = Person::create(&mut context.pool(), &new_person).await?; let local_user_form = LocalUserInsertForm::test_form(inserted_person.id); let inserted_local_user = LocalUser::create(&mut context.pool(), &local_user_form, vec![]).await?; let req = TestRequest::default().to_http_request(); let jwt = Claims::generate(inserted_local_user.id, None, req, &context).await?; let valid = Claims::validate(&jwt, &context).await; assert!(valid.is_ok()); let num_deleted = Person::delete(&mut context.pool(), inserted_person.id).await?; assert_eq!(1, num_deleted); Ok(()) } } ================================================ FILE: crates/routes/src/nodeinfo.rs ================================================ use actix_web::{Error, HttpResponse, Result, web}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema_file::enums::RegistrationMode; use lemmy_db_views_site::SiteView; use lemmy_utils::{ VERSION, cache_header::{cache_1hour, cache_3days}, error::LemmyResult, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use url::Url; /// A description of the nodeinfo endpoint is here: /// https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md pub fn config(cfg: &mut web::ServiceConfig) { cfg .route( "/nodeinfo/2.1", web::get().to(node_info).wrap(cache_1hour()), ) .service(web::redirect("/version", "/nodeinfo/2.1")) // For backwards compatibility, can be removed after Lemmy 0.20 .service(web::redirect("/nodeinfo/2.0.json", "/nodeinfo/2.1")) .service(web::redirect("/nodeinfo/2.1.json", "/nodeinfo/2.1")) .route( "/.well-known/nodeinfo", web::get().to(node_info_well_known).wrap(cache_3days()), ); } async fn node_info_well_known(context: web::Data) -> LemmyResult { let node_info = NodeInfoWellKnown { links: vec![NodeInfoWellKnownLinks { rel: Url::parse("http://nodeinfo.diaspora.software/ns/schema/2.1")?, href: Url::parse(&format!( "{}/nodeinfo/2.1", &context.settings().get_protocol_and_hostname(), ))?, }], }; Ok(HttpResponse::Ok().json(node_info)) } async fn node_info(context: web::Data) -> Result { let site_view = SiteView::read_local(&mut context.pool()).await?; // Since there are 3 registration options, // we need to set open_registrations as true if RegistrationMode is not Closed. let open_registrations = Some(site_view.local_site.registration_mode != RegistrationMode::Closed); let json = NodeInfo { version: Some("2.1".to_string()), software: Some(NodeInfoSoftware { name: Some("lemmy".to_string()), version: Some(VERSION.to_string()), repository: Some("https://github.com/LemmyNet/lemmy".to_string()), homepage: Some("https://join-lemmy.org/".to_string()), }), protocols: Some(vec!["activitypub".to_string()]), usage: Some(NodeInfoUsage { users: Some(NodeInfoUsers { total: Some(site_view.local_site.users), active_halfyear: Some(site_view.local_site.users_active_half_year), active_month: Some(site_view.local_site.users_active_month), }), local_posts: Some(site_view.local_site.posts), local_comments: Some(site_view.local_site.comments), }), open_registrations, services: Some(NodeInfoServices { inbound: Some(vec![]), outbound: Some(vec![]), }), metadata: Some(HashMap::new()), }; Ok(HttpResponse::Ok().json(json)) } #[derive(Serialize, Deserialize, Debug)] pub(crate) struct NodeInfoWellKnown { pub links: Vec, } #[derive(Serialize, Deserialize, Debug)] pub(crate) struct NodeInfoWellKnownLinks { pub rel: Url, pub href: Url, } /// Nodeinfo spec: http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.1 #[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all = "camelCase", default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub(crate) struct NodeInfo { pub version: Option, pub software: Option, pub protocols: Option>, pub usage: Option, pub open_registrations: Option, /// These fields are required by the spec for no reason pub services: Option, pub metadata: Option>, } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub(crate) struct NodeInfoSoftware { pub name: Option, pub version: Option, pub repository: Option, pub homepage: Option, } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all = "camelCase", default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub(crate) struct NodeInfoUsage { pub users: Option, pub local_posts: Option, pub local_comments: Option, } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all = "camelCase", default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub(crate) struct NodeInfoUsers { pub total: Option, pub active_halfyear: Option, pub active_month: Option, } #[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all = "camelCase", default)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] pub(crate) struct NodeInfoServices { pub inbound: Option>, pub outbound: Option>, } ================================================ FILE: crates/routes/src/utils/mod.rs ================================================ use actix_cors::Cors; use lemmy_utils::settings::structs::Settings; pub mod prometheus_metrics; pub mod scheduled_tasks; pub mod setup_local_site; pub fn cors_config(settings: &Settings) -> Cors { let self_origin = settings.get_protocol_and_hostname(); let cors_origin_setting = settings.cors_origin(); let mut cors = Cors::default() .allow_any_method() .allow_any_header() .expose_any_header() .max_age(3600); if cfg!(debug_assertions) || cors_origin_setting.is_empty() || cors_origin_setting.contains(&"*".to_string()) { cors = cors.allow_any_origin(); } else { cors = cors.allowed_origin(&self_origin); for c in cors_origin_setting { cors = cors.allowed_origin(&c); } } cors } ================================================ FILE: crates/routes/src/utils/prometheus_metrics.rs ================================================ use actix_web::{App, HttpServer, rt::System, web}; use actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder}; use lemmy_api_utils::context::LemmyContext; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, settings::structs::PrometheusConfig, }; use prometheus::{Encoder, Gauge, Opts, TextEncoder, default_registry}; use std::{sync::Arc, thread}; use tracing::error; /// Creates a middleware that populates http metrics for each path, method, and status code pub fn new_prometheus_metrics() -> LemmyResult { Ok( PrometheusMetricsBuilder::new("lemmy_api") .registry(default_registry().clone()) .build() .map_err(|e| LemmyErrorType::Unknown(format!("Should always be buildable: {e}")))?, ) } struct PromContext { lemmy: LemmyContext, db_pool_metrics: DbPoolMetrics, } struct DbPoolMetrics { max_size: Gauge, size: Gauge, available: Gauge, } pub fn serve_prometheus(config: PrometheusConfig, lemmy_context: LemmyContext) -> LemmyResult<()> { let context = Arc::new(PromContext { lemmy: lemmy_context, db_pool_metrics: create_db_pool_metrics()?, }); // spawn thread that blocks on handling requests // only mapping /metrics to a handler thread::spawn(move || { let sys = System::new(); sys.block_on(async { let server = HttpServer::new(move || { App::new() .app_data(web::Data::new(Arc::clone(&context))) .route("/metrics", web::get().to(metrics)) }) .bind((config.bind, config.port)) .unwrap_or_else(|e| panic!("Cannot bind to {}:{}: {e}", config.bind, config.port)) .run(); if let Err(err) = server.await { error!("Prometheus server error: {err}"); } }) }); Ok(()) } // handler for the /metrics path async fn metrics(context: web::Data>) -> LemmyResult { // collect metrics collect_db_pool_metrics(&context); let mut buffer = Vec::new(); let encoder = TextEncoder::new(); // gather metrics from registry and encode in prometheus format let metric_families = prometheus::gather(); encoder.encode(&metric_families, &mut buffer)?; let output = String::from_utf8(buffer)?; Ok(output) } // create lemmy_db_pool_* metrics and register them with the default registry fn create_db_pool_metrics() -> LemmyResult { let metrics = DbPoolMetrics { max_size: Gauge::with_opts(Opts::new( "lemmy_db_pool_max_connections", "Maximum number of connections in the pool", ))?, size: Gauge::with_opts(Opts::new( "lemmy_db_pool_connections", "Current number of connections in the pool", ))?, available: Gauge::with_opts(Opts::new( "lemmy_db_pool_available_connections", "Number of available connections in the pool", ))?, }; default_registry().register(Box::new(metrics.max_size.clone()))?; default_registry().register(Box::new(metrics.size.clone()))?; default_registry().register(Box::new(metrics.available.clone()))?; Ok(metrics) } /// try_from does not support conversion from usize to f64 /// https://stackoverflow.com/q/35974890 #[expect(clippy::as_conversions)] fn collect_db_pool_metrics(context: &PromContext) { let pool_status = context.lemmy.inner_pool().status(); context .db_pool_metrics .max_size .set(pool_status.max_size as f64); context.db_pool_metrics.size.set(pool_status.size as f64); context .db_pool_metrics .available .set(pool_status.available as f64); } ================================================ FILE: crates/routes/src/utils/scheduled_tasks.rs ================================================ use crate::nodeinfo::{NodeInfo, NodeInfoWellKnown}; use activitypub_federation::config::Data; use chrono::{DateTime, TimeZone, Utc}; use clokwerk::{AsyncScheduler, TimeUnits as CTimeUnits}; use diesel::{ BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, QueryDsl, QueryableByName, SelectableHelper, dsl::{IntervalDsl, count, exists, not, update}, query_builder::AsQuery, sql_query, sql_types::{BigInt, Integer, Timestamptz}, }; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use diesel_uplete::uplete; use lemmy_api_utils::{ context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::send_webmention, }; use lemmy_db_schema::{ source::{ community::Community, instance::{Instance, InstanceForm}, local_user::LocalUser, post::{Post, PostUpdateForm}, }, utils::DELETED_REPLACEMENT_TEXT, }; use lemmy_db_schema_file::schema::{ comment, community, community_actions, federation_blocklist, instance, instance_actions, local_site, local_user, person, post, received_activity, sent_activity, site, }; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, traits::Crud, utils::{functions::coalesce, now}, }; use lemmy_utils::{ DB_BATCH_SIZE, error::{LemmyErrorType, LemmyResult}, }; use reqwest_middleware::ClientWithMiddleware; use std::time::Duration; use tracing::{info, warn}; /// Schedules various cleanup tasks for lemmy in a background thread pub async fn setup(context: Data) -> LemmyResult<()> { // https://github.com/mdsherry/clokwerk/issues/38 let mut scheduler = AsyncScheduler::with_tz(Utc); let context_1 = context.clone(); // Every 10 minutes update hot ranks, delete expired captchas and publish scheduled posts scheduler.every(CTimeUnits::minutes(10)).run(move || { let context = context_1.clone(); async move { update_hot_ranks(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to update hot ranks: {e}")) .ok(); publish_scheduled_posts(&context) .await .inspect_err(|e| warn!("Failed to publish scheduled posts: {e}")) .ok(); } }); let context_1 = context.clone(); // Hourly tasks: // - Update active daily counts // - Expired bans // - Expired instance blocks scheduler.every(CTimeUnits::hour(1)).run(move || { let context = context_1.clone(); async move { active_counts(&mut context.pool(), ONE_DAY) .await .inspect_err(|e| warn!("Failed to update active counts: {e}")) .ok(); update_banned_when_expired(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to update expired bans: {e}")) .ok(); delete_instance_block_when_expired(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to delete expired instance bans: {e}")) .ok(); } }); let context_1 = context.reset_request_count(); // Daily tasks: // - Update site and community activity counts // - Update local user count // - Overwrite deleted & removed posts and comments every day // - Delete old denied users // - Update instance software // - Delete old outgoing activities scheduler.every(CTimeUnits::days(1)).run(move || { let context = context_1.reset_request_count(); async move { all_active_counts(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to update active counts: {e}")) .ok(); update_local_user_count(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to update local user count: {e}")) .ok(); overwrite_deleted_posts_and_comments(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to overwrite deleted posts/comments: {e}")) .ok(); delete_old_denied_users(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to delete old denied users: {e}")) .ok(); update_instance_software(&mut context.pool(), context.client()) .await .inspect_err(|e| warn!("Failed to update instance software: {e}")) .ok(); clear_old_activities(&mut context.pool()) .await .inspect_err(|e| warn!("Failed to clear old activities: {e}")) .ok(); } }); // Manually run the scheduler in an event loop loop { scheduler.run_pending().await; tokio::time::sleep(Duration::from_millis(1000)).await; } } /// Update the hot_rank columns for the aggregates tables /// Runs in batches until all necessary rows are updated once async fn update_hot_ranks(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Updating hot ranks for all history..."); let conn = &mut get_conn(pool).await?; process_post_aggregates_ranks_in_batches(conn).await?; process_ranks_in_batches( conn, "comment", "a.hot_rank != 0", "SET hot_rank = r.hot_rank(a.score, a.published_at)", ) .await?; process_ranks_in_batches( conn, "community", "a.hot_rank != 0", "SET hot_rank = r.hot_rank(a.subscribers, a.published_at)", ) .await?; info!("Finished hot ranks update!"); Ok(()) } #[derive(QueryableByName)] struct HotRanksUpdateResult { #[diesel(sql_type = Timestamptz)] published_at: DateTime, } /// Runs the hot rank update query in batches until all rows have been processed. /// In `where_clause` and `set_clause`, "a" will refer to the current aggregates table. /// Locked rows are skipped in order to prevent deadlocks (they will likely get updated on the next /// run) async fn process_ranks_in_batches( conn: &mut AsyncPgConnection, table_name: &str, where_clause: &str, set_clause: &str, ) -> LemmyResult<()> { let process_start_time: DateTime = Utc.timestamp_opt(0, 0).single().unwrap_or_default(); let mut processed_rows_count = 0; let mut previous_batch_result = Some(process_start_time); while let Some(previous_batch_last_published) = previous_batch_result { // Raw `sql_query` is used as a performance optimization - Diesel does not support doing this // in a single query (neither as a CTE, nor using a subquery) let updated_rows = sql_query(format!( r#"WITH batch AS (SELECT a.id FROM {table_name} a WHERE a.published_at > $1 AND ({where_clause}) ORDER BY a.published_at LIMIT $2 FOR UPDATE SKIP LOCKED) UPDATE {table_name} a {set_clause} FROM batch WHERE a.id = batch.id RETURNING a.published_at; "#, )) .bind::(previous_batch_last_published) .bind::(DB_BATCH_SIZE) .get_results::(conn) .await .map_err(|e| { LemmyErrorType::Unknown(format!("Failed to update {} hot_ranks: {}", table_name, e)) })?; processed_rows_count += updated_rows.len(); previous_batch_result = updated_rows.last().map(|row| row.published_at); } info!( "Finished process_hot_ranks_in_batches execution for {} (processed {} rows)", table_name, processed_rows_count ); Ok(()) } /// Post aggregates is a special case, since it needs to join to the community_aggregates /// table, to get the active monthly user counts. async fn process_post_aggregates_ranks_in_batches(conn: &mut AsyncPgConnection) -> LemmyResult<()> { let process_start_time: DateTime = Utc.timestamp_opt(0, 0).single().unwrap_or_default(); let mut processed_rows_count = 0; let mut previous_batch_result = Some(process_start_time); while let Some(previous_batch_last_published) = previous_batch_result { let updated_rows = sql_query( r#"WITH batch AS (SELECT pa.id FROM post pa WHERE pa.published_at > $1 AND (pa.hot_rank != 0 OR pa.hot_rank_active != 0) ORDER BY pa.published_at LIMIT $2 FOR UPDATE SKIP LOCKED) UPDATE post pa SET hot_rank = r.hot_rank(pa.score, pa.published_at), hot_rank_active = r.hot_rank(pa.score, coalesce(pa.newest_comment_time_necro_at, pa.published_at)), scaled_rank = r.scaled_rank(pa.score, pa.published_at, ca.interactions_month) FROM batch, community ca WHERE pa.id = batch.id AND pa.community_id = ca.id RETURNING pa.published_at; "#, ) .bind::(previous_batch_last_published) .bind::(DB_BATCH_SIZE) .get_results::(conn) .await .map_err(|e| { LemmyErrorType::Unknown(format!("Failed to update post_aggregates hot_ranks: {}", e)) })?; processed_rows_count += updated_rows.len(); previous_batch_result = updated_rows.last().map(|row| row.published_at); } info!( "Finished process_hot_ranks_in_batches execution for {} (processed {} rows)", "post_aggregates", processed_rows_count ); Ok(()) } /// Clear old activities (this table gets very large) async fn clear_old_activities(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Clearing old activities..."); let conn = &mut get_conn(pool).await?; diesel::delete( sent_activity::table.filter(sent_activity::published_at.lt(now() - IntervalDsl::days(7))), ) .execute(conn) .await?; diesel::delete( received_activity::table .filter(received_activity::published_at.lt(now() - IntervalDsl::days(7))), ) .execute(conn) .await?; info!("Done."); Ok(()) } async fn delete_old_denied_users(pool: &mut DbPool<'_>) -> LemmyResult<()> { LocalUser::delete_old_denied_local_users(pool).await?; info!("Done."); Ok(()) } /// overwrite posts and comments 30d after deletion async fn overwrite_deleted_posts_and_comments(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Overwriting deleted posts..."); let conn = &mut get_conn(pool).await?; diesel::update( post::table .filter(post::deleted.eq(true)) .filter(post::updated_at.lt(now().nullable() - 1.months())) .filter(post::body.ne(DELETED_REPLACEMENT_TEXT)), ) .set(( post::body.eq(DELETED_REPLACEMENT_TEXT), post::name.eq(DELETED_REPLACEMENT_TEXT), )) .execute(conn) .await?; info!("Overwriting deleted comments..."); diesel::update( comment::table .filter(comment::deleted.eq(true)) .filter(comment::updated_at.lt(now().nullable() - 1.months())) .filter(comment::content.ne(DELETED_REPLACEMENT_TEXT)), ) .set(comment::content.eq(DELETED_REPLACEMENT_TEXT)) .execute(conn) .await?; info!("Done."); Ok(()) } const ONE_DAY: (&str, &str) = ("1 day", "day"); const ONE_WEEK: (&str, &str) = ("1 week", "week"); const ONE_MONTH: (&str, &str) = ("1 month", "month"); const SIX_MONTHS: (&str, &str) = ("6 months", "half_year"); const ALL_ACTIVE_INTERVALS: [(&str, &str); 4] = [ONE_DAY, ONE_WEEK, ONE_MONTH, SIX_MONTHS]; #[derive(QueryableByName)] struct SiteActivitySelectResult { #[diesel(sql_type = Integer)] site_aggregates_activity: i32, } #[derive(QueryableByName)] struct CommunityAggregatesUpdateResult { #[diesel(sql_type = Integer)] community_id: i32, } /// Re-calculate the site and community active counts for a given interval async fn active_counts(pool: &mut DbPool<'_>, interval: (&str, &str)) -> LemmyResult<()> { info!( "Updating active site and community aggregates for {}...", interval.0 ); let conn = &mut get_conn(pool).await?; process_site_aggregates(conn, interval).await?; process_community_aggregates( conn, interval, "users_active", "community_aggregates_activity", ) .await?; Ok(()) } /// Re-calculate all the active counts async fn all_active_counts(pool: &mut DbPool<'_>) -> LemmyResult<()> { for i in ALL_ACTIVE_INTERVALS { active_counts(pool, i).await?; } let conn = &mut get_conn(pool).await?; process_community_aggregates( conn, ONE_MONTH, "interactions", "community_aggregates_interactions", ) .await?; Ok(()) } async fn process_site_aggregates( conn: &mut AsyncPgConnection, interval: (&str, &str), ) -> LemmyResult<()> { // Select the site count result first let site_activity = sql_query(format!( "select * from r.site_aggregates_activity('{}')", interval.0 )) .get_result::(conn) .await .inspect_err(|e| warn!("Failed to fetch site activity: {e}"))?; let processed_rows = site_activity.site_aggregates_activity; // Update the site count sql_query(format!( "update local_site set users_active_{} = $1", interval.1, )) .bind::(processed_rows) .execute(conn) .await .inspect_err(|e| warn!("Failed to update site stats: {e}")) .ok(); info!( "Finished site_aggregates active_{} (processed {} rows)", interval.1, processed_rows ); Ok(()) } async fn process_community_aggregates( conn: &mut AsyncPgConnection, interval: (&str, &str), field_name_prefix: &str, function_name: &str, ) -> LemmyResult<()> { // Select the community count results into a temp table. let caggs_temp_table = &format!("community_aggregates_temp_table_{}", interval.1); // Drop temp table before and after, just in case let drop_caggs_temp_table = &format!("DROP TABLE IF EXISTS {caggs_temp_table}"); sql_query(drop_caggs_temp_table).execute(conn).await.ok(); sql_query(format!( "CREATE TEMP TABLE {caggs_temp_table} AS SELECT * FROM r.{function_name}('{}')", interval.0 )) .execute(conn) .await .inspect_err(|e| warn!("Failed to create temp community_aggregates table: {e}"))?; // Split up into 1000 community transaction batches let update_batch_size = 1000; let mut processed_rows_count = 0; let mut prev_community_id_res = Some(0); while let Some(prev_community_id) = prev_community_id_res { let updated_rows = sql_query(format!( "UPDATE community a SET {field_name_prefix}_{} = b.count_ FROM ( SELECT count_, community_id_ FROM {caggs_temp_table} WHERE community_id_ > $1 ORDER BY community_id_ LIMIT $2 ) AS b WHERE a.id = b.community_id_ RETURNING a.id AS community_id ", interval.1 )) .bind::(prev_community_id) .bind::(update_batch_size) .get_results::(conn) .await .inspect_err(|e| warn!("Failed to update community stats: {e}"))?; processed_rows_count += updated_rows.len(); prev_community_id_res = updated_rows.last().map(|row| row.community_id); } // Drop the temp table just in case sql_query(drop_caggs_temp_table).execute(conn).await.ok(); info!( "Finished community_aggregates {field_name_prefix}_{} (processed {} rows)", interval.1, processed_rows_count ); info!("Done."); Ok(()) } async fn update_local_user_count(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Updating the local user count..."); let conn = &mut get_conn(pool).await?; let user_count = local_user::table .inner_join( person::table.left_join( instance_actions::table .inner_join(instance::table.inner_join(site::table.inner_join(local_site::table))), ), ) // only count approved users .filter(local_user::accepted_application) // ignore banned and deleted accounts .filter(instance_actions::received_ban_at.is_null()) .filter(not(person::deleted)) .select(count(local_user::id)) .first::(conn) .await .map(i32::try_from)??; update(local_site::table) .set(local_site::users.eq(user_count)) .execute(conn) .await?; info!("Done."); Ok(()) } /// Set banned to false after ban expires async fn update_banned_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Updating banned column if it expires ..."); let conn = &mut get_conn(pool).await?; uplete(community_actions::table.filter(community_actions::ban_expires_at.lt(now().nullable()))) .set_null(community_actions::received_ban_at) .set_null(community_actions::ban_expires_at) .as_query() .execute(conn) .await?; uplete(instance_actions::table.filter(instance_actions::ban_expires_at.lt(now().nullable()))) .set_null(instance_actions::received_ban_at) .set_null(instance_actions::ban_expires_at) .as_query() .execute(conn) .await?; Ok(()) } /// Set banned to false after ban expires async fn delete_instance_block_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Delete instance blocks when expired ..."); let conn = &mut get_conn(pool).await?; diesel::delete( federation_blocklist::table.filter(federation_blocklist::expires_at.lt(now().nullable())), ) .execute(conn) .await?; Ok(()) } /// Find all unpublished posts with scheduled date in the future, and publish them. async fn publish_scheduled_posts(context: &Data) -> LemmyResult<()> { let pool = &mut context.pool(); let local_instance_id = SiteView::read_local(pool).await?.instance.id; let conn = &mut get_conn(pool).await?; let not_community_banned_action = community_actions::table .find((person::id, community::id)) .filter(community_actions::received_ban_at.is_not_null()); let not_local_banned_action = instance_actions::table .find((person::id, local_instance_id)) .filter(instance_actions::received_ban_at.is_not_null()); let scheduled_posts: Vec<_> = post::table .inner_join(community::table) .inner_join(person::table) // find all posts which have scheduled_publish_time that is in the past .filter(post::scheduled_publish_time_at.is_not_null()) .filter(coalesce(post::scheduled_publish_time_at, now()).lt(now())) // make sure the post, person and community are still around .filter(not(post::deleted.or(post::removed))) .filter(not(person::deleted)) .filter(not(community::removed.or(community::deleted))) // ensure that user isnt banned from community .filter(not(exists(not_community_banned_action))) // ensure that user isnt banned from local .filter(not(exists(not_local_banned_action))) .select((Post::as_select(), Community::as_select())) .get_results::<(Post, Community)>(conn) .await?; for (post, community) in scheduled_posts { // mark post as published in db let form = PostUpdateForm { scheduled_publish_time_at: Some(None), ..Default::default() }; Post::update(&mut context.pool(), post.id, &form).await?; // send out post via federation and webmention let send_activity = SendActivityData::CreatePost(post.clone()); ActivityChannel::submit_activity(send_activity, context)?; send_webmention(post, &community); } Ok(()) } /// Updates the instance software and version. /// /// Does so using the /.well-known/nodeinfo protocol described here: /// https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md /// /// TODO: if instance has been dead for a long time, it should be checked less frequently async fn update_instance_software( pool: &mut DbPool<'_>, client: &ClientWithMiddleware, ) -> LemmyResult<()> { info!("Updating instances software and versions..."); let conn = &mut get_conn(pool).await?; let instances = instance::table.get_results::(conn).await?; for instance in instances { if let Some(form) = build_update_instance_form(&instance.domain, client).await { Instance::update(pool, instance.id, form).await?; } } info!("Finished updating instances software and versions..."); Ok(()) } /// This builds an instance update form, for a given domain. /// If the instance sends a response, but doesn't have a well-known or nodeinfo, /// Then return a default form with only the updated field. async fn build_update_instance_form( domain: &str, client: &ClientWithMiddleware, ) -> Option { // The `updated` column is used to check if instances are alive. If it is more than three // days in the past, no outgoing activities will be sent to that instance. However // not every Fediverse instance has a valid Nodeinfo endpoint (its not required for // Activitypub). That's why we always need to mark instances as updated if they are // alive. let mut instance_form = InstanceForm { updated_at: Some(Utc::now()), ..InstanceForm::new(domain.to_string()) }; // First, fetch their /.well-known/nodeinfo, then extract the correct nodeinfo link from it let well_known_url = format!("https://{}/.well-known/nodeinfo", domain); let Ok(res) = client.get(&well_known_url).send().await else { // This is the only kind of error that means the instance is dead return None; }; let status = res.status(); if status.is_client_error() || status.is_server_error() { return None; } // In this block, returning `None` is ignored, and only means not writing nodeinfo to db async { let node_info_url = res .json::() .await .ok()? .links .into_iter() .find(|links| { links .rel .as_str() .starts_with("http://nodeinfo.diaspora.software/ns/schema/2.") })? .href; let software = client .get(node_info_url) .send() .await .ok()? .json::() .await .ok()? .software?; instance_form.software = software.name; instance_form.version = software.version; Some(()) } .await; Some(instance_form) } #[cfg(test)] mod tests { use super::*; use lemmy_api_utils::request::client_builder; use lemmy_db_schema::{ source::{ community::{Community, CommunityInsertForm}, person::{Person, PersonInsertForm}, post::{Post, PostActions, PostInsertForm, PostLikeForm}, }, test_data::TestData, traits::Likeable, }; use lemmy_diesel_utils::traits::Crud; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use pretty_assertions::assert_eq; use reqwest_middleware::ClientBuilder; use serial_test::serial; #[tokio::test] async fn test_nodeinfo_lemmy_ml() -> LemmyResult<()> { let client = ClientBuilder::new(client_builder(&Settings::default()).build()?).build(); let form = build_update_instance_form("lemmy.ml", &client) .await .ok_or(LemmyErrorType::NotFound)?; assert_eq!(form.software.ok_or(LemmyErrorType::NotFound)?, "lemmy"); Ok(()) } #[tokio::test] async fn test_nodeinfo_mastodon_social() -> LemmyResult<()> { let client = ClientBuilder::new(client_builder(&Settings::default()).build()?).build(); let form = build_update_instance_form("mastodon.social", &client) .await .ok_or(LemmyErrorType::NotFound)?; assert_eq!(form.software.ok_or(LemmyErrorType::NotFound)?, "mastodon"); Ok(()) } #[tokio::test] #[serial] async fn test_scheduled_tasks() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let data = TestData::create(pool).await?; let community = Community::create( pool, &CommunityInsertForm::new( data.instance.id, "name".to_owned(), "title".to_owned(), "pubkey".to_owned(), ), ) .await?; let person = Person::create( pool, &PersonInsertForm::new("felicity".to_owned(), "pubkey".to_owned(), data.instance.id), ) .await?; let post = Post::create( pool, &PostInsertForm::new("i am grrreat".to_owned(), person.id, community.id), ) .await?; PostActions::like(pool, &PostLikeForm::new(post.id, person.id, Some(true))).await?; active_counts(pool, ONE_DAY).await?; all_active_counts(pool).await?; update_local_user_count(pool).await?; update_hot_ranks(pool).await?; update_banned_when_expired(pool).await?; delete_instance_block_when_expired(pool).await?; clear_old_activities(pool).await?; overwrite_deleted_posts_and_comments(pool).await?; delete_old_denied_users(pool).await?; update_instance_software(pool, context.client()).await?; publish_scheduled_posts(&context).await?; let community_after = Community::read(pool, community.id).await?; assert_eq!( community_after, Community { posts: 1, users_active_day: 1, users_active_week: 1, users_active_month: 1, users_active_half_year: 1, interactions_month: 1, ..community_after.clone() } ); data.delete(pool).await?; Ok(()) } } ================================================ FILE: crates/routes/src/utils/setup_local_site.rs ================================================ use activitypub_federation::http_signatures::generate_actor_keypair; use chrono::Utc; use diesel::{ dsl::{exists, not, select}, query_builder::AsQuery, }; use diesel_async::{RunQueryDsl, scoped_futures::ScopedFutureExt}; use lemmy_api_utils::utils::generate_inbox_url; use lemmy_db_schema::{ source::{ instance::Instance, local_site::{LocalSite, LocalSiteInsertForm}, local_site_rate_limit::{LocalSiteRateLimit, LocalSiteRateLimitInsertForm}, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, site::{Site, SiteInsertForm}, }, traits::ApubActor, }; use lemmy_db_schema_file::schema::local_site; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::{ connection::{DbPool, get_conn}, sensitive::SensitiveString, traits::Crud, }; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, settings::structs::Settings, }; use rand::{RngExt, distr::Alphanumeric}; use tracing::info; use url::Url; pub async fn setup_local_site(pool: &mut DbPool<'_>, settings: &Settings) -> LemmyResult { let conn = &mut get_conn(pool).await?; // Check to see if local_site exists, without the cache wrapper if select(not(exists(local_site::table.as_query()))) .get_result(conn) .await? { info!("No Local Site found, creating it."); let domain = settings .get_hostname_without_port() .with_lemmy_type(LemmyErrorType::Unknown("must have domain".into()))?; conn .run_transaction(|conn| { async move { // Upsert this to the instance table let instance = Instance::read_or_create(&mut conn.into(), &domain).await?; if let Some(setup) = &settings.setup { let person_keypair = generate_actor_keypair()?; let person_ap_id = Person::generate_local_actor_url(&setup.admin_username, settings)?; // Register the user if there's a site setup let person_form = PersonInsertForm { ap_id: Some(person_ap_id.clone()), inbox_url: Some(generate_inbox_url()?), private_key: Some(person_keypair.private_key), ..PersonInsertForm::new( setup.admin_username.clone(), person_keypair.public_key, instance.id, ) }; let person_inserted = Person::create(&mut conn.into(), &person_form).await?; let local_user_form = LocalUserInsertForm { email: setup.admin_email.clone(), admin: Some(true), ..LocalUserInsertForm::new(person_inserted.id, Some(setup.admin_password.clone())) }; LocalUser::create(&mut conn.into(), &local_user_form, vec![]).await?; }; // Add an entry for the site table let site_key_pair = generate_actor_keypair()?; let site_ap_id = Url::parse(&settings.get_protocol_and_hostname())?; let name = settings .setup .clone() .map(|s| s.site_name) .unwrap_or_else(|| "New Site".to_string()); let site_form = SiteInsertForm { ap_id: Some(site_ap_id.clone().into()), last_refreshed_at: Some(Utc::now()), inbox_url: Some(generate_inbox_url()?), private_key: Some(site_key_pair.private_key), public_key: Some(site_key_pair.public_key), ..SiteInsertForm::new(name, instance.id) }; let site = Site::create(&mut conn.into(), &site_form).await?; // create multi-comm follower account let r: String = rand::rng() .sample_iter(&Alphanumeric) .take(14) .map(char::from) .collect(); let name = format!("lemmy_{}", r); let form = PersonInsertForm { private_key: site.private_key.map(SensitiveString::into_inner), inbox_url: Some(site.inbox_url), bot_account: Some(true), ..PersonInsertForm::new(name, site.public_key, instance.id) }; let system_account = Person::create(&mut conn.into(), &form).await?; // Finally create the local_site row let local_site_form = LocalSiteInsertForm { site_setup: Some(settings.setup.is_some()), system_account: Some(system_account.id), ..LocalSiteInsertForm::new(site.id) }; let local_site = LocalSite::create(&mut conn.into(), &local_site_form).await?; // Create the rate limit table let local_site_rate_limit_form = LocalSiteRateLimitInsertForm::new(local_site.id); LocalSiteRateLimit::create(&mut conn.into(), &local_site_rate_limit_form).await?; Ok(()) } .scope_boxed() }) .await?; } SiteView::read_local(pool).await } ================================================ FILE: crates/routes/src/webfinger.rs ================================================ use activitypub_federation::{ config::Data, fetch::webfinger::{WEBFINGER_CONTENT_TYPE, Webfinger, WebfingerLink, extract_webfinger_name}, }; use actix_web::{HttpResponse, web, web::Query}; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::{ source::{community::Community, person::Person}, traits::ApubActor, }; use lemmy_utils::{ cache_header::cache_3days, error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, }; use serde::Deserialize; use std::collections::HashMap; use url::Url; #[derive(Deserialize)] struct Params { resource: String, } pub fn config(cfg: &mut web::ServiceConfig) { cfg.route( ".well-known/webfinger", web::get().to(get_webfinger_response).wrap(cache_3days()), ); } /// Responds to webfinger requests of the following format. There isn't any real documentation for /// this, but it described in this blog post: /// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social /// /// You can also view the webfinger response that Mastodon sends: /// https://radical.town/.well-known/webfinger?resource=acct:felix@radical.town async fn get_webfinger_response( info: Query, context: Data, ) -> LemmyResult { let name = extract_webfinger_name(&info.resource, &context)?; let links = if name == context.settings().hostname { // webfinger response for instance actor (required for mastodon authorized fetch) let url = Url::parse(&context.settings().get_protocol_and_hostname())?; vec![webfinger_link_for_actor(Some(url), "none", &context)?] } else { // webfinger response for user/community let user_id: Option = Person::read_from_name(&mut context.pool(), name, None, false) .await .ok() .flatten() .map(|c| c.ap_id.into()); let community_id: Option = Community::read_from_name(&mut context.pool(), name, None, false) .await .ok() .flatten() .and_then(|c| { c.visibility.can_federate().then(|| { let id: Url = c.ap_id.into(); id }) }); // NOTE: Do not change the order of these items! // Mastodon seems to prioritize the last webfinger item in case of duplicates. Put // community last so that it gets prioritized. // Lemmy also relies on this specific order, so in case a resolve for `reddit@lemmy.world` // gives both user and community, the community is returned (also necessary for remote follow). vec![ webfinger_link_for_actor(user_id, "Person", &context)?, webfinger_link_for_actor(community_id, "Group", &context)?, ] } .into_iter() .flatten() .collect::>(); if links.is_empty() { Ok(HttpResponse::NotFound().finish()) } else { let json = Webfinger { subject: info.resource.clone(), links, ..Default::default() }; Ok( HttpResponse::Ok() .content_type(WEBFINGER_CONTENT_TYPE.as_bytes()) .json(json), ) } } fn webfinger_link_for_actor( url: Option, kind: &str, context: &LemmyContext, ) -> LemmyResult> { if let Some(url) = url { let type_key = "https://www.w3.org/ns/activitystreams#type" .parse() .with_lemmy_type(LemmyErrorType::InvalidUrl)?; let mut vec = vec![ WebfingerLink { rel: Some("http://webfinger.net/rel/profile-page".into()), kind: Some("text/html".into()), href: Some(url.clone()), ..Default::default() }, WebfingerLink { rel: Some("self".into()), kind: Some("application/activity+json".into()), href: Some(url), properties: HashMap::from([(type_key, kind.into())]), ..Default::default() }, ]; // insert remote follow link if kind == "Person" { let template = format!( "{}/activitypub/externalInteraction?uri={{uri}}", context.settings().get_protocol_and_hostname() ); vec.push(WebfingerLink { rel: Some("http://ostatus.org/schema/1.0/subscribe".into()), template: Some(template), ..Default::default() }); } Ok(vec) } else { Ok(vec![]) } } ================================================ FILE: crates/server/Cargo.toml ================================================ [package] name = "lemmy_server" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true publish = false default-run = "lemmy_server" [lib] test = false doctest = false [lints] workspace = true [features] default = [] [dependencies] lemmy_api = { workspace = true } lemmy_api_routes = { workspace = true } lemmy_api_routes_v3 = { workspace = true } lemmy_apub = { workspace = true } lemmy_apub_activities = { workspace = true } lemmy_apub_objects = { workspace = true } lemmy_utils = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_diesel_utils = { workspace = true } lemmy_api_utils = { workspace = true } lemmy_routes = { workspace = true } lemmy_apub_send = { workspace = true } lemmy_db_views_site = { workspace = true } activitypub_federation = { workspace = true } actix-web = { workspace = true } tracing = { workspace = true } tracing-actix-web = { workspace = true } tracing-subscriber = { workspace = true } reqwest-middleware = { workspace = true } reqwest-tracing = { workspace = true } serde_json = { workspace = true } tokio.workspace = true clap = { workspace = true } [target.'cfg(target_arch = "x86_64")'.dependencies] mimalloc = "0.1.48" ================================================ FILE: crates/server/src/lib.rs ================================================ use activitypub_federation::config::{FederationConfig, FederationMiddleware}; use actix_web::{ App, HttpResponse, HttpServer, dev::{ServerHandle, ServiceResponse}, middleware::{self, Condition, ErrorHandlerResponse, ErrorHandlers}, web::{Data, get, scope}, }; use clap::{Parser, Subcommand}; use lemmy_api::sitemap::get_sitemap; use lemmy_api_utils::{ context::LemmyContext, request::client_builder, send_activity::ActivityChannel, utils::local_site_rate_limit_to_rate_limit_config, }; use lemmy_apub::{ FEDERATION_HTTP_FETCH_LIMIT, VerifyUrlData, collections::fetch_community_collections, }; use lemmy_apub_activities::handle_outgoing_activities; use lemmy_apub_objects::objects::{community::FETCH_COMMUNITY_COLLECTIONS, instance::ApubSite}; use lemmy_apub_send::{Opts, SendManager}; use lemmy_db_schema::source::secret::Secret; use lemmy_db_views_site::SiteView; use lemmy_diesel_utils::connection::build_db_pool; use lemmy_routes::{ feeds, middleware::{ idempotency::{IdempotencyMiddleware, IdempotencySet}, session::SessionMiddleware, }, nodeinfo, utils::{ cors_config, prometheus_metrics::{new_prometheus_metrics, serve_prometheus}, scheduled_tasks, setup_local_site::setup_local_site, }, webfinger, }; use lemmy_utils::{ VERSION, error::{LemmyErrorType, LemmyResult}, rate_limit::RateLimit, response::jsonify_plain_text_errors, settings::{SETTINGS, structs::Settings}, }; use reqwest_middleware::ClientBuilder; use reqwest_tracing::TracingMiddleware; use serde_json::json; use std::{ops::Deref, time::Duration}; use tokio::signal::unix::SignalKind; use tracing_actix_web::{DefaultRootSpanBuilder, TracingLogger}; #[cfg_attr(target_arch = "x86_64", global_allocator)] #[cfg(target_arch = "x86_64")] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; /// Timeout for HTTP requests while sending activities. A longer timeout provides better /// compatibility with other ActivityPub software that might allocate more time for synchronous /// processing of incoming activities. This timeout should be slightly longer than the time we /// expect a remote server to wait before aborting processing on its own to account for delays from /// establishing the HTTP connection and sending the request itself. const ACTIVITY_SENDING_TIMEOUT: Duration = Duration::from_secs(125); #[derive(Parser, Debug)] #[command( version, about = "A link aggregator for the fediverse", long_about = "A link aggregator for the fediverse.\n\nThis is the Lemmy backend API server. This will connect to a PostgreSQL database, run any pending migrations and start accepting API requests." )] // TODO: Instead of defining individual env vars, only specify prefix once supported by clap. // https://github.com/clap-rs/clap/issues/3221 pub struct CmdArgs { /// Don't run scheduled tasks. /// /// If you are running multiple Lemmy server processes, you probably want to disable scheduled /// tasks on all but one of the processes, to avoid running the tasks more often than intended. #[arg(long, default_value_t = false, env = "LEMMY_DISABLE_SCHEDULED_TASKS")] disable_scheduled_tasks: bool, /// Disables the HTTP server. /// /// This can be used to run a Lemmy server process that only performs scheduled tasks or activity /// sending. #[arg(long, default_value_t = false, env = "LEMMY_DISABLE_HTTP_SERVER")] disable_http_server: bool, /// Disable sending outgoing ActivityPub messages. /// /// Only pass this for horizontally scaled setups. /// See https://join-lemmy.org/docs/administration/horizontal_scaling.html for details. #[arg(long, default_value_t = false, env = "LEMMY_DISABLE_ACTIVITY_SENDING")] disable_activity_sending: bool, /// The index of this outgoing federation process. /// /// Defaults to 1/1. If you want to split the federation workload onto n servers, run each server /// 1≤i≤n with these args: --federate-process-index i --federate-process-count n /// /// Make you have exactly one server with each `i` running, otherwise federation will randomly /// send duplicates or nothing. /// /// See https://join-lemmy.org/docs/administration/horizontal_scaling.html for more detail. #[arg(long, default_value_t = 1, env = "LEMMY_FEDERATE_PROCESS_INDEX")] federate_process_index: i32, /// How many outgoing federation processes you are starting in total. /// /// If set, make sure to set --federate-process-index differently for each. #[arg(long, default_value_t = 1, env = "LEMMY_FEDERATE_PROCESS_COUNT")] federate_process_count: i32, #[command(subcommand)] subcommand: Option, } #[derive(Subcommand, Debug)] enum CmdSubcommand { /// Do something with migrations, then exit. Migration { #[command(subcommand)] subcommand: MigrationSubcommand, /// Stop after there's no remaining migrations. #[arg(long, default_value_t = false)] all: bool, /// Stop after the given number of migrations. #[arg(long, default_value_t = 1)] number: u64, }, } #[derive(Subcommand, Debug, PartialEq, Eq)] enum MigrationSubcommand { /// Run up.sql for pending migrations, oldest to newest. Run, /// Run down.sql for non-pending migrations, newest to oldest. Revert, } /// Placing the main function in lib.rs allows other crates to import it and embed Lemmy pub async fn start_lemmy_server(args: CmdArgs) -> LemmyResult<()> { if let Some(CmdSubcommand::Migration { subcommand, all, number, }) = args.subcommand { let mut options = match subcommand { MigrationSubcommand::Run => lemmy_diesel_utils::schema_setup::Options::default().run(), MigrationSubcommand::Revert => lemmy_diesel_utils::schema_setup::Options::default().revert(), } .print_output(); if !all { options = options.limit(number); } lemmy_diesel_utils::schema_setup::run(options, &SETTINGS.get_database_url_with_options()?)?; #[cfg(debug_assertions)] if all && subcommand == MigrationSubcommand::Run { println!( "Warning: you probably want this command instead, which requires less crates to be compiled: cargo run --package lemmy_diesel_utils" ); } return Ok(()); } // Print version number to log println!("Starting Lemmy v{}", *VERSION); // return error 503 while running db migrations and startup tasks let mut startup_server_handle = None; if !args.disable_http_server { startup_server_handle = Some(create_startup_server()?); } // Set up the connection pool let pool = build_db_pool()?; // Initialize the secrets let secret = Secret::init(&mut (&pool).into()).await?; // Make sure the local site is set up. let site_view = setup_local_site(&mut (&pool).into(), &SETTINGS).await?; let federation_enabled = site_view.local_site.federation_enabled; if federation_enabled { println!("Federation enabled, host is {}", &SETTINGS.hostname); } // Set up the rate limiter let rate_limit_config = local_site_rate_limit_to_rate_limit_config(&site_view.local_site_rate_limit); let rate_limit_cell = RateLimit::new(rate_limit_config); println!( "Starting HTTP server at {}:{}", SETTINGS.bind, SETTINGS.port ); let client = ClientBuilder::new(client_builder(&SETTINGS).build()?) .with(TracingMiddleware::default()) .build(); let pictrs_client = ClientBuilder::new(client_builder(&SETTINGS).no_proxy().build()?) .with(TracingMiddleware::default()) .build(); let context = LemmyContext::create( pool.clone(), client.clone(), pictrs_client, secret.clone(), rate_limit_cell, ); if let Some(prometheus) = SETTINGS.prometheus.clone() { serve_prometheus(prometheus, context.clone())?; } let mut federation_config_builder = FederationConfig::builder(); federation_config_builder .domain(SETTINGS.hostname.clone()) .app_data(context.clone()) .client(client.clone()) .http_fetch_limit(FEDERATION_HTTP_FETCH_LIMIT) .debug(cfg!(debug_assertions)) .http_signature_compat(true) .url_verifier(Box::new(VerifyUrlData(context.inner_pool().clone()))); if site_view.local_site.federation_signed_fetch { let site: ApubSite = site_view.site.clone().into(); federation_config_builder.signed_fetch_actor(&site); } let federation_config = federation_config_builder.build().await?; FETCH_COMMUNITY_COLLECTIONS .set(fetch_community_collections) .map_err(|_e| LemmyErrorType::Unknown("couldnt set function pointer".into()))?; let request_data = federation_config.to_request_data(); let outgoing_activities_task = tokio::task::spawn(handle_outgoing_activities(request_data.clone())); if !args.disable_scheduled_tasks { // Schedules various cleanup tasks for the DB let _scheduled_tasks = tokio::task::spawn(scheduled_tasks::setup(request_data.clone())); } let server = if !args.disable_http_server { if let Some(startup_server_handle) = startup_server_handle { startup_server_handle.stop(true).await; } Some(create_http_server( federation_config.clone(), SETTINGS.clone(), site_view, )?) } else { None }; // This FederationConfig instance is exclusively used to send activities, so we can safely // increase the timeout without affecting timeouts for resolving objects anywhere. let federation_sender_config = if !args.disable_activity_sending { let mut federation_sender_config = federation_config_builder.clone(); federation_sender_config.request_timeout(ACTIVITY_SENDING_TIMEOUT); Some(federation_sender_config.build().await?) } else { None }; let federate = federation_sender_config.map(|cfg| { SendManager::run( Opts { process_index: args.federate_process_index, process_count: args.federate_process_count, }, cfg, SETTINGS.federation.clone(), ) }); let mut interrupt = tokio::signal::unix::signal(SignalKind::interrupt())?; let mut terminate = tokio::signal::unix::signal(SignalKind::terminate())?; tokio::select! { _ = tokio::signal::ctrl_c() => { tracing::warn!("Received ctrl-c, shutting down gracefully..."); } _ = interrupt.recv() => { tracing::warn!("Received interrupt, shutting down gracefully..."); } _ = terminate.recv() => { tracing::warn!("Received terminate, shutting down gracefully..."); } } if let Some(server) = server { server.stop(true).await; } if let Some(federate) = federate { federate.cancel().await?; } // Wait for outgoing apub sends to complete ActivityChannel::close(outgoing_activities_task).await?; Ok(()) } /// Creates temporary HTTP server which returns status 503 for all requests. fn create_startup_server() -> LemmyResult { let startup_server = HttpServer::new(move || { App::new().wrap(ErrorHandlers::new().default_handler(move |req| { let (req, _) = req.into_parts(); let response = HttpResponse::ServiceUnavailable().json(json!({"error": "Lemmy is currently starting"})); let service_response = ServiceResponse::new(req, response); Ok(ErrorHandlerResponse::Response( service_response.map_into_right_body(), )) })) }) .bind((SETTINGS.bind, SETTINGS.port))? .run(); let startup_server_handle = startup_server.handle(); tokio::task::spawn(startup_server); Ok(startup_server_handle) } fn create_http_server( federation_config: FederationConfig, settings: Settings, site_view: SiteView, ) -> LemmyResult { // These must come before HttpServer creation so they can collect data across threads. let prom_api_metrics = new_prometheus_metrics()?; let idempotency_set = IdempotencySet::default(); // Create Http server let bind = (settings.bind, settings.port); let server = HttpServer::new(move || { let context: LemmyContext = federation_config.deref().clone(); let rate_limit = federation_config.rate_limit_cell().clone(); let cors_config = cors_config(&settings); let app = App::new() .wrap(ErrorHandlers::new().default_handler(jsonify_plain_text_errors)) .wrap(middleware::Logger::new( // This is the default log format save for the usage of %{r}a over %a to guarantee to // record the client's (forwarded) IP and not the last peer address, since the latter is // frequently just a reverse proxy "%{r}a '%r' %s %b '%{Referer}i' '%{User-Agent}i' %T", )) .wrap(middleware::Compress::default()) .wrap(cors_config) .wrap(TracingLogger::::new()) .app_data(Data::new(context.clone())) .wrap(FederationMiddleware::new(federation_config.clone())) .wrap(IdempotencyMiddleware::new(idempotency_set.clone())) .wrap(SessionMiddleware::new(context.clone())) .wrap(Condition::new( SETTINGS.prometheus.is_some(), prom_api_metrics.clone(), )); // The routes app .configure(|cfg| lemmy_api_routes::config(cfg, &rate_limit)) .configure(|cfg| lemmy_api_routes_v3::config(cfg, &rate_limit)) .configure(|cfg| { if site_view.local_site.federation_enabled { lemmy_apub::http::routes::config(cfg); webfinger::config(cfg); } }) .configure(feeds::config) .configure(nodeinfo::config) .service( scope("/sitemap.xml") .wrap(rate_limit.message()) .route("", get().to(get_sitemap)), ) }) .disable_signals() .bind(bind)? .run(); let handle = server.handle(); tokio::task::spawn(server); Ok(handle) } ================================================ FILE: crates/server/src/main.rs ================================================ use clap::Parser; use lemmy_server::{CmdArgs, start_lemmy_server}; use lemmy_utils::{error::LemmyResult, settings::SETTINGS}; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; #[tokio::main] pub async fn main() -> LemmyResult<()> { let filter = EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(); if SETTINGS.json_logging { tracing_subscriber::fmt() .with_env_filter(filter) .json() .init(); } else { tracing_subscriber::fmt().with_env_filter(filter).init(); } let args = CmdArgs::parse(); start_lemmy_server(args).await?; Ok(()) } ================================================ FILE: crates/utils/Cargo.toml ================================================ [package] name = "lemmy_utils" version.workspace = true edition.workspace = true description.workspace = true license.workspace = true homepage.workspace = true documentation.workspace = true repository.workspace = true rust-version.workspace = true [lib] name = "lemmy_utils" path = "src/lib.rs" doctest = false [[bin]] name = "lemmy_util_bin" path = "src/main.rs" required-features = ["full"] [lints] workspace = true [features] full = [ "diesel", "actix-web", "tracing", "actix-web", "serde_json", "anyhow", "http", "deser-hjson", "regex", "urlencoding", "doku", "url", "smart-default", "enum-map", "futures", "tokio", "itertools", "markdown-it", "moka", "actix-extensible-rate-limit", "dashmap", ] ts-rs = ["dep:ts-rs"] [package.metadata.cargo-shear] ignored = ["http"] [dependencies] regex = { workspace = true, optional = true } tracing = { workspace = true, optional = true } itertools = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true, optional = true } url = { workspace = true, optional = true } actix-web = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } strum = { workspace = true } futures = { workspace = true, optional = true } diesel = { workspace = true, optional = true } http = { workspace = true, optional = true } doku = { workspace = true, features = ["url-2"], optional = true } tokio = { workspace = true, optional = true } urlencoding = { workspace = true, optional = true } deser-hjson = { version = "2.2.5", optional = true } smart-default = { version = "0.7.1", optional = true } markdown-it = { version = "0.6.1", optional = true } ts-rs = { workspace = true, optional = true } enum-map = { version = "2.7", optional = true } chrono = { workspace = true } cfg-if = { workspace = true } clearurls = { version = "0.0.4", features = ["linkify"] } markdown-it-block-spoiler = "1.0.3" markdown-it-sub = "1.0.2" markdown-it-sup = "1.0.2" markdown-it-ruby = "1.0.2" markdown-it-footnote = "0.2.0" moka = { workspace = true, optional = true } git-version = "0.3.9" unicode-segmentation = "1.12.0" invisible-characters = "0.1.5" actix-extensible-rate-limit = { version = "0.4.0", optional = true } dashmap = { version = "6.1.0", optional = true } serde_with = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } unified-diff = { workspace = true } tokio = { workspace = true, features = ["test-util"] } ================================================ FILE: crates/utils/src/cache_header.rs ================================================ use actix_web::middleware::DefaultHeaders; /// Adds a cache header to requests /// /// Common cache amounts are: /// * 1 hour = 60s * 60m = `3600` seconds /// * 3 days = 60s * 60m * 24h * 3d = `259200` seconds /// /// Mastodon & other activitypub server defaults to 3d fn cache_header(seconds: usize) -> DefaultHeaders { DefaultHeaders::new().add(("Cache-Control", format!("public, max-age={seconds}"))) } /// Set a 1 hour cache time pub fn cache_1hour() -> DefaultHeaders { cache_header(3600) } /// Set a 3 day cache time pub fn cache_3days() -> DefaultHeaders { cache_header(259200) } ================================================ FILE: crates/utils/src/error.rs ================================================ use cfg_if::cfg_if; use serde::{Deserialize, Serialize}; use std::{fmt::Debug, panic::Location}; use strum::{Display, EnumIter}; /// Errors used in the API, all of these are translated in lemmy-ui. #[derive(Display, Debug, Serialize, Deserialize, Clone, PartialEq, EnumIter, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] #[serde(tag = "error", content = "message", rename_all = "snake_case")] #[non_exhaustive] pub enum LemmyErrorType { BlockKeywordTooShort, BlockKeywordTooLong, CouldntUpdate, CouldntCreate, ReportReasonRequired, ReportTooLong, NotAModerator, NotAnAdmin, CantBlockYourself, CantNoteYourself, CantBlockAdmin, PasswordsDoNotMatch, EmailNotVerified, EmailRequired, CannotLeaveAdmin, CannotLeaveMod, PictrsResponseError(String), PictrsPurgeResponseError(String), PictrsApiKeyNotProvided, PictrsInvalidImageUpload(String), NoContentTypeHeader, NotAnImageType, ImageUploadDisabled, NotAModOrAdmin, NotTopMod, NotLoggedIn, NotHigherMod, NotHigherAdmin, SiteBan, Deleted, PersonIsBlocked, CommunityIsBlocked, InstanceIsBlocked, InstanceIsPrivate, /// Password must be between 10 and 60 characters InvalidPassword, SiteDescriptionLengthOverflow, HoneypotFailed, RegistrationApplicationIsPending, Locked, MaxCommentDepthReached, NoCommentEditAllowed, OnlyAdminsCanCreateCommunities, AlreadyExists, LanguageNotAllowed, NoPostEditAllowed, NsfwNotAllowed, EditPrivateMessageNotAllowed, ApplicationQuestionRequired, InvalidDefaultPostListingType, RegistrationClosed, RegistrationApplicationAnswerRequired, RegistrationUsernameRequired, EmailAlreadyTaken, UsernameAlreadyTaken, PersonIsBannedFromCommunity, NoIdGiven, IncorrectLogin, NoEmailSetup, LocalSiteNotSetup, InvalidEmailAddress(String), InvalidName, InvalidCodeVerifier, InvalidDisplayName, InvalidMatrixId, InvalidPostTitle, InvalidBodyField, BioLengthOverflow, AltTextLengthOverflow, CouldntParseTotpSecret, CouldntGenerateTotp, MissingTotpToken, MissingTotpSecret, IncorrectTotpToken, TotpAlreadyEnabled, BlockedUrl, InvalidUrl, EmailSendFailed, Slurs, RegistrationDenied(String), SiteNameRequired, SiteNameLengthOverflow, PermissiveRegex, InvalidRegex, InvalidUrlScheme, ContradictingFilters, /// Thrown when an API call is submitted with more than 1000 array elements, see /// [[MAX_API_PARAM_ELEMENTS]] TooManyItems, BanExpirationInPast, InvalidUnixTime, InvalidBotAction, TagNotInCommunity, CantBlockLocalInstance, Unknown(String), UrlLengthOverflow, OauthAuthorizationInvalid, OauthLoginFailed, OauthRegistrationClosed, NotFound, PostScheduleTimeMustBeInFuture, TooManyScheduledPosts, CannotCombineFederationBlocklistAndAllowlist, CouldntParsePaginationToken, PluginError(String), InvalidFetchLimit, EmailNotificationsDisabled, MultiCommunityUpdateWrongUser, CannotCombineCommunityIdAndMultiCommunityId, MultiCommunityEntryLimitReached, TooManyRequests, ResolveObjectFailed(String), #[serde(untagged)] #[cfg_attr(feature = "ts-rs", ts(skip))] UntranslatedError(Option), } /// These errors are only used for federation or internally and dont need to be translated. #[derive(Display, Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[serde(tag = "error", content = "message", rename_all = "snake_case")] #[non_exhaustive] pub enum UntranslatedError { InvalidCommunity, CannotCreatePostOrCommentInDeletedOrRemovedCommunity, CannotReceivePage, OnlyLocalAdminCanRemoveCommunity, OnlyLocalAdminCanRestoreCommunity, PostIsLocked, PersonIsBannedFromSite(String), InvalidVoteValue, PageDoesNotSpecifyCreator, FederationDisabled, DomainBlocked(String), DomainNotInAllowList(String), FederationDisabledByStrictAllowList, ContradictingFilters, UrlWithoutDomain, InboxTimeout, CantDeleteSite, ObjectIsNotPublic, ObjectIsNotPrivate, InvalidFollow(String), PurgeInvalidImageUrl, Unreachable, CouldntSendWebmention, /// A remote community sent an activity to us, but actually no local user follows the community /// so the activity was rejected. CommunityHasNoFollowers(String), } cfg_if! { if #[cfg(feature = "full")] { use std::fmt; use serde_with::serde_as; use serde_with::DisplayFromStr; pub type LemmyResult = Result; #[serde_as] #[derive(Serialize)] pub struct LemmyError { #[serde(flatten)] pub error_type: LemmyErrorType, #[serde_as(as = "DisplayFromStr")] pub cause: anyhow::Error, #[serde(skip)] pub caller: Location<'static>, } /// Maximum number of items in an array passed as API parameter. See [[LemmyErrorType::TooManyItems]] pub(crate) const MAX_API_PARAM_ELEMENTS: usize = 10_000; impl From for LemmyError where T: Into, { #[track_caller] fn from(t: T) -> Self { let cause = t.into(); let error_type = match cause.downcast_ref::() { Some(&diesel::NotFound) => LemmyErrorType::NotFound, _ => LemmyErrorType::Unknown(format!("{}", &cause)) }; LemmyError { error_type, cause, caller: *Location::caller(), } } } impl Debug for LemmyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LemmyError") .field("message", &self.error_type) .field("caller", &format_args!("{}", self.caller)) .field("inner", &self.cause) .finish() } } impl fmt::Display for LemmyError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: ", &self.error_type)?; write!(f, "{}", self.caller)?; write!(f, "{}", self.cause)?; Ok(()) } } impl actix_web::error::ResponseError for LemmyError { fn status_code(&self) -> actix_web::http::StatusCode { match self.error_type { LemmyErrorType::IncorrectLogin => actix_web::http::StatusCode::UNAUTHORIZED, LemmyErrorType::NotFound => actix_web::http::StatusCode::NOT_FOUND, _ => actix_web::http::StatusCode::BAD_REQUEST, } } fn error_response(&self) -> actix_web::HttpResponse { actix_web::HttpResponse::build(self.status_code()).json(self) } } impl From for LemmyError { #[track_caller] fn from(error_type: LemmyErrorType) -> Self { let cause = anyhow::anyhow!("{}", error_type); LemmyError { error_type, cause, caller: *Location::caller(), } } } impl From for LemmyError { #[track_caller] fn from(error_type: UntranslatedError) -> Self { let cause = anyhow::anyhow!("{}", error_type); LemmyError { error_type: LemmyErrorType::UntranslatedError( Some(error_type) ), cause, caller: *Location::caller(), } } } impl From for LemmyErrorType { fn from(error: UntranslatedError) -> Self { LemmyErrorType::UntranslatedError (Some(error) ) } } pub trait LemmyErrorExt> { fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult; } impl> LemmyErrorExt for Result { #[track_caller] fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult { self.map_err(|error| LemmyError { error_type, cause: error.into(), caller: *Location::caller(), }) } } pub trait LemmyErrorExt2 { fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult; fn into_anyhow(self) -> Result; } impl LemmyErrorExt2 for LemmyResult { fn with_lemmy_type(self, error_type: LemmyErrorType) -> LemmyResult { self.map_err(|mut e| { e.error_type = error_type; e }) } // this function can't be an impl From or similar because it would conflict with one of the other broad Into<> implementations fn into_anyhow(self) -> Result { self.map_err(|e| e.cause) } } #[cfg(test)] mod tests { #![allow(clippy::indexing_slicing)] use super::*; use actix_web::{body::MessageBody, ResponseError}; use pretty_assertions::assert_eq; #[test] fn untranslated_error_format() -> LemmyResult<()> { let err = LemmyError::from(UntranslatedError::DomainBlocked("test".to_string())).error_response(); let json = String::from_utf8(err.into_body().try_into_bytes().unwrap_or_default().to_vec())?; assert_eq!(&json, r#"{"error":"domain_blocked","message":"test","cause":"DomainBlocked"}"#); Ok(()) } #[test] fn deserializes_no_message() -> LemmyResult<()> { let err = LemmyError::from(LemmyErrorType::BlockedUrl).error_response(); let json = String::from_utf8(err.into_body().try_into_bytes().unwrap_or_default().to_vec())?; assert_eq!(&json, r#"{"error":"blocked_url","cause":"BlockedUrl"}"#); Ok(()) } #[test] fn deserializes_with_message() -> LemmyResult<()> { let reg_banned = LemmyErrorType::PictrsResponseError(String::from("reason")); let err = LemmyError::from(reg_banned).error_response(); let json = String::from_utf8(err.into_body().try_into_bytes().unwrap_or_default().to_vec())?; assert_eq!( &json, r#"{"error":"pictrs_response_error","message":"reason","cause":"PictrsResponseError"}"# ); Ok(()) } #[test] fn test_convert_diesel_errors() { let not_found_error = LemmyError::from(diesel::NotFound); assert_eq!(LemmyErrorType::NotFound, not_found_error.error_type); assert_eq!(404, not_found_error.status_code()); let other_error = LemmyError::from(diesel::result::Error::NotInTransaction); assert!(matches!(other_error.error_type, LemmyErrorType::Unknown{..})); assert_eq!(400, other_error.status_code()); } } } } ================================================ FILE: crates/utils/src/lib.rs ================================================ use cfg_if::cfg_if; use chrono::Utc; use std::{cmp::min, sync::LazyLock}; cfg_if! { if #[cfg(feature = "full")] { pub mod cache_header; pub mod rate_limit; pub mod response; pub mod settings; pub mod utils; } } pub mod error; use std::time::Duration; pub type ConnectionId = usize; pub static VERSION: LazyLock = LazyLock::new(version); pub const REQWEST_TIMEOUT: Duration = Duration::from_secs(10); // TODO: use from_days once stabilized // https://github.com/rust-lang/rust/issues/120301 const DAY: Duration = Duration::from_secs(24 * 60 * 60); #[cfg(debug_assertions)] pub const CACHE_DURATION_FEDERATION: Duration = Duration::from_secs(0); #[cfg(not(debug_assertions))] pub const CACHE_DURATION_FEDERATION: Duration = Duration::from_secs(60); #[cfg(debug_assertions)] pub const CACHE_DURATION_API: Duration = Duration::from_secs(0); #[cfg(not(debug_assertions))] pub const CACHE_DURATION_API: Duration = Duration::from_secs(1); #[cfg(debug_assertions)] pub const CACHE_DURATION_LARGEST_COMMUNITY: Duration = Duration::from_secs(0); #[cfg(not(debug_assertions))] pub const CACHE_DURATION_LARGEST_COMMUNITY: Duration = DAY; pub const MAX_COMMENT_DEPTH_LIMIT: usize = 50; /// Doing DB transactions of bigger batches than this tend to cause seq scans. pub const DB_BATCH_SIZE: i64 = 1000; fn version() -> String { if cfg!(debug_assertions) { // For debug simply use the version from Cargo.toml. We can't use git_version here // because it would cause a rebuild if any file in the repo is changed. env!("CARGO_PKG_VERSION").to_string() } else { // Event cron means its a nightly build // https://woodpecker-ci.org/docs/usage/environment if option_env!("CI_PIPELINE_EVENT") == Some("cron") { format!("nightly-{}", Utc::now().date_naive()) } else { // For actual release builds use git binary for detailed version information. git_version::git_version!( args = ["--tags", "--dirty=-modified"], fallback = env!("CARGO_PKG_VERSION") ) .to_string() } } } #[macro_export] macro_rules! location_info { () => { format!( "None value at {}:{}, column {}", file!(), line!(), column!() ) }; } cfg_if! { if #[cfg(feature = "full")] { use moka::future::Cache;use std::fmt::Debug;use std::hash::Hash; use serde_json::Value; /// Only include a basic context to save space and bandwidth. The main context is hosted statically /// on join-lemmy.org. Include activitystreams explicitly for better compat, but this could /// theoretically also be moved. pub static FEDERATION_CONTEXT: LazyLock = LazyLock::new(|| { Value::Array(vec![ Value::String("https://join-lemmy.org/context.json".to_string()), Value::String("https://www.w3.org/ns/activitystreams".to_string()), ]) }); /// tokio::spawn, but accepts a future that may fail and also /// * logs errors /// * attaches the spawned task to the tracing span of the caller for better logging pub fn spawn_try_task( task: impl futures::Future> + Send + 'static, ) { use tracing::Instrument; tokio::spawn( async { if let Err(e) = task.await { tracing::warn!("error in spawn: {e}"); } } .in_current_span(), /* this makes sure the inner tracing gets the same context as where * spawn was called */ ); } pub fn build_cache() -> Cache where K: Debug + Eq + Hash + Send + Sync + 'static, V: Debug + Clone + Send + Sync + 'static, { Cache::::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_API) .build() } #[cfg(feature = "full")] pub type CacheLock = std::sync::LazyLock>; } } /// Calculate how long to sleep until next federation send based on how many /// retries have already happened. Uses exponential backoff with maximum of one day. The first /// error is ignored. pub fn federate_retry_sleep_duration(retry_count: i32) -> Duration { debug_assert!(retry_count != 0); if retry_count == 1 { return Duration::from_secs(0); } let retry_count = retry_count - 1; let pow = 1.25_f64.powf(retry_count.into()); let pow = Duration::try_from_secs_f64(pow).unwrap_or(DAY); min(DAY, pow) } #[cfg(test)] pub(crate) mod tests { use super::*; #[test] fn test_federate_retry_sleep_duration() { assert_eq!(Duration::from_secs(0), federate_retry_sleep_duration(1)); assert_eq!( Duration::new(1, 250000000), federate_retry_sleep_duration(2) ); assert_eq!( Duration::new(2, 441406250), federate_retry_sleep_duration(5) ); assert_eq!(DAY, federate_retry_sleep_duration(100)); } } ================================================ FILE: crates/utils/src/main.rs ================================================ use cfg_if::cfg_if; fn main() { cfg_if! { if #[cfg(feature = "full")] { println!("{}", config_to_string()) } else { } } } #[cfg(feature = "full")] fn config_to_string() -> String { use doku::json::{AutoComments, CommentsStyle, Formatting, ObjectsStyle}; use lemmy_utils::settings::structs::Settings; let fmt = Formatting { auto_comments: AutoComments::none(), comments_style: CommentsStyle { separator: "#".to_owned(), }, objects_style: ObjectsStyle { surround_keys_with_quotes: false, use_comma_as_separator: false, }, ..Default::default() }; doku::to_json_fmt_val(&fmt, &Settings::default()) } #[cfg(test)] mod test { use crate::config_to_string; #[test] fn test_config_defaults_updated() -> lemmy_utils::error::LemmyResult<()> { let current_config = std::fs::read_to_string("../../config/defaults.hjson")?; let mut updated_config = config_to_string(); updated_config.push('\n'); if current_config != updated_config { let diff = unified_diff::diff( current_config.as_bytes(), "current", updated_config.as_bytes(), "expected", 3, ); panic!("{}", String::from_utf8_lossy(&diff)); } Ok(()) } } ================================================ FILE: crates/utils/src/rate_limit/backend.rs ================================================ //! The content in this file is mostly copy-pasted from library code: //! https://github.com/jacob-pro/actix-extensible-rate-limit/blob/master/src/backend/memory.rs use crate::rate_limit::{ActionType, BucketConfig, input::LemmyInput}; use actix_extensible_rate_limit::backend::{ Backend, Decision, SimpleOutput, memory::DEFAULT_GC_INTERVAL_SECONDS, }; use actix_web::rt::{task::JoinHandle, time::Instant}; use dashmap::DashMap; use enum_map::EnumMap; use std::{ convert::Infallible, sync::{Arc, RwLock}, time::Duration, }; /// A Fixed Window rate limiter [Backend] that uses [Dashmap](dashmap::DashMap) to store keys /// in memory. #[derive(Clone)] pub struct LemmyBackend { map: Arc>, gc_handle: Option>>, pub(super) configs: Arc>>, } struct Value { ttl: Instant, count: u64, } impl LemmyBackend { pub(crate) fn new(configs: EnumMap, enable_gc: bool) -> Self { let map = Arc::new(DashMap::::new()); let gc_handle = enable_gc.then(|| { Arc::new(LemmyBackend::garbage_collector( map.clone(), Duration::from_secs(DEFAULT_GC_INTERVAL_SECONDS), )) }); LemmyBackend { map, gc_handle, configs: Arc::new(RwLock::new(configs)), } } fn garbage_collector(map: Arc>, interval: Duration) -> JoinHandle<()> { assert!( interval.as_secs_f64() > 0f64, "GC interval must be non-zero" ); tokio::spawn(async move { loop { let now = Instant::now(); map.retain(|_k, v| v.ttl > now); tokio::time::sleep_until(now + interval).await; } }) } } impl Backend for LemmyBackend { type Output = SimpleOutput; type RollbackToken = LemmyInput; type Error = Infallible; #[expect(clippy::expect_used)] async fn request( &self, input: LemmyInput, ) -> Result<(Decision, Self::Output, Self::RollbackToken), Self::Error> { #[expect(clippy::expect_used)] let config = self.configs.read().expect("read rwlock")[input.1]; let max_requests: u64 = config.max_requests.into(); let interval = Duration::from_secs(config.interval.into()); let now = Instant::now(); let mut count = 1; let mut expiry = now .checked_add(interval) .expect("Interval unexpectedly large"); self .map .entry(input) .and_modify(|v| { // If this bucket hasn't yet expired, increment and extract the count/expiry if v.ttl > now { v.count += 1; count = v.count; expiry = v.ttl; } else { // If this bucket has expired we will reset the count to 1 and set a new TTL. v.ttl = expiry; v.count = count; } }) .or_insert_with(|| Value { // If the bucket doesn't exist, create it with a count of 1, and set the TTL. ttl: expiry, count, }); let allow = count <= max_requests; let output = SimpleOutput { limit: max_requests, remaining: max_requests.saturating_sub(count), reset: expiry, }; Ok((Decision::from_allowed(allow), output, input)) } async fn rollback(&self, token: Self::RollbackToken) -> Result<(), Self::Error> { self.map.entry(token).and_modify(|v| { v.count = v.count.saturating_sub(1); }); Ok(()) } } impl Drop for LemmyBackend { fn drop(&mut self) { if let Some(handle) = &self.gc_handle { handle.abort(); } } } #[cfg(test)] mod tests { use super::*; use crate::{ error::LemmyResult, rate_limit::{ActionType, input::raw_ip_key}, }; use enum_map::enum_map; const MINUTE_SECS: u32 = 60; const MINUTE: Duration = Duration::from_secs(60); fn test_config(interval: u32, max_requests: u32) -> EnumMap { enum_map! { ActionType::Message => BucketConfig { max_requests, interval }, ActionType::Post => BucketConfig { max_requests: 1, interval: 120, }, ActionType::Register => BucketConfig { max_requests: 0, interval: 0, }, ActionType::Image => BucketConfig { max_requests: 0, interval: 0, }, ActionType::Comment => BucketConfig { max_requests: 0, interval: 0, }, ActionType::Search => BucketConfig { max_requests: 0, interval: 0, }, ActionType::ImportUserSettings => BucketConfig { max_requests: 0, interval: 0, }, } } #[actix_web::test] async fn test_allow_deny() -> LemmyResult<()> { tokio::time::pause(); let backend = LemmyBackend::new(test_config(MINUTE_SECS, 5), true); let key = raw_ip_key(Some("127.0.0.2")); let input = LemmyInput(key, ActionType::Message); for _ in 0..5 { // First 5 should be allowed let (allow, _, _) = backend.request(input).await?; assert!(allow.is_allowed()); } // Sixth should be denied let (allow, _, _) = backend.request(input).await?; assert!(!allow.is_allowed()); Ok(()) } #[actix_web::test] async fn test_reset() -> LemmyResult<()> { tokio::time::pause(); let backend = LemmyBackend::new(test_config(MINUTE_SECS, 1), false); let input = LemmyInput(raw_ip_key(Some("127.0.0.3")), ActionType::Message); // Make first request, should be allowed let (decision, _, _) = backend.request(input).await?; assert!(decision.is_allowed()); // Request again, should be denied let (decision, _, _) = backend.request(input).await?; assert!(decision.is_denied()); // Advance time and try again, should now be allowed tokio::time::advance(MINUTE).await; // We want to be sure the key hasn't been garbage collected, and we are testing the expiry logic assert!(backend.map.contains_key(&input)); let (decision, _, _) = backend.request(input).await?; assert!(decision.is_allowed()); Ok(()) } #[actix_web::test] async fn test_garbage_collection() -> LemmyResult<()> { tokio::time::pause(); let backend = LemmyBackend::new(test_config(MINUTE_SECS, 1), true); let key1 = LemmyInput(raw_ip_key(Some("127.0.0.4")), ActionType::Message); let key2 = LemmyInput(raw_ip_key(Some("127.0.0.5")), ActionType::Post); backend.request(key1).await?; backend.request(key2).await?; assert!(backend.map.contains_key(&key1)); assert!(backend.map.contains_key(&key2)); // Advance time such that the garbage collector runs, // expired KEY1 should be cleaned, but KEY2 should remain. tokio::time::advance(MINUTE).await; assert!(!backend.map.contains_key(&key1)); assert!(backend.map.contains_key(&key2)); Ok(()) } #[actix_web::test] async fn test_output() -> LemmyResult<()> { tokio::time::pause(); let backend = LemmyBackend::new(test_config(MINUTE_SECS, 2), true); let key = raw_ip_key(Some("127.0.0.6")); let input = LemmyInput(key, ActionType::Message); // First of 2 should be allowed. let (decision, output, _) = backend.request(input).await?; assert!(decision.is_allowed()); assert_eq!(output.remaining, 1); assert_eq!(output.limit, 2); assert_eq!(output.reset, Instant::now() + MINUTE); // Second of 2 should be allowed. let (decision, output, _) = backend.request(input).await?; assert!(decision.is_allowed()); assert_eq!(output.remaining, 0); assert_eq!(output.limit, 2); assert_eq!(output.reset, Instant::now() + MINUTE); // Should be denied let (decision, output, _) = backend.request(input).await?; assert!(decision.is_denied()); assert_eq!(output.remaining, 0); assert_eq!(output.limit, 2); assert_eq!(output.reset, Instant::now() + MINUTE); Ok(()) } #[actix_web::test] async fn test_rollback() -> LemmyResult<()> { tokio::time::pause(); let backend = LemmyBackend::new(test_config(MINUTE_SECS, 5), true); let key = raw_ip_key(Some("127.0.0.7")); let input = LemmyInput(key, ActionType::Message); let (_, output, rollback) = backend.request(input).await?; assert_eq!(output.remaining, 4); backend.rollback(rollback).await?; // Remaining requests should still be the same, since the previous call was excluded let (_, output, _) = backend.request(input).await?; assert_eq!(output.remaining, 4); Ok(()) } } ================================================ FILE: crates/utils/src/rate_limit/input.rs ================================================ use crate::rate_limit::ActionType; use std::{ future::Ready, net::{IpAddr, Ipv4Addr, SocketAddr}, str::FromStr, }; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub struct LemmyInput(pub(crate) RateLimitIpAddr, pub(crate) ActionType); pub(crate) type LemmyInputFuture = Ready>; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub(crate) enum RateLimitIpAddr { V4(Ipv4Addr), V6([u16; 4]), } #[expect(clippy::expect_used)] impl From for RateLimitIpAddr { fn from(value: IpAddr) -> Self { match value { IpAddr::V4(addr) => RateLimitIpAddr::V4(addr), IpAddr::V6(addr) => RateLimitIpAddr::V6( addr.segments()[..4] .try_into() .expect("byte array is correct length"), ), } } } /// Generate a raw byte key for backend which uses less memory. pub(crate) fn raw_ip_key(ip_str: Option<&str>) -> RateLimitIpAddr { parse_ip(ip_str).into() } fn parse_ip(addr: Option<&str>) -> IpAddr { if let Some(addr) = addr { if let Ok(ip) = IpAddr::from_str(addr) { return ip; } else if let Ok(socket) = SocketAddr::from_str(addr) { return socket.ip(); } } Ipv4Addr::new(127, 0, 0, 1).into() } #[cfg(test)] mod tests { use super::*; use crate::error::LemmyResult; #[test] fn test_get_ip() -> LemmyResult<()> { // Check that IPv4 addresses are preserved assert_eq!( raw_ip_key(Some("142.250.187.206")), "142.250.187.206".parse::()?.into() ); // Check that IPv6 addresses are grouped into /64 subnets assert_eq!( raw_ip_key(Some("2a00:1450:4009:81f::200e")), RateLimitIpAddr::V6([0x2a00, 0x1450, 0x4009, 0x81f]) ); assert_eq!( raw_ip_key(Some("[2a00:1450:4009:81f::200e]:123")), RateLimitIpAddr::V6([0x2a00, 0x1450, 0x4009, 0x81f]) ); Ok(()) } } ================================================ FILE: crates/utils/src/rate_limit/mod.rs ================================================ use crate::rate_limit::{ backend::LemmyBackend, input::{LemmyInput, LemmyInputFuture, raw_ip_key}, }; use actix_extensible_rate_limit::{RateLimiter, backend::SimpleOutput}; use actix_web::dev::ServiceRequest; use enum_map::{EnumMap, enum_map}; use std::future::ready; use strum::{AsRefStr, Display}; mod backend; mod input; #[derive(Debug, enum_map::Enum, Copy, Clone, Display, AsRefStr, Eq, PartialEq, Hash)] pub enum ActionType { Message, Register, Post, Image, Comment, Search, ImportUserSettings, } #[derive(PartialEq, Debug, Copy, Clone)] pub struct BucketConfig { pub max_requests: u32, pub interval: u32, } #[derive(Clone)] pub struct RateLimit { backend: LemmyBackend, } impl RateLimit { pub fn new(configs: EnumMap) -> Self { Self { backend: LemmyBackend::new(configs, true), } } pub fn with_debug_config() -> Self { Self::new(enum_map! { ActionType::Message => BucketConfig { max_requests: 180, interval: 60, }, ActionType::Post => BucketConfig { max_requests: 6, interval: 300, }, ActionType::Register => BucketConfig { max_requests: 3, interval: 3600, }, ActionType::Image => BucketConfig { max_requests: 6, interval: 3600, }, ActionType::Comment => BucketConfig { max_requests: 6, interval: 600, }, ActionType::Search => BucketConfig { max_requests: 60, interval: 600, }, ActionType::ImportUserSettings => BucketConfig { max_requests: 1, interval: 24 * 60 * 60, }, }) } #[expect(clippy::expect_used)] pub fn set_config(&self, configs: EnumMap) { *self.backend.configs.write().expect("write rwlock") = configs; } fn build_rate_limiter( &self, action_type: ActionType, ) -> RateLimiter LemmyInputFuture + 'static> { let input = new_input(action_type); RateLimiter::builder(self.backend.clone(), input) .add_headers() // rollback rate limit on any error 500 .rollback_server_errors() .build() } pub fn message( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::Message) } pub fn search( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::Search) } pub fn register( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::Register) } pub fn post( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::Post) } pub fn image( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::Image) } pub fn comment( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::Comment) } pub fn import_user_settings( &self, ) -> RateLimiter LemmyInputFuture + 'static> { self.build_rate_limiter(ActionType::ImportUserSettings) } } fn new_input(action_type: ActionType) -> impl Fn(&ServiceRequest) -> LemmyInputFuture + 'static { move |req| { ready({ let info = req.connection_info(); let key = raw_ip_key(info.realip_remote_addr()); Ok(LemmyInput(key, action_type)) }) } } ================================================ FILE: crates/utils/src/response.rs ================================================ use crate::error::{LemmyError, LemmyErrorType}; use actix_web::{ HttpRequest, HttpResponse, dev::ServiceResponse, middleware::ErrorHandlerResponse, }; pub fn jsonify_plain_text_errors( res: ServiceResponse, ) -> actix_web::Result> { let maybe_error = res.response().error(); let is_rate_limit_error = res.status() == 429; // This function is only expected to be called for errors, so if there is no error, return if maybe_error.is_none() && !is_rate_limit_error { return Ok(ErrorHandlerResponse::Response(res.map_into_left_body())); } // We're assuming that any LemmyError is already in JSON format, so we don't need to do anything if let Some(maybe_error) = maybe_error && maybe_error.as_error::().is_some() { return Ok(ErrorHandlerResponse::Response(res.map_into_left_body())); } // convert other errors to json format let (req, res_parts) = res.into_parts(); let lemmy_err_type = if let Some(error) = res_parts.error() { LemmyErrorType::Unknown(error.to_string()) } else if is_rate_limit_error { LemmyErrorType::TooManyRequests } else { LemmyErrorType::Unknown("couldnt build json".into()) }; build_error_response(req, res_parts, lemmy_err_type) } fn build_error_response( req: HttpRequest, res_parts: HttpResponse, err: LemmyErrorType, ) -> actix_web::Result> { let response = HttpResponse::build(res_parts.status()).json(err); let service_response = ServiceResponse::new(req, response); Ok(ErrorHandlerResponse::Response( service_response.map_into_right_body(), )) } #[cfg(test)] mod tests { use super::*; use crate::error::{LemmyError, LemmyErrorType}; use actix_web::{ App, Error, Handler, Responder, error::ErrorInternalServerError, http::StatusCode, middleware::ErrorHandlers, test, web, }; use pretty_assertions::assert_eq; #[actix_web::test] async fn test_non_error_responses_are_not_modified() { async fn ok_service() -> actix_web::Result { Ok("Oll Korrect".to_string()) } check_for_jsonification(ok_service, StatusCode::OK, "Oll Korrect").await; } #[actix_web::test] async fn test_lemmy_errors_are_not_modified() { async fn lemmy_error_service() -> actix_web::Result { Err(LemmyError::from(LemmyErrorType::AlreadyExists)) } check_for_jsonification( lemmy_error_service, StatusCode::BAD_REQUEST, "{\"error\":\"already_exists\",\"cause\":\"AlreadyExists\"}", ) .await; } #[actix_web::test] async fn test_generic_errors_are_jsonified_as_unknown_errors() { async fn generic_error_service() -> actix_web::Result { Err(ErrorInternalServerError("This is not a LemmyError")) } check_for_jsonification( generic_error_service, StatusCode::INTERNAL_SERVER_ERROR, "{\"error\":\"unknown\",\"message\":\"This is not a LemmyError\"}", ) .await; } #[actix_web::test] async fn test_anyhow_errors_wrapped_in_lemmy_errors_are_jsonified_correctly() { async fn anyhow_error_service() -> actix_web::Result { Err(LemmyError::from(anyhow::anyhow!("This is the inner error"))) } check_for_jsonification( anyhow_error_service, StatusCode::BAD_REQUEST, "{\"error\":\"unknown\",\"message\":\"This is the inner error\",\"cause\":\"This is the inner error\"}", ) .await; } #[actix_web::test] async fn test_rate_limit_error() { async fn lemmy_error_service() -> actix_web::Result { Ok(HttpResponse::TooManyRequests().finish()) } check_for_jsonification( lemmy_error_service, StatusCode::TOO_MANY_REQUESTS, "{\"error\":\"too_many_requests\"}", ) .await; } async fn check_for_jsonification( service: impl Handler<(), Output = impl Responder + 'static>, expected_status_code: StatusCode, expected_body: &str, ) { let app = test::init_service( App::new() .wrap(ErrorHandlers::new().default_handler(jsonify_plain_text_errors)) .route("/", web::get().to(service)), ) .await; let req = test::TestRequest::default().to_request(); let res = test::call_service(&app, req).await; assert_eq!(res.status(), expected_status_code); let body = test::read_body(res).await; assert_eq!(body, expected_body); } } ================================================ FILE: crates/utils/src/settings/mod.rs ================================================ use crate::{error::LemmyResult, location_info}; use anyhow::{Context, anyhow}; use deser_hjson::from_str; use std::{env, fs, sync::LazyLock}; use structs::{PictrsConfig, Settings}; use url::Url; use urlencoding::encode; pub mod structs; static DEFAULT_CONFIG_FILE: &str = "config/config.hjson"; /// Some connection options to speed up queries const CONNECTION_OPTIONS: [&str; 1] = ["geqo_threshold=12"]; #[expect(clippy::expect_used)] pub static SETTINGS: LazyLock = LazyLock::new(|| { if env::var("LEMMY_INITIALIZE_WITH_DEFAULT_SETTINGS").is_ok() { println!( "LEMMY_INITIALIZE_WITH_DEFAULT_SETTINGS was set, any configuration file has been ignored." ); println!( "Use with other environment variables to configure this instance further; e.g. LEMMY_DATABASE_URL." ); Settings::default() } else { Settings::init().expect("Failed to load settings file, see documentation (https://join-lemmy.org/docs/en/administration/configuration.html).") } }); impl Settings { /// Reads config from configuration file. /// /// Note: The env var `LEMMY_DATABASE_URL` is parsed in /// `lemmy_db_schema/src/lib.rs::get_database_url_from_env()` /// Warning: Only call this once. pub(crate) fn init() -> LemmyResult { let path = env::var("LEMMY_CONFIG_LOCATION").unwrap_or_else(|_| DEFAULT_CONFIG_FILE.to_string()); let plain = fs::read_to_string(path)?; let config = from_str::(&plain)?; if config.hostname == "unset" { Err(anyhow!("Hostname variable is not set!").into()) } else { Ok(config) } } pub fn get_database_url(&self) -> String { if let Ok(url) = env::var("LEMMY_DATABASE_URL") { url } else { self.database.connection.clone() } } /// Returns either "http" or "https", depending on tls_enabled setting fn get_protocol_string(&self) -> &'static str { if self.tls_enabled { "https" } else { "http" } } /// Returns something like `http://localhost` or `https://lemmy.ml`, /// with the correct protocol and hostname. pub fn get_protocol_and_hostname(&self) -> String { format!("{}://{}", self.get_protocol_string(), self.hostname) } /// When running the federation test setup in `api_tests/` or `docker/federation`, the `hostname` /// variable will be like `lemmy-alpha:8541`. This method removes the port and returns /// `lemmy-alpha` instead. It has no effect in production. pub fn get_hostname_without_port(&self) -> Result { Ok( (*self .hostname .split(':') .collect::>() .first() .context(location_info!())?) .to_string(), ) } pub fn pictrs(&self) -> LemmyResult { self .pictrs .clone() .ok_or_else(|| anyhow!("images_disabled").into()) } /// Sets a few additional config options necessary for starting lemmy pub fn get_database_url_with_options(&self) -> LemmyResult { let mut url = Url::parse(&self.get_database_url())?; // Set `lemmy.protocol_and_hostname` so triggers can use it let lemmy_protocol_and_hostname_option = "lemmy.protocol_and_hostname=".to_owned() + &self.get_protocol_and_hostname(); let mut options = CONNECTION_OPTIONS.to_vec(); options.push(&lemmy_protocol_and_hostname_option); // Create the connection uri portion let options_segments = options .iter() // The equal signs need to be encoded, since the url set_query doesn't do them, // and postgres requires them to be %3D // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING .map(|o| format!("-c {}", encode(o))) .collect::>() .join(" "); url.set_query(Some(&format!("options={options_segments}"))); Ok(url.into()) } } #[expect(clippy::expect_used)] /// Necessary to avoid URL expect failures fn pictrs_placeholder_url() -> Url { Url::parse("http://localhost:8080").expect("parse pictrs url") } #[cfg(test)] mod tests { use super::*; #[test] fn test_load_config() -> LemmyResult<()> { Settings::init()?; Ok(()) } } ================================================ FILE: crates/utils/src/settings/structs.rs ================================================ use super::pictrs_placeholder_url; use doku::Document; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::{ collections::BTreeMap, env, net::{IpAddr, Ipv4Addr}, }; use url::Url; #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] pub struct Settings { /// settings related to the postgresql database pub database: DatabaseConfig, /// Pictrs image server configuration. #[default(Some(Default::default()))] pub(crate) pictrs: Option, /// Email sending configuration. All options except login/password are mandatory #[doku(example = "Some(Default::default())")] pub email: Option, /// Parameters for automatic configuration of new instance (only used at first start) #[doku(example = "Some(Default::default())")] pub setup: Option, /// the domain name of your instance (mandatory) #[default("unset")] #[doku(example = "example.com")] pub hostname: String, /// Address where lemmy should listen for incoming requests #[default(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)))] #[doku(as = "String")] pub bind: IpAddr, /// Port where lemmy should listen for incoming requests #[default(8536)] pub port: u16, /// Whether the site is available over TLS. Needs to be true for federation to work. #[default(true)] pub tls_enabled: bool, /// Set the URL for opentelemetry exports. If you do not have an opentelemetry collector, do not /// set this option #[doku(skip)] pub opentelemetry_url: Option, pub federation: FederationWorkerConfig, // Prometheus configuration. #[doku(example = "Some(Default::default())")] pub prometheus: Option, /// Sets a response Access-Control-Allow-Origin CORS header. Can also be set via environment: /// `LEMMY_CORS_ORIGIN=example.org,site.com` /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin #[doku(example = "lemmy.tld")] cors_origin: Vec, /// Print logs in JSON format. You can also disable ANSI colors in logs with env var `NO_COLOR`. pub json_logging: bool, /// Data for loading Lemmy plugins pub plugins: Vec, } impl Settings { pub fn cors_origin(&self) -> Vec { env::var("LEMMY_CORS_ORIGIN") .ok() .map(|e| e.split(',').map(ToString::to_string).collect()) .unwrap_or(self.cors_origin.clone()) } } #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] pub struct PictrsConfig { /// Address where pictrs is available (for image hosting) #[default(pictrs_placeholder_url())] #[doku(example = "http://localhost:8080")] pub url: Url, /// Set a custom pictrs API key. ( Required for deleting images ) pub api_key: Option, } #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] pub struct DatabaseConfig { /// Configure the database by specifying URI pointing to a postgres instance. This parameter can /// also be set by environment variable `LEMMY_DATABASE_URL`. /// /// For an explanation of how to use connection URIs, see PostgreSQL's documentation: /// https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6 #[default("postgres://lemmy:password@localhost:5432/lemmy")] #[doku(example = "postgresql:///lemmy?user=lemmy&host=/var/run/postgresql")] pub(crate) connection: String, /// Maximum number of active sql connections /// /// A high value here can result in errors "could not resize shared memory segment". In this case /// it is necessary to increase shared memory size in Docker: https://stackoverflow.com/a/56754077 #[default(30)] pub pool_size: usize, } #[derive(Debug, Deserialize, Serialize, Clone, Document, SmartDefault)] #[serde(default, deny_unknown_fields)] pub struct EmailConfig { /// https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url #[default("smtp://localhost:25")] #[doku(example = "smtps://user:pass@hostname:port")] pub connection: String, /// Address to send emails from, eg "noreply@your-instance.com" #[doku(example = "noreply@example.com")] pub smtp_from_address: String, } #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] pub struct SetupConfig { /// Username for the admin user #[doku(example = "admin")] pub admin_username: String, /// Password for the admin user. It must be between 10 and 60 characters. #[doku(example = "tf6HHDS4RolWfFhk4Rq9")] pub admin_password: String, /// Name of the site, can be changed later. Maximum 20 characters. #[doku(example = "My Lemmy Instance")] pub site_name: String, /// Email for the admin user (optional, can be omitted and set later through the website) #[doku(example = "user@example.com")] pub admin_email: Option, /// On first start Lemmy fetches the 50 most active communities from one of these instances, /// to provide some initial data. It tries the first list entry, and if it fails uses subsequent /// instances as fallback. /// Leave this empty to disable community bootstrap. /// TODO: remove voyager.lemmy.ml from defaults once Lemmy 1.0 is deployed to production /// instances. #[default(vec!["lemmy.ml".to_string(),"lemmy.world".to_string(),"lemmy.zip".to_string(),"voyager.lemmy.ml".to_string()])] pub bootstrap_instances: Vec, } #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] pub struct PrometheusConfig { // Address that the Prometheus metrics will be served on. #[default(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))] #[doku(example = "127.0.0.1")] pub bind: IpAddr, // Port that the Prometheus metrics will be served on. #[default(10002)] #[doku(example = "10002")] pub port: u16, } #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] // named federation"worker"config to disambiguate from the activitypub library configuration pub struct FederationWorkerConfig { /// Limit to the number of concurrent outgoing federation requests per target instance. /// Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities /// per second) and if a receiving instance is not keeping up. #[default(1)] pub concurrent_sends_per_instance: i8, } /// See the extism docs for more details: https://extism.org/docs/concepts/manifest #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[serde(default, deny_unknown_fields)] pub struct PluginSettings { /// Where to load the .wasm file from, can be a local file path or URL #[doku( example = "https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.1/go_replace_words.wasm" )] pub file: String, /// SHA256 hash of the .wasm file #[doku(example = "37cdc01a3ff26eef578b668c6cc57fc06649deddb3a92cb6bae8e79b4e60fe12")] pub hash: Option, /// Which websites the plugin may connect to #[serde(default)] #[doku(example = "lemmy.ml")] pub allowed_hosts: Option>, /// Configuration options for the plugin #[serde(default)] pub config: BTreeMap, } ================================================ FILE: crates/utils/src/utils/markdown/identifier_rule.rs ================================================ use crate::utils::markdown::link_rule::Link; use markdown_it::{ MarkdownIt, Node, NodeValue, Renderer, parser::inline::{InlineRule, InlineState}, }; #[derive(Debug)] pub struct Identifier { pub is_community: bool, pub name: String, pub domain: String, } impl NodeValue for Identifier { fn render(&self, node: &Node, fmt: &mut dyn Renderer) { let mut attrs = node.attrs.clone(); let path = if self.is_community { 'c' } else { 'u' }; attrs.push(("href", format!("/{path}/{}@{}", &self.name, &self.domain))); attrs.push(("rel", "nofollow".to_string())); attrs.push(("class", "u-url".to_string())); attrs.push(("class", "mention".to_string())); fmt.open("a", &attrs); let marker = if self.is_community { '!' } else { '@' }; fmt.text(&format!("{marker}{}@{}", self.name, self.domain)); fmt.close("a"); } } struct CommunityIdentifierScanner; struct PersonIdentifierScanner; impl InlineRule for CommunityIdentifierScanner { const MARKER: char = '!'; fn run(state: &mut InlineState) -> Option<(Node, usize)> { scan_for_identifier(true, Self::MARKER, state) } } impl InlineRule for PersonIdentifierScanner { const MARKER: char = '@'; fn run(state: &mut InlineState) -> Option<(Node, usize)> { scan_for_identifier(false, Self::MARKER, state) } } fn scan_for_identifier( is_community: bool, marker: char, state: &mut InlineState, ) -> Option<(Node, usize)> { // Dont allow identifier inside link, otherwise it outputs nested `` tags. if state.node.is::() { return None; } let Some(input) = &state.src.get(state.pos..state.pos_max) else { return None; }; // wrong start character if !input.starts_with(marker) { return None; } let mut found_at = false; let mut name = String::new(); let mut domain = String::new(); for c in input.chars().skip(1) { // whitespace means we reached the end if c.is_whitespace() { break; } // we are inside a markdown link, ignore if c == ']' { return None; } // found the @ character between name and domain if c == '@' { found_at = true; continue; } if !found_at { name.push(c); } else { domain.push(c); } } // check if we found a valid, nonempty identifier (!name.is_empty() && !domain.is_empty()).then(|| { let len = name.len() + domain.len() + 2; let identifier = Identifier { is_community, name, domain, }; (Node::new(identifier), len) }) } pub fn add(md: &mut MarkdownIt) { md.inline.add_rule::(); md.inline.add_rule::(); } ================================================ FILE: crates/utils/src/utils/markdown/image_links.rs ================================================ use super::link_rule::Link; use crate::{settings::SETTINGS, utils::markdown::link_rule}; use markdown_it::{ MarkdownIt, NodeValue, parser::linkfmt::LinkFormatter, plugins::cmark::{ block::fence, inline::{image, image::Image}, }, }; use std::sync::LazyLock; use url::Url; use urlencoding::encode; /// Rewrites all links to remote domains in markdown, so they go through `/api/v4/image_proxy`. pub fn markdown_rewrite_image_links(mut src: String) -> (String, Vec) { let links_offsets = find_urls::(&src); let mut links = vec![]; // Go through the collected links in reverse order for (start, end) in links_offsets.into_iter().rev() { let (url, extra) = markdown_handle_title(&src, start, end); match Url::parse(url) { Ok(parsed) => { links.push(parsed.clone()); // If link points to remote domain, replace with proxied link if parsed.domain() != Some(&SETTINGS.hostname) { let mut proxied = format!( "{}/api/v4/image/proxy?url={}", SETTINGS.get_protocol_and_hostname(), encode(url), ); // restore custom emoji format if let Some(extra) = extra { proxied.push(' '); proxied.push_str(extra); } src.replace_range(start..end, &proxied); } } Err(_) => { // If its not a valid url, replace with empty text src.replace_range(start..end, ""); } } } (src, links) } pub fn markdown_handle_title(src: &str, start: usize, end: usize) -> (&str, Option<&str>) { let content = src.get(start..end).unwrap_or_default(); // necessary for custom emojis which look like `![name](url "title")` match content.split_once(' ') { Some((a, b)) => (a, Some(b)), _ => (content, None), } } pub fn markdown_find_links(src: &str) -> Vec<(usize, usize)> { find_urls::(src) } // Walk the syntax tree to find positions of image or link urls fn find_urls(src: &str) -> Vec<(usize, usize)> { // Use separate markdown parser here, with most features disabled for faster parsing, // and a dummy link formatter which doesnt normalize links. static PARSER: LazyLock = LazyLock::new(|| { let mut p = MarkdownIt::new(); p.link_formatter = Box::new(NoopLinkFormatter {}); image::add(&mut p); fence::add(&mut p); link_rule::add(&mut p); p }); let ast = PARSER.parse(src); let mut links_offsets = vec![]; ast.walk(|node, _depth| { if let Some(image) = node.cast::() && let Some(srcmap) = node.srcmap { let (_, node_offset) = srcmap.get_byte_offsets(); let start_offset = node_offset - image.url_len() - 1 - image.title_len(); let end_offset = node_offset - 1; links_offsets.push((start_offset, end_offset)); } }); links_offsets } trait UrlAndTitle { fn url_len(&self) -> usize; fn title_len(&self) -> usize; } impl UrlAndTitle for Image { fn url_len(&self) -> usize { self.url.len() } fn title_len(&self) -> usize { self.title.as_ref().map(|t| t.len() + 3).unwrap_or_default() } } impl UrlAndTitle for Link { fn url_len(&self) -> usize { self.url.len() } fn title_len(&self) -> usize { self.title.as_ref().map(|t| t.len() + 3).unwrap_or_default() } } /// markdown-it normalizes links by default, which breaks the link rewriting. So we use a dummy /// formatter here which does nothing. Note this isnt actually used to render the markdown. #[derive(Debug)] struct NoopLinkFormatter; impl LinkFormatter for NoopLinkFormatter { fn validate_link(&self, _url: &str) -> Option<()> { Some(()) } fn normalize_link(&self, url: &str) -> String { url.to_owned() } fn normalize_link_text(&self, url: &str) -> String { url.to_owned() } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn test_find_links() { let links = markdown_find_links("[test](https://example.com)"); assert_eq!(vec![(7, 26)], links); let links = find_urls::("![test](https://example.com)"); assert_eq!(vec![(8, 27)], links); let links = find_urls::("![ითხოვს](https://example.com/ითხოვს)"); assert_eq!(vec![(22, 60)], links); let links = find_urls::("![test](https://example.com/%C3%A4%C3%B6%C3%BC.jpg)"); assert_eq!(vec![(8, 50)], links); } #[test] fn test_markdown_proxy_images() { let tests: Vec<_> = vec![ ( "remote image proxied", "![link](http://example.com/image.jpg)", "![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)", ), ( "local image unproxied", "![link](http://lemmy-alpha/image.jpg)", "![link](http://lemmy-alpha/image.jpg)", ), ( "multiple image links", "![link](http://example.com/image1.jpg) ![link](http://example.com/image2.jpg)", "![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage1.jpg) ![link](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage2.jpg)", ), ("empty link handled", "![image]()", "![image]()"), ( "empty label handled", "![](http://example.com/image.jpg)", "![](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)", ), ( "invalid image link removed", "![image](http-not-a-link)", "![image]()", ), ( "label with nested markdown handled", "![a *b* c](http://example.com/image.jpg)", "![a *b* c](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2Fimage.jpg)", ), ( "custom emoji support", r#"![party-blob](https://www.hexbear.net/pictrs/image/83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#, r#"![party-blob](https://lemmy-alpha/api/v4/image/proxy?url=https%3A%2F%2Fwww.hexbear.net%2Fpictrs%2Fimage%2F83405746-0620-4728-9358-5f51b040ffee.gif "emoji party-blob")"#, ), ( "image with special chars", "ითხოვს ![ითხოვს](http://example.com/ითხოვს%C3%A4.jpg)", "ითხოვს ![ითხოვს](https://lemmy-alpha/api/v4/image/proxy?url=http%3A%2F%2Fexample.com%2F%E1%83%98%E1%83%97%E1%83%AE%E1%83%9D%E1%83%95%E1%83%A1%25C3%25A4.jpg)", ), ]; tests.iter().for_each(|&(msg, input, expected)| { let result = markdown_rewrite_image_links(input.to_string()); assert_eq!( result.0, expected, "Testing {}, with original input '{}'", msg, input ); }); } } ================================================ FILE: crates/utils/src/utils/markdown/link_rule.rs ================================================ use crate::utils::mention::MENTIONS_REGEX; use markdown_it::{ MarkdownIt, Node, NodeValue, Renderer, generics::inline::full_link, parser::inline::Text, }; /// Renders markdown links. Copied directly from markdown-it source, unlike original code it also /// sets `rel=nofollow` attribute. /// /// TODO: We can set nofollow only if post was not made by mod/admin, but then we have to construct /// new parser for every invocation which might have performance implications. /// https://github.com/markdown-it-rust/markdown-it/blob/master/src/plugins/cmark/inline/link.rs #[derive(Debug)] pub struct Link { pub url: String, pub title: Option, } impl NodeValue for Link { fn render(&self, node: &Node, fmt: &mut dyn Renderer) { let mut attrs = node.attrs.clone(); attrs.push(("href", self.url.clone())); attrs.push(("rel", "nofollow".to_string())); if let Some(title) = &self.title { attrs.push(("title", title.clone())); } let text = node.children.first().and_then(|n| n.cast::()); if let Some(text) = text && MENTIONS_REGEX.is_match(&text.content) { attrs.push(("class", "u-url".to_string())); attrs.push(("class", "mention".to_string())); } fmt.open("a", &attrs); fmt.contents(&node.children); fmt.close("a"); } } pub fn add(md: &mut MarkdownIt) { full_link::add::(md, |href, title| { Node::new(Link { url: href.unwrap_or_default(), title, }) }); } ================================================ FILE: crates/utils/src/utils/markdown/mod.rs ================================================ use crate::error::{LemmyErrorType, LemmyResult}; use markdown_it::MarkdownIt; use regex::RegexSet; use std::sync::LazyLock; mod identifier_rule; pub mod image_links; mod link_rule; static MARKDOWN_PARSER: LazyLock = LazyLock::new(|| { let mut parser = MarkdownIt::new(); markdown_it::plugins::cmark::add(&mut parser); markdown_it::plugins::extra::add(&mut parser); markdown_it_block_spoiler::add(&mut parser); markdown_it_sub::add(&mut parser); markdown_it_sup::add(&mut parser); markdown_it_ruby::add(&mut parser); markdown_it_footnote::add(&mut parser); link_rule::add(&mut parser); identifier_rule::add(&mut parser); parser }); pub fn markdown_to_html(text: &str) -> String { MARKDOWN_PARSER.parse(text).xrender() } pub fn markdown_check_for_blocked_urls(text: &str, blocklist: &RegexSet) -> LemmyResult<()> { if blocklist.is_match(text) { return Err(LemmyErrorType::BlockedUrl.into()); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::utils::validation::check_urls_are_valid; use pretty_assertions::assert_eq; use regex::escape; #[test] fn test_basic_markdown() { let tests: Vec<_> = vec![ ( "rewrite community identifier", "!test@lemmy-alpha", "

!test@lemmy-alpha

\n", ), ( "rewrite user identifier", "@garda@lemmy-alpha", "

@garda@lemmy-alpha

\n", ), ( "headings", "# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6", "

h1

\n

h2

\n

h3

\n

h4

\n
h5
\n
h6
\n", ), ("line breaks", "First\rSecond", "

First\nSecond

\n"), ( "emphasis", "__bold__ **bold** *italic* ***bold+italic***", "

bold bold italic bold+italic

\n", ), ( "blockquotes", "> #### Hello\n > \n > - Hola\n > - 안영 \n>> Goodbye\n", "
\n

Hello

\n
    \n
  • Hola
  • \n
  • 안영
  • \n
\n
\n

Goodbye

\n
\n
\n", ), ( "lists (ordered, unordered)", "1. pen\n2. apple\n3. apple pen\n- pen\n- pineapple\n- pineapple pen", "
    \n
  1. pen
  2. \n
  3. apple
  4. \n
  5. apple pen
  6. \n
\n
    \n
  • pen
  • \n
  • pineapple
  • \n
  • pineapple pen
  • \n
\n", ), ( "code and code blocks", "this is my amazing `code snippet` and my amazing ```code block```", "

this is my amazing code snippet and my amazing code block

\n", ), // Links with added nofollow attribute ( "links", "[Lemmy](https://join-lemmy.org/ \"Join Lemmy!\")", "

Lemmy

\n", ), // Remote images with proxy ( "images", "![My linked image](https://example.com/image.png \"image alt text\")", "

\"My

\n", ), // Local images without proxy ( "images", "![My linked image](https://lemmy-alpha/image.png \"image alt text\")", "

\"My

\n", ), // Ensure spoiler plugin is added ( "basic spoiler", "::: spoiler click to see more\nhow spicy!\n:::\n", "
\n\nclick to see more\n\n

how spicy!

\n
\n", ), ( "escape html special chars", " hello &\"", "

<script>alert(‘xss’);</script> hello &"

\n", ), ("subscript", "log~2~(a)", "

log2(a)

\n"), ( "superscript", "Markdown^TM^", "

MarkdownTM

\n", ), ( "ruby text", "{漢|Kan}{字|ji}", "

(Kan)(ji)

\n", ), ( "footnotes", "Bold claim.[^1]\n\n[^1]: example.com", "

Bold claim.[1]

\n\
\n\
\n\
    \n\
  1. \n\

    example.com ↩︎

    \n\
  2. \n
\n
\n", ), ( "mention links", "[@example@example.com](https://example.com/u/example)", "

@example@example.com

\n", ), ( "dont add backslash escapes in urls", "[markdown link](https://en.wikipedia.org/wiki/Dragnet_(franchise))", "

markdown link

\n", ), ]; tests.iter().for_each(|&(msg, input, expected)| { let result = markdown_to_html(input); assert_eq!( result, expected, "Testing {}, with original input '{}'", msg, input ); }); } // This replicates the logic when saving url blocklist patterns and querying them. // Refer to lemmy_api_crud::site::update::update_site and // lemmy_api_common::utils::get_url_blocklist(). fn create_url_blocklist_test_regex_set(patterns: Vec<&str>) -> LemmyResult { let url_blocklist = patterns.iter().map(|&s| s.to_string()).collect(); let valid_urls = check_urls_are_valid(&url_blocklist)?; let regexes = valid_urls.iter().map(|p| format!(r"\b{}\b", escape(p))); let set = RegexSet::new(regexes)?; Ok(set) } #[test] fn test_url_blocking() -> LemmyResult<()> { let set = create_url_blocklist_test_regex_set(vec!["example.com/"])?; assert!( markdown_check_for_blocked_urls(&String::from("[](https://example.com)"), &set).is_err() ); assert!( markdown_check_for_blocked_urls( &String::from("Go to https://example.com to get free Robux"), &set ) .is_err() ); assert!( markdown_check_for_blocked_urls(&String::from("[](https://example.blog)"), &set).is_ok() ); assert!(markdown_check_for_blocked_urls(&String::from("example.com"), &set).is_err()); assert!( markdown_check_for_blocked_urls( "Odio exercitationem culpa sed sunt et. Sit et similique tempora deserunt doloremque. Cupiditate iusto repellat et quis qui. Cum veritatis facere quasi repellendus sunt eveniet nemo sint. Cumque sit unde est. https://example.com Alias repellendus at quos.", &set ) .is_err() ); let set = create_url_blocklist_test_regex_set(vec!["example.com/spam.jpg"])?; assert!(markdown_check_for_blocked_urls("![](https://example.com/spam.jpg)", &set).is_err()); assert!(markdown_check_for_blocked_urls("![](https://example.com/spam.jpg1)", &set).is_ok()); // TODO: the following should not be matched, scunthorpe problem. assert!( markdown_check_for_blocked_urls("![](https://example.com/spam.jpg.html)", &set).is_err() ); let set = create_url_blocklist_test_regex_set(vec![ r"quo.example.com/", r"foo.example.com/", r"bar.example.com/", ])?; assert!(markdown_check_for_blocked_urls("https://baz.example.com", &set).is_ok()); assert!(markdown_check_for_blocked_urls("https://bar.example.com", &set).is_err()); let set = create_url_blocklist_test_regex_set(vec!["example.com/banned_page"])?; assert!(markdown_check_for_blocked_urls("https://example.com/page", &set).is_ok()); let set = create_url_blocklist_test_regex_set(vec!["ex.mple.com/"])?; assert!(markdown_check_for_blocked_urls("example.com", &set).is_ok()); let set = create_url_blocklist_test_regex_set(vec!["rt.com/"])?; assert!(markdown_check_for_blocked_urls("deviantart.com", &set).is_ok()); assert!(markdown_check_for_blocked_urls("art.com.example.com", &set).is_ok()); assert!(markdown_check_for_blocked_urls("https://rt.com/abc", &set).is_err()); assert!(markdown_check_for_blocked_urls("go to rt.com.", &set).is_err()); assert!(markdown_check_for_blocked_urls("check out rt.computer", &set).is_ok()); // TODO: the following should not be matched, scunthorpe problem. assert!(markdown_check_for_blocked_urls("rt.com.example.com", &set).is_err()); Ok(()) } } ================================================ FILE: crates/utils/src/utils/mention.rs ================================================ use itertools::Itertools; use regex::Regex; use std::sync::LazyLock; #[expect(clippy::expect_used)] pub(crate) static MENTIONS_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"@(?P[\w.]+)@(?P[a-zA-Z0-9._:-]+)").expect("compile regex") }); // TODO nothing is done with community / group webfingers yet, so just ignore those for now #[derive(Clone, PartialEq, Eq, Hash)] pub struct MentionData { pub name: String, pub domain: String, } impl MentionData { pub fn is_local(&self, hostname: &str) -> bool { hostname.eq(&self.domain) } pub fn full_name(&self) -> String { format!("@{}@{}", &self.name, &self.domain) } } pub fn scrape_text_for_mentions(text: &str) -> Vec { let mut out: Vec = Vec::new(); for caps in MENTIONS_REGEX.captures_iter(text) { if let Some(name) = caps.name("name").map(|c| c.as_str().to_string()) && let Some(domain) = caps.name("domain").map(|c| c.as_str().to_string()) { out.push(MentionData { name, domain }); } } out.into_iter().unique().collect() } #[cfg(test)] #[expect(clippy::indexing_slicing)] mod test { use crate::utils::mention::scrape_text_for_mentions; use pretty_assertions::assert_eq; #[test] fn test_mentions_regex() { let text = "Just read a great blog post by [@tedu@honk.teduangst.com](/u/test). And another by !test_community@fish.teduangst.com . Another [@lemmy@lemmy-alpha:8540](/u/fish)"; let mentions = scrape_text_for_mentions(text); assert_eq!(mentions[0].name, "tedu".to_string()); assert_eq!(mentions[0].domain, "honk.teduangst.com".to_string()); assert_eq!(mentions[1].domain, "lemmy-alpha:8540".to_string()); } } ================================================ FILE: crates/utils/src/utils/mod.rs ================================================ pub mod markdown; pub mod mention; pub mod slurs; pub mod validation; ================================================ FILE: crates/utils/src/utils/slurs.rs ================================================ use crate::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; use regex::Regex; pub fn remove_slurs(test: &str, slur_regex: &Regex) -> String { slur_regex.replace_all(test, "*removed*").to_string() } pub(crate) fn slur_check<'a>(test: &'a str, slur_regex: &'a Regex) -> Result<(), Vec<&'a str>> { let mut matches: Vec<&str> = slur_regex.find_iter(test).map(|mat| mat.as_str()).collect(); // Unique matches.sort_unstable(); matches.dedup(); if matches.is_empty() { Ok(()) } else { Err(matches) } } pub fn check_slurs(text: &str, slur_regex: &Regex) -> LemmyResult<()> { if let Err(slurs) = slur_check(text, slur_regex) { Err(anyhow::anyhow!("{}", slurs_vec_to_str(&slurs))).with_lemmy_type(LemmyErrorType::Slurs) } else { Ok(()) } } pub fn check_slurs_opt(text: &Option, slur_regex: &Regex) -> LemmyResult<()> { match text { Some(t) => check_slurs(t, slur_regex), None => Ok(()), } } pub(crate) fn slurs_vec_to_str(slurs: &[&str]) -> String { let start = "No slurs - "; let combined = &slurs.join(", "); [start, combined].concat() } #[cfg(test)] mod test { use crate::{ error::LemmyResult, utils::slurs::{remove_slurs, slur_check, slurs_vec_to_str}, }; use pretty_assertions::assert_eq; use regex::RegexBuilder; #[test] fn test_slur_filter() -> LemmyResult<()> { let slur_regex = RegexBuilder::new(r"(fag(g|got|tard)?\b|cock\s?sucker(s|ing)?|ni[gq]{2}[e3]?r[sz]?|mudslime?s?|kikes?|\bspi(c|k)s?\b|\bchinks?|gooks?|bitch(es|ing|y)?|whor(es?|ing)|\btr(a|@)nn?(y|ies?)|\b(b|re|r)tard(ed)?s?)").case_insensitive(true).build()?; let test = "faggot test kike tranny cocksucker retardeds. Capitalized Niggerz. This is a bunch of other safe text."; let slur_free = "No slurs here"; assert_eq!( remove_slurs(test, &slur_regex), "*removed* test *removed* *removed* *removed* *removed*. Capitalized *removed*. This is a bunch of other safe text." .to_string() ); let has_slurs_vec = vec![ "Niggerz", "cocksucker", "faggot", "kike", "retardeds", "tranny", ]; let has_slurs_err_str = "No slurs - Niggerz, cocksucker, faggot, kike, retardeds, tranny"; assert_eq!(slur_check(test, &slur_regex), Err(has_slurs_vec)); assert_eq!(slur_check(slur_free, &slur_regex), Ok(())); if let Err(slur_vec) = slur_check(test, &slur_regex) { assert_eq!(&slurs_vec_to_str(&slur_vec), has_slurs_err_str); } Ok(()) } // These helped with testing // #[test] // fn test_send_email() { // let result = send_email("not a subject", "test_email@gmail.com", "ur user", "

HI // there

"); assert!(result.is_ok()); // } } ================================================ FILE: crates/utils/src/utils/validation.rs ================================================ use crate::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS}; use clearurls::UrlCleaner; use invisible_characters::INVISIBLE_CHARS; use itertools::Itertools; use regex::{Regex, RegexBuilder, RegexSet}; use std::sync::LazyLock; use unicode_segmentation::UnicodeSegmentation; use url::{ParseError, Url}; // From here: https://github.com/vector-im/element-android/blob/develop/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixPatterns.kt#L35 #[expect(clippy::expect_used)] static VALID_MATRIX_ID_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"^@[A-Za-z0-9\x21-\x39\x3B-\x7F]+:[A-Za-z0-9.-]+(:[0-9]{2,5})?$") .expect("compile regex") }); // taken from https://en.wikipedia.org/wiki/UTM_parameters #[expect(clippy::expect_used)] static URL_CLEANER: LazyLock = LazyLock::new(|| UrlCleaner::from_embedded_rules().expect("compile clearurls")); const ALLOWED_POST_URL_SCHEMES: [&str; 3] = ["http", "https", "magnet"]; const BODY_MAX_LENGTH: usize = 10000; const POST_BODY_MAX_LENGTH: usize = 50000; const BIO_MAX_LENGTH: usize = 1000; const URL_MAX_LENGTH: usize = 2000; const ALT_TEXT_MAX_LENGTH: usize = 1500; const SITE_NAME_MAX_LENGTH: usize = 20; const SITE_NAME_MIN_LENGTH: usize = 1; const SITE_SUMMARY_MAX_LENGTH: usize = 150; const MIN_LENGTH_BLOCKING_KEYWORD: usize = 3; const MAX_LENGTH_BLOCKING_KEYWORD: usize = 50; const ACTOR_NAME_MAX_LENGTH: usize = 20; const DISPLAY_NAME_MAX_LENGTH: usize = 50; fn has_newline(name: &str) -> bool { name.contains('\n') } pub fn is_valid_actor_name(name: &str) -> LemmyResult<()> { // Only allow characters from a single alphabet per username. This avoids problems with lookalike // characters like `o` which looks identical in Latin and Cyrillic, and can be used to imitate // other users. Checks for additional alphabets can be added in the same way. #[expect(clippy::expect_used)] static VALID_ACTOR_NAME_REGEX: LazyLock = LazyLock::new(|| { Regex::new(r"^(?:[a-zA-Z0-9_]+|[0-9_\p{Arabic}]+|[0-9_\p{Cyrillic}]+)$").expect("compile regex") }); min_length_check(name, 3, LemmyErrorType::InvalidName)?; max_length_check(name, ACTOR_NAME_MAX_LENGTH, LemmyErrorType::InvalidName)?; if VALID_ACTOR_NAME_REGEX.is_match(name) { Ok(()) } else { Err(LemmyErrorType::InvalidName.into()) } } fn has_3_permitted_display_chars(name: &str) -> bool { let mut num_non_fdc: i8 = 0; for c in name.chars() { if !INVISIBLE_CHARS.contains(&c) { num_non_fdc += 1; if num_non_fdc >= 3 { break; } } } if num_non_fdc >= 3 { return true; } false } // Can't do a regex here, reverse lookarounds not supported pub fn is_valid_display_name(name: &str) -> LemmyResult<()> { let check = !name.starts_with('@') && !name.starts_with(INVISIBLE_CHARS) && name.chars().count() <= DISPLAY_NAME_MAX_LENGTH && !has_newline(name) && has_3_permitted_display_chars(name); if !check { Err(LemmyErrorType::InvalidDisplayName.into()) } else { Ok(()) } } pub fn is_valid_matrix_id(matrix_id: &str) -> LemmyResult<()> { let check = VALID_MATRIX_ID_REGEX.is_match(matrix_id) && !has_newline(matrix_id); if !check { Err(LemmyErrorType::InvalidMatrixId.into()) } else { Ok(()) } } pub fn is_valid_post_title(title: &str) -> LemmyResult<()> { let length = title.trim().chars().count(); let check = (3..=200).contains(&length) && !has_newline(title) && has_3_permitted_display_chars(title); if !check { Err(LemmyErrorType::InvalidPostTitle.into()) } else { Ok(()) } } /// This could be post bodies, comments, notes, or any description field pub fn is_valid_body_field(body: &str, post: bool) -> LemmyResult<()> { if post { max_length_check(body, POST_BODY_MAX_LENGTH, LemmyErrorType::InvalidBodyField)?; } else { max_length_check(body, BODY_MAX_LENGTH, LemmyErrorType::InvalidBodyField)?; }; Ok(()) } pub fn is_valid_bio_field(bio: &str) -> LemmyResult<()> { max_length_check(bio, BIO_MAX_LENGTH, LemmyErrorType::BioLengthOverflow) } pub fn is_valid_alt_text_field(alt_text: &str) -> LemmyResult<()> { max_length_check( alt_text, ALT_TEXT_MAX_LENGTH, LemmyErrorType::AltTextLengthOverflow, )?; Ok(()) } /// Checks the site name length, the limit as defined in the DB. pub fn site_name_length_check(name: &str) -> LemmyResult<()> { min_length_check(name, SITE_NAME_MIN_LENGTH, LemmyErrorType::SiteNameRequired)?; max_length_check( name, SITE_NAME_MAX_LENGTH, LemmyErrorType::SiteNameLengthOverflow, ) } /// Checks the site / community description length, the limit as defined in the DB. pub fn summary_length_check(description: &str) -> LemmyResult<()> { max_length_check( description, SITE_SUMMARY_MAX_LENGTH, LemmyErrorType::SiteDescriptionLengthOverflow, ) } /// Check minimum and maximum length of input string. If the string is too short or too long, the /// corresponding error is returned. /// /// HTML frontends specify maximum input length using `maxlength` attribute. /// For consistency we use the same counting method (UTF-16 code units). /// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxlength fn max_length_check(item: &str, max_length: usize, max_msg: LemmyErrorType) -> LemmyResult<()> { let len = item.encode_utf16().count(); if len > max_length { Err(max_msg.into()) } else { Ok(()) } } fn min_length_check(item: &str, min_length: usize, min_msg: LemmyErrorType) -> LemmyResult<()> { let len = item.encode_utf16().count(); if len < min_length { Err(min_msg.into()) } else { Ok(()) } } /// Attempts to build a regex and check it for common errors before inserting into the DB. pub fn build_and_check_regex(regex_str_opt: Option<&str>) -> LemmyResult { // Placeholder regex which doesnt match anything // https://stackoverflow.com/a/940840 let match_nothing = RegexBuilder::new("a^") .build() .with_lemmy_type(LemmyErrorType::InvalidRegex); if let Some(regex) = regex_str_opt { if regex.is_empty() { match_nothing } else { let regex = RegexBuilder::new(regex) .case_insensitive(true) .build() .with_lemmy_type(LemmyErrorType::InvalidRegex)?; if regex.is_match("1") { Err(LemmyErrorType::PermissiveRegex.into()) } else { Ok(regex) } } } else { match_nothing } } /// Cleans a url of tracking parameters. pub fn clean_url(url: &Url) -> Url { match URL_CLEANER.clear_single_url(url) { Ok(res) => res.into_owned(), // If there are any errors, just return the original url Err(_) => url.clone(), } } /// Cleans all the links in a string of tracking parameters. pub fn clean_urls_in_text(text: &str) -> String { match URL_CLEANER.clear_text(text) { Ok(res) => res.into_owned(), // If there are any errors, just return the original text Err(_) => text.to_owned(), } } pub fn is_valid_url(url: &Url) -> LemmyResult<()> { if !ALLOWED_POST_URL_SCHEMES.contains(&url.scheme()) { return Err(LemmyErrorType::InvalidUrlScheme.into()); } max_length_check( url.as_str(), URL_MAX_LENGTH, LemmyErrorType::UrlLengthOverflow, )?; Ok(()) } pub fn is_url_blocked(url: &Url, blocklist: &RegexSet) -> LemmyResult<()> { if blocklist.is_match(url.as_str()) { return Err(LemmyErrorType::BlockedUrl.into()); } Ok(()) } /// Check that urls are valid, and also remove the scheme, and uniques pub fn check_urls_are_valid(urls: &Vec) -> LemmyResult> { let mut parsed_urls = vec![]; for url in urls { parsed_urls.push(build_url_str_without_scheme(url)?); } let unique_urls = parsed_urls.into_iter().unique().collect(); Ok(unique_urls) } pub fn check_blocking_keywords_are_valid(blocking_keywords: &Vec) -> LemmyResult<()> { for keyword in blocking_keywords { min_length_check( keyword, MIN_LENGTH_BLOCKING_KEYWORD, LemmyErrorType::BlockKeywordTooShort, )?; max_length_check( keyword, MAX_LENGTH_BLOCKING_KEYWORD, LemmyErrorType::BlockKeywordTooLong, )?; } check_api_elements_count(blocking_keywords.len())?; Ok(()) } fn build_url_str_without_scheme(url_str: &str) -> LemmyResult { // Parse and check for errors let mut url = Url::parse(url_str).or_else(|e| { if e == ParseError::RelativeUrlWithoutBase { Url::parse(&format!("http://{url_str}")) } else { Err(e) } })?; // Set the scheme to http, then remove the http:// part url .set_scheme("http") .map_err(|_e| LemmyErrorType::InvalidUrl)?; let mut out = url .to_string() .get(7..) .ok_or(LemmyErrorType::InvalidUrl)? .to_string(); // Remove trailing / if necessary if out.ends_with('/') { out.pop(); } Ok(out) } // Shorten a string to n chars, being mindful of unicode grapheme // boundaries // To understand the difference between chars and graphemes see: // https://hsivonen.fi/string-length/ fn truncate_for_db(text: &str, len: usize) -> String { if text.chars().count() <= len { text.to_string() } else { // Get the char at the desired `len` let char_at_len = text .char_indices() .nth(len) .unwrap_or(text.char_indices().last().unwrap_or_default()); let graphemes: Vec<(usize, _)> = text.grapheme_indices(true).collect(); let mut index = 0; // Walk the string backwards and find the first char within our length for idx in (0..graphemes.len()).rev() { if let Some(grapheme) = graphemes.get(idx) && grapheme.0 < char_at_len.0 { index = idx; break; } } let grapheme_at_index = graphemes.get(index).unwrap_or(&(0, "")); // The char count of the grapheme at the very end of the range let grapheme_at_index_count = grapheme_at_index.1.chars().count(); // Count the total chars within the selected grapheme range let char_sum = graphemes .get(0..index) .unwrap_or_default() .iter() .map(|(_, g)| g.chars().count()) .sum(); // Get the actual count of chars we need to take from `text`. // `take` isn't inclusive, so if the last grapheme can fit we add its char // length let char_total = if char_sum + grapheme_at_index_count <= len { char_sum + grapheme_at_index_count } else { char_sum }; text.chars().take(char_total).collect::() } } pub fn truncate_summary(text: &str) -> String { truncate_for_db(text, SITE_SUMMARY_MAX_LENGTH) } pub fn check_api_elements_count(len: usize) -> LemmyResult<()> { if len >= MAX_API_PARAM_ELEMENTS { return Err(LemmyErrorType::TooManyItems.into()); } Ok(()) } #[cfg(test)] mod tests { use crate::{ error::{LemmyErrorType, LemmyResult}, utils::validation::{ BIO_MAX_LENGTH, SITE_NAME_MAX_LENGTH, SITE_SUMMARY_MAX_LENGTH, URL_MAX_LENGTH, build_and_check_regex, check_urls_are_valid, clean_url, clean_urls_in_text, is_url_blocked, is_valid_actor_name, is_valid_bio_field, is_valid_display_name, is_valid_matrix_id, is_valid_post_title, is_valid_url, site_name_length_check, summary_length_check, truncate_for_db, }, }; use pretty_assertions::assert_eq; use url::Url; const URL_WITH_TRACKING: &str = "https://example.com/path/123?utm_content=buffercf3b2&utm_medium=social&user+name=random+user&id=123"; const URL_TRACKING_REMOVED: &str = "https://example.com/path/123?user+name=random+user&id=123"; #[test] fn test_clean_url_params() -> LemmyResult<()> { let url = Url::parse(URL_WITH_TRACKING)?; let cleaned = clean_url(&url); let expected = Url::parse(URL_TRACKING_REMOVED)?; assert_eq!(expected.to_string(), cleaned.to_string()); let url = Url::parse("https://example.com/path/123")?; let cleaned = clean_url(&url); assert_eq!(url.to_string(), cleaned.to_string()); Ok(()) } #[test] fn test_clean_body() -> LemmyResult<()> { let text = format!("[a link]({URL_WITH_TRACKING})"); let cleaned = clean_urls_in_text(&text); let expected = format!("[a link]({URL_TRACKING_REMOVED})"); assert_eq!(expected.clone(), cleaned.clone()); let text = "[a link](https://example.com/path/123)"; let cleaned = clean_urls_in_text(text); assert_eq!(text.to_string(), cleaned); Ok(()) } #[test] fn regex_checks() { assert!(is_valid_post_title("hi").is_err()); assert!(is_valid_post_title("him").is_ok()); assert!(is_valid_post_title(" him ").is_ok()); assert!(is_valid_post_title("n\n\n\n\nanother").is_err()); assert!(is_valid_post_title("hello there!\n this is a test.").is_err()); assert!(is_valid_post_title("hello there! this is a test.").is_ok()); assert!(is_valid_post_title(("12345".repeat(40) + "x").as_str()).is_err()); assert!(is_valid_post_title("12345".repeat(40).as_str()).is_ok()); assert!(is_valid_post_title((("12345".repeat(40)) + " ").as_str()).is_ok()); } #[test] fn test_valid_actor_name() { assert!(is_valid_actor_name("Hello_98",).is_ok()); assert!(is_valid_actor_name("ten",).is_ok()); assert!(is_valid_actor_name("تجريب",).is_ok()); assert!(is_valid_actor_name("تجريب_123",).is_ok()); assert!(is_valid_actor_name("Владимир",).is_ok()); // mixed scripts assert!(is_valid_actor_name("تجريب_abc",).is_err()); assert!(is_valid_actor_name("Влад_abc",).is_err()); // dash assert!(is_valid_actor_name("Hello-98",).is_err()); // too short assert!(is_valid_actor_name("a",).is_err()); // empty assert!(is_valid_actor_name("",).is_err()); // newline assert!( is_valid_actor_name( r"Line1 Line3", ) .is_err() ); assert!(is_valid_actor_name("Line1\nLine3",).is_err()); } #[test] fn test_valid_display_name() { assert!(is_valid_display_name("hello @there").is_ok()); assert!(is_valid_display_name("@hello there").is_err()); assert!(is_valid_display_name("\u{200d}hello").is_err()); assert!(is_valid_display_name("\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}Name").is_ok()); assert!(is_valid_display_name("\u{2003}1\u{ffa0}2\u{200d}").is_err()); // Make sure zero-space with an @ doesn't work assert!(is_valid_display_name(&format!("{}@my name is", '\u{200b}')).is_err()); } #[test] fn test_valid_post_title() { assert!(is_valid_post_title("Post Title").is_ok()); assert!( is_valid_post_title( "აშშ ითხოვს ირანს დაუყოვნებლივ გაანთავისუფლოს დაკავებული ნავთობის ტანკერი" ) .is_ok() ); assert!(is_valid_post_title(" POST TITLE 😃😃😃😃😃").is_ok()); assert!(is_valid_post_title("\n \n \n \n ").is_err()); // tabs/spaces/newlines assert!(is_valid_post_title("\u{206a}").is_err()); // invisible chars assert!(is_valid_post_title("\u{1f3f3}\u{fe0f}\u{200d}\u{26a7}\u{fe0f}").is_ok()); } #[test] fn test_valid_matrix_id() { assert!(is_valid_matrix_id("@dess:matrix.org").is_ok()); assert!(is_valid_matrix_id("@dess_:matrix.org").is_ok()); assert!(is_valid_matrix_id("@dess:matrix.org:443").is_ok()); assert!(is_valid_matrix_id("dess:matrix.org").is_err()); assert!(is_valid_matrix_id(" @dess:matrix.org").is_err()); assert!(is_valid_matrix_id("@dess:matrix.org t").is_err()); assert!(is_valid_matrix_id("@dess:matrix.org t").is_err()); } #[test] fn test_valid_site_name() -> LemmyResult<()> { let valid_names = [ (0..SITE_NAME_MAX_LENGTH).map(|_| 'A').collect::(), String::from("A"), ]; let invalid_names = [ ( &(0..SITE_NAME_MAX_LENGTH + 1) .map(|_| 'A') .collect::(), LemmyErrorType::SiteNameLengthOverflow, ), (&String::new(), LemmyErrorType::SiteNameRequired), ]; valid_names.iter().for_each(|valid_name| { assert!( site_name_length_check(valid_name).is_ok(), "Expected {} of length {} to be Ok.", valid_name, valid_name.len() ) }); invalid_names .iter() .for_each(|(invalid_name, expected_err)| { let result = site_name_length_check(invalid_name); assert!(result.is_err()); assert!( result.is_err_and(|e| e.error_type.eq(&expected_err.clone())), "Testing {}, expected error {}", invalid_name, expected_err ); }); Ok(()) } #[test] fn test_valid_bio() { assert!(is_valid_bio_field(&(0..BIO_MAX_LENGTH).map(|_| 'A').collect::()).is_ok()); let invalid_result = is_valid_bio_field(&(0..BIO_MAX_LENGTH + 1).map(|_| 'A').collect::()); assert!( invalid_result.is_err() && invalid_result.is_err_and(|e| e.error_type.eq(&LemmyErrorType::BioLengthOverflow)) ); } #[test] fn test_valid_site_description() { assert!( summary_length_check( &(0..SITE_SUMMARY_MAX_LENGTH) .map(|_| 'A') .collect::() ) .is_ok() ); let invalid_result = summary_length_check( &(0..SITE_SUMMARY_MAX_LENGTH + 1) .map(|_| 'A') .collect::(), ); assert!( invalid_result.is_err() && invalid_result.is_err_and(|e| e .error_type .eq(&LemmyErrorType::SiteDescriptionLengthOverflow)) ); } #[test] fn test_valid_slur_regex() -> LemmyResult<()> { let valid_regex = Some("(foo|bar)"); build_and_check_regex(valid_regex)?; let missing_regex = None; let match_none = build_and_check_regex(missing_regex)?; assert!(!match_none.is_match("")); assert!(!match_none.is_match("a")); let empty = Some(""); let match_none = build_and_check_regex(empty)?; assert!(!match_none.is_match("")); assert!(!match_none.is_match("a")); Ok(()) } #[test] fn test_too_permissive_slur_regex() { let match_everything_regexes = [ (Some("["), LemmyErrorType::InvalidRegex), (Some("(foo|bar|)"), LemmyErrorType::PermissiveRegex), (Some(".*"), LemmyErrorType::PermissiveRegex), ]; match_everything_regexes .into_iter() .for_each(|(regex_str, expected_err)| { let result = build_and_check_regex(regex_str); assert!(result.is_err()); assert!( result.is_err_and(|e| e.error_type.eq(&expected_err.clone())), "Testing regex {:?}, expected error {}", regex_str, expected_err ); }); } #[test] fn test_check_url_valid() -> LemmyResult<()> { assert!(is_valid_url(&Url::parse("http://example.com")?).is_ok()); assert!(is_valid_url(&Url::parse("https://example.com")?).is_ok()); assert!(is_valid_url(&Url::parse("https://example.com")?).is_ok()); assert!( is_valid_url(&Url::parse("ftp://example.com")?) .is_err_and(|e| e.error_type.eq(&LemmyErrorType::InvalidUrlScheme)) ); assert!( is_valid_url(&Url::parse("javascript:void")?) .is_err_and(|e| e.error_type.eq(&LemmyErrorType::InvalidUrlScheme)) ); let magnet_link = "magnet:?xt=urn:btih:4b390af3891e323778959d5abfff4b726510f14c&dn=Ravel%20Complete%20Piano%20Sheet%20Music%20-%20Public%20Domain&tr=udp%3A%2F%2Fopen.tracker.cl%3A1337%2Fannounce"; assert!(is_valid_url(&Url::parse(magnet_link)?).is_ok()); // Also make sure the length overflow hits an error let mut long_str = "http://example.com/test=".to_string(); for _ in 1..URL_MAX_LENGTH { long_str.push('X'); } let long_url = Url::parse(&long_str)?; assert!( is_valid_url(&long_url).is_err_and(|e| e.error_type.eq(&LemmyErrorType::UrlLengthOverflow)) ); Ok(()) } #[test] fn test_url_block() -> LemmyResult<()> { let set = regex::RegexSet::new(vec![ r"(https://)?example\.org/page/to/article", r"(https://)?example\.net/?", r"(https://)?example\.com/?", ])?; assert!(is_url_blocked(&Url::parse("https://example.blog")?, &set).is_ok()); assert!(is_url_blocked(&Url::parse("https://example.org")?, &set).is_ok()); assert!(is_url_blocked(&Url::parse("https://example.com")?, &set).is_err()); Ok(()) } #[test] fn test_url_parsed() -> LemmyResult<()> { // Make sure the scheme is removed, and uniques also assert_eq!( &check_urls_are_valid(&vec![ "example.com".to_string(), "http://example.com".to_string(), "https://example.com".to_string(), "https://example.com/test?q=test2&q2=test3#test4".to_string(), ])?, &vec![ "example.com".to_string(), "example.com/test?q=test2&q2=test3#test4".to_string() ], ); assert!(check_urls_are_valid(&vec!["https://example .com".to_string()]).is_err()); Ok(()) } #[test] fn test_truncate() -> LemmyResult<()> { assert_eq!("Hell", truncate_for_db("Hello", 4)); assert_eq!("word", truncate_for_db("word", 10)); assert_eq!("Wales: ", truncate_for_db("Wales: 🏴󠁧󠁢󠁷󠁬󠁳󠁿", 10)); assert_eq!("Wales: 🏴󠁧󠁢󠁷󠁬󠁳󠁿", truncate_for_db("Wales: 🏴󠁧󠁢󠁷󠁬󠁳󠁿", 14)); assert_eq!("it’s", truncate_for_db("it’s like this", 4)); assert_eq!("🤦🏼‍♂️150", truncate_for_db("🤦🏼‍♂️150🤦🏼‍♂️", 11)); Ok(()) } } ================================================ FILE: crates/utils/tests/test_errors_used.rs ================================================ use lemmy_utils::error::LemmyErrorType; use std::{env::current_dir, process::Command}; use strum::IntoEnumIterator; #[test] #[expect(clippy::unwrap_used, clippy::tests_outside_test_module)] fn test_errors_used() { let mut unused_error_found = false; let mut current_dir = current_dir().unwrap(); current_dir.pop(); current_dir.pop(); for error in LemmyErrorType::iter() { let search = format!("LemmyErrorType::{error}"); let mut grep_all = Command::new("grep"); let grep_all = grep_all .current_dir(current_dir.clone()) .arg("-R") .arg("--exclude=error.rs") .arg(&search) .arg("crates/"); let output = grep_all.output().unwrap(); let grep_all_out = std::str::from_utf8(&output.stdout).unwrap(); let mut grep_apub = Command::new("grep"); let grep_apub = grep_apub .current_dir(current_dir.clone()) .arg("-R") .arg("--exclude-dir=api") .arg(&search) .arg("crates/apub/"); let output = grep_apub.output().unwrap(); let grep_apub_out = std::str::from_utf8(&output.stdout).unwrap(); if grep_all_out.is_empty() { println!("LemmyErrorType::{} is unused", error); unused_error_found = true; } if search != "LemmyErrorType::UntranslatedError" && grep_all_out == grep_apub_out { println!("LemmyErrorType::{} is only used for federation", error); unused_error_found = true; } } assert!(!unused_error_found); } ================================================ FILE: diesel.toml ================================================ [print_schema] file = "crates/db_schema_file/src/schema.rs" patch_file = "crates/db_schema_file/diesel_ltree.patch" # Required for https://github.com/adwhit/diesel-derive-enum custom_type_derives = ["diesel::query_builder::QueryId"] # This table is in the lemmy_diesel_utils crate instead. filter = { except_tables = ["previously_run_sql"] } allow_tables_to_appear_in_same_query_config = "fk_related_tables" ================================================ FILE: docker/Dockerfile ================================================ # syntax=docker/dockerfile:1.20 ARG RUST_VERSION=1.94 ARG CARGO_BUILD_FEATURES=default ARG RUST_RELEASE_MODE=debug ARG BUILDER_IMAGE=lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} ARG RUNNER_IMAGE=debian:sid-slim ARG UNAME=lemmy ARG UID=1000 ARG GID=1000 # Chef FROM ${BUILDER_IMAGE} AS chef WORKDIR /lemmy # Planner FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json # Builder FROM chef AS builder ARG CARGO_BUILD_FEATURES ARG RUST_RELEASE_MODE ARG RUSTFLAGS ARG CI_PIPELINE_EVENT COPY --from=planner /lemmy/recipe.json recipe.json # Build dependencies - this is the caching Docker layer! RUN if [ "${RUST_RELEASE_MODE}" = "release" ]; \ then \ cargo chef cook --recipe-path recipe.json --release; \ else \ cargo chef cook --recipe-path recipe.json; \ fi COPY . . # Release build RUN if [ "${RUST_RELEASE_MODE}" = "release" ]; \ then \ cargo build --features "${CARGO_BUILD_FEATURES}" --release; \ mv target/release/lemmy_server .; \ else \ cargo build --features "${CARGO_BUILD_FEATURES}"; \ mv target/debug/lemmy_server .; \ fi # Runner FROM ${RUNNER_IMAGE} AS runner # Add system packages that are needed: federation needs CA certificates, curl can be used for healthchecks RUN apt update && apt install -y libssl-dev libpq-dev ca-certificates curl git COPY --from=builder --chmod=0755 /lemmy/lemmy_server /usr/local/bin LABEL org.opencontainers.image.authors="The Lemmy Authors" LABEL org.opencontainers.image.source="https://github.com/LemmyNet/lemmy" LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" LABEL org.opencontainers.image.description="A link aggregator and forum for the fediverse" ARG UNAME ARG GID ARG UID RUN groupadd -g ${GID} -o ${UNAME} && \ useradd -m -u ${UID} -g ${GID} -o -s /bin/bash ${UNAME} USER $UNAME ENTRYPOINT ["lemmy_server"] EXPOSE 8536 STOPSIGNAL SIGTERM ================================================ FILE: docker/README.md ================================================ # Building Lemmy Images Lemmy's images are meant to be **built** on `linux/amd64`, but they can be **executed** on both `linux/amd64` and `linux/arm64`. To do so we need to use a _cross toolchain_ whose goal is to build **from** amd64 **to** arm64. Namely, we need to link the _lemmy_server_ with `pq` and `openssl` shared libraries and a few others, and they need to be in `arm64`, indeed. The toolchain we use to cross-compile is specifically tailored for Lemmy's needs, see [the image repository][image-repo]. #### References - [The Linux Documentation Project on Shared Libraries][tldp-lib] [tldp-lib]: https://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html [image-repo]: https://github.com/raskyld/lemmy-cross-toolchains ================================================ FILE: docker/customPostgresql.conf ================================================ # You can use https://pgtune.leopard.in.ua to tune this for your system. # DB Version: 16 # OS Type: linux # DB Type: web # Total Memory (RAM): 12 GB # CPUs num: 16 # Data Storage: ssd max_connections = 200 shared_buffers = 3GB effective_cache_size = 9GB maintenance_work_mem = 768MB checkpoint_completion_target = 0.9 wal_buffers = 16MB default_statistics_target = 100 random_page_cost = 1.1 effective_io_concurrency = 200 work_mem = 3932kB huge_pages = try min_wal_size = 1GB max_wal_size = 8GB max_worker_processes = 16 max_parallel_workers_per_gather = 4 max_parallel_workers = 16 max_parallel_maintenance_workers = 4 # Listen address listen_addresses = '*' # Logging session_preload_libraries = auto_explain auto_explain.log_min_duration = 5ms auto_explain.log_analyze = true auto_explain.log_triggers = true track_activity_query_size = 1048576 ================================================ FILE: docker/docker-compose.yml ================================================ x-logging: &default-logging driver: "json-file" options: max-size: "50m" max-file: "4" services: proxy: image: nginx:1-alpine ports: # actual and only port facing any connection from outside # Note, change the left number if port 1236 is already in use on your system # You could use port 80 if you won't use a reverse proxy - "1236:1236" - "8536:8536" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro,Z restart: unless-stopped depends_on: - pictrs - lemmy-ui logging: *default-logging lemmy: build: context: ../ dockerfile: docker/Dockerfile hostname: lemmy restart: unless-stopped environment: - RUST_LOG=warn,extism=info,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug - LEMMY_DISABLE_ACTIVITY_SENDING=true volumes: - ./lemmy.hjson:/config/config.hjson:Z - ./plugins:/plugins:Z depends_on: - postgres - pictrs logging: *default-logging lemmy-ui: # use "image" to pull down an already compiled lemmy-ui. make sure to comment out "build". image: dessalines/lemmy-ui:nightly # platform: linux/x86_64 # no arm64 support. uncomment platform if using m1. # use "build" to build your local lemmy ui image for development. make sure to comment out "image". # run: docker compose up --build # build: # context: ../../lemmy-ui # assuming lemmy-ui is cloned besides lemmy directory # dockerfile: dev.dockerfile environment: # this needs to match the hostname defined in the lemmy service - LEMMY_UI_BACKEND=lemmy:8536 # set the outside hostname here - LEMMY_UI_HTTPS=false - LEMMY_UI_ERUDA=true depends_on: - lemmy restart: unless-stopped logging: *default-logging init: true pictrs: image: asonix/pictrs:0.5.17-pre.9 # this needs to match the pictrs url in lemmy.hjson hostname: pictrs # we can set options to pictrs like this, here we set max. image size and forced format for conversion # entrypoint: /sbin/tini -- /usr/local/bin/pict-rs -p /mnt -m 4 --image-format webp environment: - PICTRS_OPENTELEMETRY_URL=http://otel:4137 - PICTRS__SERVER__API_KEY=my-pictrs-key - PICTRS__MEDIA__VIDEO_CODEC=vp9 - PICTRS__MEDIA__GIF__MAX_WIDTH=256 - PICTRS__MEDIA__GIF__MAX_HEIGHT=256 - PICTRS__MEDIA__GIF__MAX_AREA=65536 - PICTRS__MEDIA__GIF__MAX_FRAME_COUNT=400 user: 991:991 volumes: - ./volumes/pictrs:/mnt:Z restart: unless-stopped logging: *default-logging postgres: image: pgautoupgrade/pgautoupgrade:18-alpine # this needs to match the database host in lemmy.hson # Tune your settings via # https://pgtune.leopard.in.ua/#/ # You can use this technique to add them here # https://stackoverflow.com/a/30850095/1655478 hostname: postgres command: postgres -c config_file=/etc/postgresql.conf ports: # use a different port so it doesn't conflict with potential postgres db running on the host - "5433:5432" environment: - POSTGRES_USER=lemmy - POSTGRES_PASSWORD=password - POSTGRES_DB=lemmy volumes: - ./volumes/postgres:/var/lib/postgresql:Z - ./customPostgresql.conf:/etc/postgresql.conf:Z restart: unless-stopped logging: *default-logging ================================================ FILE: docker/docker_db_backup.sh ================================================ #!/usr/bin/env bash docker-compose exec postgres pg_dumpall -c -U lemmy >dump_$(date +%Y-%m-%d"_"%H_%M_%S).sql ================================================ FILE: docker/docker_update.sh ================================================ #!/bin/sh set -e Help() { # Display help echo "Usage: ./docker_update.sh [OPTIONS]" echo "" echo "Start all docker containers required to run Lemmy." echo "" echo "Options:" echo "-u Docker username. Only required if managing Docker via Docker Desktop with a personal access token." echo "-h Print this help." } while getopts ":hu:" option; do case $option in h) Help exit ;; u) DOCKER_USER=$OPTARG ;; *) echo "Invalid option $OPTARG." exit ;; esac done LOG_PREFIX="[🐀 lemmy]" ARCH=$(uname -m 2>/dev/null || echo 'unknown') # uname may not exist on windows machines; default to unknown to be safe. mkdir -p volumes/pictrs echo "$LOG_PREFIX Please provide your password to change ownership of the pictrs volume." sudo chown -R 991:991 volumes/pictrs if [ "$ARCH" = 'arm64' ]; then echo "$LOG_PREFIX WARN: If building from images, make sure to uncomment 'platform' in the docker-compose.yml file!" # You need a Docker account to pull images. Otherwise, you will get an error like: "error getting credentials" if [ -z "$DOCKER_USER" ]; then echo "$LOG_PREFIX Logging into Docker Hub..." docker login else echo "$LOG_PREFIX Logging into Docker Hub. Please provide your personal access token." docker login --username="$DOCKER_USER" fi echo "$LOG_PREFIX Initializing images in the background. Please be patient if compiling from source..." docker compose up --build else sudo docker compose up --build fi echo "$LOG_PREFIX Complete! You can now access the UI at http://localhost:1236." ================================================ FILE: docker/federation/docker-compose.yml ================================================ version: "3.7" x-ui-default: &ui-default init: true image: dessalines/lemmy-ui:0.19.14 # assuming lemmy-ui is cloned besides lemmy directory # build: # context: ../../../lemmy-ui # dockerfile: dev.dockerfile environment: - LEMMY_UI_HTTPS=false x-lemmy-default: &lemmy-default build: context: ../.. dockerfile: docker/Dockerfile environment: - RUST_BACKTRACE=1 - RUST_LOG="warn,lemmy_server=debug,lemmy_api=debug,lemmy_api_common=debug,lemmy_api_crud=debug,lemmy_apub=debug,lemmy_db_schema=debug,lemmy_db_views=debug,lemmy_routes=debug,lemmy_utils=debug,lemmy_websocket=debug" restart: always x-postgres-default: &postgres-default image: pgautoupgrade/pgautoupgrade:18-alpine environment: - POSTGRES_USER=lemmy - POSTGRES_PASSWORD=password - POSTGRES_DB=lemmy restart: always services: nginx: image: nginx:1-alpine ports: - "8540:8540" - "8550:8550" - "8560:8560" - "8570:8570" - "8580:8580" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:Z restart: always depends_on: - pictrs - lemmy-alpha-ui - lemmy-beta-ui - lemmy-gamma-ui - lemmy-delta-ui - lemmy-epsilon-ui pictrs: restart: always image: asonix/pictrs:0.5.17-pre.9 user: 991:991 volumes: - ./volumes/pictrs_alpha:/mnt:Z environment: - PICTRS__SERVER__API_KEY=my-pictrs-key lemmy-alpha-ui: <<: *ui-default environment: - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-alpha:8541 - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8541 depends_on: - lemmy-alpha lemmy-alpha: <<: *lemmy-default volumes: - ./lemmy_alpha.hjson:/config/config.hjson:Z depends_on: - postgres_alpha ports: - "8541:8541" postgres_alpha: <<: *postgres-default volumes: - ./volumes/postgres_alpha:/var/lib/postgresql:Z lemmy-beta-ui: <<: *ui-default environment: - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-beta:8551 - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8551 depends_on: - lemmy-beta lemmy-beta: <<: *lemmy-default volumes: - ./lemmy_beta.hjson:/config/config.hjson:Z depends_on: - postgres_beta ports: - "8551:8551" postgres_beta: <<: *postgres-default volumes: - ./volumes/postgres_beta:/var/lib/postgresql:Z lemmy-gamma-ui: <<: *ui-default environment: - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-gamma:8561 - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8561 depends_on: - lemmy-gamma lemmy-gamma: <<: *lemmy-default volumes: - ./lemmy_gamma.hjson:/config/config.hjson:Z depends_on: - postgres_gamma ports: - "8561:8561" postgres_gamma: <<: *postgres-default volumes: - ./volumes/postgres_gamma:/var/lib/postgresql:Z # An instance with only an allowlist for beta lemmy-delta-ui: <<: *ui-default environment: - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-delta:8571 - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8571 depends_on: - lemmy-delta lemmy-delta: <<: *lemmy-default volumes: - ./lemmy_delta.hjson:/config/config.hjson:Z depends_on: - postgres_delta ports: - "8571:8571" postgres_delta: <<: *postgres-default volumes: - ./volumes/postgres_delta:/var/lib/postgresql:Z # An instance who has a blocklist, with lemmy-alpha blocked lemmy-epsilon-ui: <<: *ui-default environment: - LEMMY_UI_LEMMY_INTERNAL_HOST=lemmy-epsilon:8581 - LEMMY_UI_LEMMY_EXTERNAL_HOST=localhost:8581 depends_on: - lemmy-epsilon lemmy-epsilon: <<: *lemmy-default volumes: - ./lemmy_epsilon.hjson:/config/config.hjson:Z depends_on: - postgres_epsilon ports: - "8581:8581" postgres_epsilon: <<: *postgres-default volumes: - ./volumes/postgres_epsilon:/var/lib/postgresql:Z ================================================ FILE: docker/federation/lemmy_alpha.hjson ================================================ { hostname: lemmy-alpha:8541 port: 8541 tls_enabled: false setup: { admin_username: lemmy_alpha admin_password: lemmylemmy site_name: lemmy-alpha } database: { connection: "postgres://lemmy:password@postgres_alpha:5432/lemmy" } pictrs: { api_key: "my-pictrs-key" } } ================================================ FILE: docker/federation/lemmy_beta.hjson ================================================ { hostname: lemmy-beta:8551 port: 8551 tls_enabled: false setup: { admin_username: lemmy_beta admin_password: lemmylemmy site_name: lemmy-beta } database: { connection: "postgres://lemmy:password@postgres_beta:5432/lemmy" } pictrs: { api_key: "my-pictrs-key" } } ================================================ FILE: docker/federation/lemmy_delta.hjson ================================================ { hostname: lemmy-delta:8571 port: 8571 tls_enabled: false setup: { admin_username: lemmy_delta admin_password: lemmylemmy site_name: lemmy-delta } database: { connection: "postgres://lemmy:password@postgres_delta:5432/lemmy" } pictrs: { api_key: "my-pictrs-key" } } ================================================ FILE: docker/federation/lemmy_epsilon.hjson ================================================ { hostname: lemmy-epsilon:8581 port: 8581 tls_enabled: false setup: { admin_username: lemmy_epsilon admin_password: lemmylemmy site_name: lemmy-epsilon } database: { connection: "postgres://lemmy:password@postgres_epsilon:5432/lemmy" } pictrs: { api_key: "my-pictrs-key" } plugins: [{ file: "https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.1/go_replace_words.wasm" hash: "37cdc01a3ff26eef578b668c6cc57fc06649deddb3a92cb6bae8e79b4e60fe12" }] } ================================================ FILE: docker/federation/lemmy_gamma.hjson ================================================ { hostname: lemmy-gamma:8561 port: 8561 tls_enabled: false setup: { admin_username: lemmy_gamma admin_password: lemmylemmy site_name: lemmy-gamma } database: { connection: "postgres://lemmy:password@postgres_gamma:5432/lemmy" } pictrs: { api_key: "my-pictrs-key" } } ================================================ FILE: docker/federation/nginx.conf ================================================ events { worker_connections 1024; } http { upstream lemmy-alpha { server "lemmy-alpha:8541"; } upstream lemmy-alpha-ui { server "lemmy-alpha-ui:1234"; } server { listen 8540; server_name 127.0.0.1; access_log off; # Upload limit for pictshare client_max_body_size 50M; location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) { proxy_pass http://lemmy-alpha; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { set $proxpass http://lemmy-alpha-ui; if ($http_accept = "application/activity+json") { set $proxpass http://lemmy-alpha; } if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { set $proxpass http://lemmy-alpha; } proxy_pass $proxpass; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Cuts off the trailing slash on URLs to make them valid rewrite ^(.+)/+$ $1 permanent; } } upstream lemmy-beta { server "lemmy-beta:8551"; } upstream lemmy-beta-ui { server "lemmy-beta-ui:1234"; } server { listen 8550; server_name 127.0.0.1; access_log off; # Upload limit for pictshare client_max_body_size 50M; location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) { proxy_pass http://lemmy-beta; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { set $proxpass http://lemmy-beta-ui; if ($http_accept = "application/activity+json") { set $proxpass http://lemmy-beta; } if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { set $proxpass http://lemmy-beta; } proxy_pass $proxpass; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Cuts off the trailing slash on URLs to make them valid rewrite ^(.+)/+$ $1 permanent; } } upstream lemmy-gamma { server "lemmy-gamma:8561"; } upstream lemmy-gamma-ui { server "lemmy-gamma-ui:1234"; } server { listen 8560; server_name 127.0.0.1; access_log off; # Upload limit for pictshare client_max_body_size 50M; location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) { proxy_pass http://lemmy-gamma; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { set $proxpass http://lemmy-gamma-ui; if ($http_accept = "application/activity+json") { set $proxpass http://lemmy-gamma; } if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { set $proxpass http://lemmy-gamma; } proxy_pass $proxpass; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Cuts off the trailing slash on URLs to make them valid rewrite ^(.+)/+$ $1 permanent; } } upstream lemmy-delta { server "lemmy-delta:8571"; } upstream lemmy-delta-ui { server "lemmy-delta-ui:1234"; } server { listen 8570; server_name 127.0.0.1; access_log off; # Upload limit for pictshare client_max_body_size 50M; location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) { proxy_pass http://lemmy-delta; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { set $proxpass http://lemmy-delta-ui; if ($http_accept = "application/activity+json") { set $proxpass http://lemmy-delta; } if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { set $proxpass http://lemmy-delta; } proxy_pass $proxpass; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Cuts off the trailing slash on URLs to make them valid rewrite ^(.+)/+$ $1 permanent; } } upstream lemmy-epsilon { server "lemmy-epsilon:8581"; } upstream lemmy-epsilon-ui { server "lemmy-epsilon-ui:1234"; } server { listen 8580; server_name 127.0.0.1; access_log off; # Upload limit for pictshare client_max_body_size 50M; location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) { proxy_pass http://lemmy-epsilon; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location / { set $proxpass http://lemmy-epsilon-ui; if ($http_accept = "application/activity+json") { set $proxpass http://lemmy-epsilon; } if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { set $proxpass http://lemmy-epsilon; } proxy_pass $proxpass; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Cuts off the trailing slash on URLs to make them valid rewrite ^(.+)/+$ $1 permanent; } } } ================================================ FILE: docker/federation/start-local-instances.bash ================================================ #!/bin/bash set -e sudo docker compose down for Item in alpha beta gamma delta epsilon; do sudo mkdir -p volumes/pictrs_$Item sudo chown -R 991:991 volumes/pictrs_$Item done sudo docker compose up --build ================================================ FILE: docker/lemmy.hjson ================================================ { # for more info about the config, check out the documentation # https://join-lemmy.org/docs/en/administration/configuration.html # This is a minimal lemmy config for the dev / main branch. Do not use for a # release / stable version. setup: { admin_username: "lemmy" admin_password: "lemmylemmy" site_name: "lemmy-dev" } database: { connection: "postgres://lemmy:password@postgres:5432/lemmy" } hostname: "localhost" bind: "0.0.0.0" port: 8536 pictrs: { url: "http://pictrs:8080/" api_key: "my-pictrs-key" } #opentelemetry_url: "http://otel:4137" } ================================================ FILE: docker/nginx.conf ================================================ worker_processes 1; events { worker_connections 1024; } http { upstream lemmy { # this needs to map to the lemmy (server) docker service hostname server "lemmy:8536"; } upstream lemmy-ui { # this needs to map to the lemmy-ui docker service hostname server "lemmy-ui:1234"; } server { # this is the port inside docker, not the public one yet listen 1236; listen 8536; # change if needed, this is facing the public web server_name localhost; server_tokens off; gzip on; gzip_types text/css application/javascript image/svg+xml; gzip_vary on; # Upload limit, relevant for pictrs client_max_body_size 20M; add_header X-Frame-Options SAMEORIGIN; add_header X-Content-Type-Options nosniff; add_header X-XSS-Protection "1; mode=block"; # frontend general requests location / { # distinguish between ui requests and backend # don't change lemmy-ui or lemmy here, they refer to the upstream definitions on top set $proxpass "http://lemmy-ui"; if ($http_accept = "application/activity+json") { set $proxpass "http://lemmy"; } if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") { set $proxpass "http://lemmy"; } if ($request_method = POST) { set $proxpass "http://lemmy"; } proxy_pass $proxpass; rewrite ^(.+)/+$ $1 permanent; # Send actual client IP upstream proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # backend location ~ ^/(api|pictrs|feeds|nodeinfo|version|.well-known) { proxy_pass "http://lemmy"; # proxy common stuff proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Send actual client IP upstream proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } } ================================================ FILE: docker/test_deploy.sh ================================================ #!/usr/bin/env bash set -e export COMPOSE_DOCKER_CLI_BUILD=1 export DOCKER_BUILDKIT=1 # Rebuilding dev docker pushd .. sudo docker build . -f docker/Dockerfile --build-arg RUST_RELEASE_MODE=release -t "dessalines/lemmy:dev" --platform=linux/amd64 --push # Run the playbook # pushd ../../../lemmy-ansible # ansible-playbook -i test playbooks/site.yml # popd ================================================ FILE: migrations/00000000000000_diesel_initial_setup/down.sql ================================================ -- This file was automatically created by Diesel to setup helper functions -- and other internal bookkeeping. This file is safe to edit, any future -- changes will be added to existing projects as new migrations. DROP FUNCTION IF EXISTS diesel_manage_updated_at (_tbl regclass); DROP FUNCTION IF EXISTS diesel_set_updated_at (); ================================================ FILE: migrations/00000000000000_diesel_initial_setup/up.sql ================================================ -- This file was automatically created by Diesel to setup helper functions -- and other internal bookkeeping. This file is safe to edit, any future -- changes will be added to existing projects as new migrations. -- Sets up a trigger for the given table to automatically set a column called -- `updated_at` whenever the row is modified (unless `updated_at` was included -- in the modified columns) -- -- # Example -- -- ```sql -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); -- -- SELECT diesel_manage_updated_at('users'); -- ``` CREATE OR REPLACE FUNCTION diesel_manage_updated_at (_tbl regclass) RETURNS VOID AS $$ BEGIN EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); END; $$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION diesel_set_updated_at () RETURNS TRIGGER AS $$ BEGIN IF (NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at) THEN NEW.updated_at := CURRENT_TIMESTAMP; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; ================================================ FILE: migrations/2019-02-26-002946_create_user/down.sql ================================================ DROP TABLE user_ban; DROP TABLE user_; ================================================ FILE: migrations/2019-02-26-002946_create_user/up.sql ================================================ CREATE TABLE user_ ( id serial PRIMARY KEY, name varchar(20) NOT NULL, fedi_name varchar(40) NOT NULL, preferred_username varchar(20), password_encrypted text NOT NULL, email text UNIQUE, icon bytea, admin boolean DEFAULT FALSE NOT NULL, banned boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), updated timestamp, UNIQUE (name, fedi_name) ); CREATE TABLE user_ban ( id serial PRIMARY KEY, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (user_id) ); INSERT INTO user_ (name, fedi_name, password_encrypted) VALUES ('admin', 'TBD', 'TBD'); ================================================ FILE: migrations/2019-02-27-170003_create_community/down.sql ================================================ DROP TABLE site; DROP TABLE community_user_ban; ; DROP TABLE community_moderator; DROP TABLE community_follower; DROP TABLE community; DROP TABLE category; ================================================ FILE: migrations/2019-02-27-170003_create_community/up.sql ================================================ CREATE TABLE category ( id serial PRIMARY KEY, name varchar(100) NOT NULL UNIQUE ); INSERT INTO category (name) VALUES ('Discussion'), ('Humor/Memes'), ('Gaming'), ('Movies'), ('TV'), ('Music'), ('Literature'), ('Comics'), ('Photography'), ('Art'), ('Learning'), ('DIY'), ('Lifestyle'), ('News'), ('Politics'), ('Society'), ('Gender/Identity/Sexuality'), ('Race/Colonisation'), ('Religion'), ('Science/Technology'), ('Programming/Software'), ('Health/Sports/Fitness'), ('Porn'), ('Places'), ('Meta'), ('Other'); CREATE TABLE community ( id serial PRIMARY KEY, name varchar(20) NOT NULL UNIQUE, title varchar(100) NOT NULL, description text, category_id int REFERENCES category ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, removed boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), updated timestamp ); CREATE TABLE community_moderator ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (community_id, user_id) ); CREATE TABLE community_follower ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (community_id, user_id) ); CREATE TABLE community_user_ban ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (community_id, user_id) ); INSERT INTO community (name, title, category_id, creator_id) VALUES ('main', 'The Default Community', 1, 1); CREATE TABLE site ( id serial PRIMARY KEY, name varchar(20) NOT NULL UNIQUE, description text, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), updated timestamp ); ================================================ FILE: migrations/2019-03-03-163336_create_post/down.sql ================================================ DROP TABLE post_read; DROP TABLE post_saved; DROP TABLE post_like; DROP TABLE post; ================================================ FILE: migrations/2019-03-03-163336_create_post/up.sql ================================================ CREATE TABLE post ( id serial PRIMARY KEY, name varchar(100) NOT NULL, url text, -- These are both optional, a post can just have a title body text, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, removed boolean DEFAULT FALSE NOT NULL, locked boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), updated timestamp ); CREATE TABLE post_like ( id serial PRIMARY KEY, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, score smallint NOT NULL, -- -1, or 1 for dislike, like, no row for no opinion published timestamp NOT NULL DEFAULT now(), UNIQUE (post_id, user_id) ); CREATE TABLE post_saved ( id serial PRIMARY KEY, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (post_id, user_id) ); CREATE TABLE post_read ( id serial PRIMARY KEY, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (post_id, user_id) ); ================================================ FILE: migrations/2019-03-05-233828_create_comment/down.sql ================================================ DROP TABLE comment_saved; DROP TABLE comment_like; DROP TABLE comment; ================================================ FILE: migrations/2019-03-05-233828_create_comment/up.sql ================================================ CREATE TABLE comment ( id serial PRIMARY KEY, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, parent_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, content text NOT NULL, removed boolean DEFAULT FALSE NOT NULL, read boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), updated timestamp ); CREATE TABLE comment_like ( id serial PRIMARY KEY, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, score smallint NOT NULL, -- -1, or 1 for dislike, like, no row for no opinion published timestamp NOT NULL DEFAULT now(), UNIQUE (comment_id, user_id) ); CREATE TABLE comment_saved ( id serial PRIMARY KEY, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (comment_id, user_id) ); ================================================ FILE: migrations/2019-03-30-212058_create_post_view/down.sql ================================================ DROP VIEW post_view; DROP FUNCTION hot_rank; ================================================ FILE: migrations/2019-03-30-212058_create_post_view/up.sql ================================================ -- Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone) RETURNS integer AS $$ BEGIN -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600 RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer; END; $$ LANGUAGE plpgsql; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-04-03-155205_create_community_view/down.sql ================================================ DROP VIEW community_view; DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; DROP VIEW site_view; ================================================ FILE: migrations/2019-04-03-155205_create_community_view/up.sql ================================================ CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; CREATE VIEW community_moderator_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_moderator cm; CREATE VIEW community_follower_view AS SELECT *, ( SELECT name FROM user_ u WHERE cf.user_id = u.id) AS user_name, ( SELECT name FROM community c WHERE cf.community_id = c.id) AS community_name FROM community_follower cf; CREATE VIEW community_user_ban_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_user_ban cm; CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments FROM site s; ================================================ FILE: migrations/2019-04-03-155309_create_comment_view/down.sql ================================================ DROP VIEW reply_view; DROP VIEW comment_view; ================================================ FILE: migrations/2019-04-03-155309_create_comment_view/up.sql ================================================ CREATE VIEW comment_view AS with all_comment AS ( SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2019-04-07-003142_create_moderation_logs/down.sql ================================================ DROP TABLE mod_remove_post; DROP TABLE mod_lock_post; DROP TABLE mod_remove_comment; DROP TABLE mod_remove_community; DROP TABLE mod_ban; DROP TABLE mod_ban_from_community; DROP TABLE mod_add; DROP TABLE mod_add_community; ================================================ FILE: migrations/2019-04-07-003142_create_moderation_logs/up.sql ================================================ CREATE TABLE mod_remove_post ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, removed boolean DEFAULT TRUE, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE mod_lock_post ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, locked boolean DEFAULT TRUE, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE mod_remove_comment ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, removed boolean DEFAULT TRUE, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE mod_remove_community ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, removed boolean DEFAULT TRUE, expires timestamp, when_ timestamp NOT NULL DEFAULT now() ); -- TODO make sure you can't ban other mods CREATE TABLE mod_ban_from_community ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, banned boolean DEFAULT TRUE, expires timestamp, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE mod_ban ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, banned boolean DEFAULT TRUE, expires timestamp, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE mod_add_community ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, removed boolean DEFAULT FALSE, when_ timestamp NOT NULL DEFAULT now() ); -- When removed is false that means kicked CREATE TABLE mod_add ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, other_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, removed boolean DEFAULT FALSE, when_ timestamp NOT NULL DEFAULT now() ); ================================================ FILE: migrations/2019-04-08-015947_create_user_view/down.sql ================================================ DROP VIEW user_view; ================================================ FILE: migrations/2019-04-08-015947_create_user_view/up.sql ================================================ CREATE VIEW user_view AS SELECT id, name, fedi_name, admin, banned, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; ================================================ FILE: migrations/2019-04-11-144915_create_mod_views/down.sql ================================================ DROP VIEW mod_remove_post_view; DROP VIEW mod_lock_post_view; DROP VIEW mod_remove_comment_view; DROP VIEW mod_remove_community_view; DROP VIEW mod_ban_from_community_view; DROP VIEW mod_ban_view; DROP VIEW mod_add_community_view; DROP VIEW mod_add_view; ================================================ FILE: migrations/2019-04-11-144915_create_mod_views/up.sql ================================================ CREATE VIEW mod_remove_post_view AS SELECT mrp.*, ( SELECT name FROM user_ u WHERE mrp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE mrp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE mrp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE mrp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_remove_post mrp; CREATE VIEW mod_lock_post_view AS SELECT mlp.*, ( SELECT name FROM user_ u WHERE mlp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE mlp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE mlp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE mlp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_lock_post mlp; CREATE VIEW mod_remove_comment_view AS SELECT mrc.*, ( SELECT name FROM user_ u WHERE mrc.mod_user_id = u.id) AS mod_user_name, ( SELECT c.id FROM comment c WHERE mrc.comment_id = c.id) AS comment_user_id, ( SELECT name FROM user_ u, comment c WHERE mrc.comment_id = c.id AND u.id = c.creator_id) AS comment_user_name, ( SELECT content FROM comment c WHERE mrc.comment_id = c.id) AS comment_content, ( SELECT p.id FROM post p, comment c WHERE mrc.comment_id = c.id AND c.post_id = p.id) AS post_id, ( SELECT p.name FROM post p, comment c WHERE mrc.comment_id = c.id AND c.post_id = p.id) AS post_name, ( SELECT co.id FROM comment c, post p, community co WHERE mrc.comment_id = c.id AND c.post_id = p.id AND p.community_id = co.id) AS community_id, ( SELECT co.name FROM comment c, post p, community co WHERE mrc.comment_id = c.id AND c.post_id = p.id AND p.community_id = co.id) AS community_name FROM mod_remove_comment mrc; CREATE VIEW mod_remove_community_view AS SELECT mrc.*, ( SELECT name FROM user_ u WHERE mrc.mod_user_id = u.id) AS mod_user_name, ( SELECT c.name FROM community c WHERE mrc.community_id = c.id) AS community_name FROM mod_remove_community mrc; CREATE VIEW mod_ban_from_community_view AS SELECT mb.*, ( SELECT name FROM user_ u WHERE mb.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM user_ u WHERE mb.other_user_id = u.id) AS other_user_name, ( SELECT name FROM community c WHERE mb.community_id = c.id) AS community_name FROM mod_ban_from_community mb; CREATE VIEW mod_ban_view AS SELECT mb.*, ( SELECT name FROM user_ u WHERE mb.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM user_ u WHERE mb.other_user_id = u.id) AS other_user_name FROM mod_ban mb; CREATE VIEW mod_add_community_view AS SELECT ma.*, ( SELECT name FROM user_ u WHERE ma.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM user_ u WHERE ma.other_user_id = u.id) AS other_user_name, ( SELECT name FROM community c WHERE ma.community_id = c.id) AS community_name FROM mod_add_community ma; CREATE VIEW mod_add_view AS SELECT ma.*, ( SELECT name FROM user_ u WHERE ma.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM user_ u WHERE ma.other_user_id = u.id) AS other_user_name FROM mod_add ma; ================================================ FILE: migrations/2019-04-29-175834_add_delete_columns/down.sql ================================================ DROP VIEW reply_view; DROP VIEW comment_view; DROP VIEW community_view; DROP VIEW post_view; ALTER TABLE community DROP COLUMN deleted; ALTER TABLE post DROP COLUMN deleted; ALTER TABLE comment DROP COLUMN deleted; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; CREATE OR REPLACE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW comment_view AS with all_comment AS ( SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2019-04-29-175834_add_delete_columns/up.sql ================================================ ALTER TABLE community ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL; ALTER TABLE post ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL; ALTER TABLE comment ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL; -- The views DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; DROP VIEW reply_view; DROP VIEW comment_view; CREATE VIEW comment_view AS with all_comment AS ( SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2019-05-02-051656_community_view_hot_rank/down.sql ================================================ DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; ================================================ FILE: migrations/2019-05-02-051656_community_view_hot_rank/up.sql ================================================ DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; ================================================ FILE: migrations/2019-06-01-222649_remove_admin/down.sql ================================================ INSERT INTO user_ (name, fedi_name, password_encrypted) VALUES ('admin', 'TBD', 'TBD'); ================================================ FILE: migrations/2019-06-01-222649_remove_admin/up.sql ================================================ DELETE FROM user_ WHERE name LIKE 'admin'; ================================================ FILE: migrations/2019-08-11-000918_add_nsfw_columns/down.sql ================================================ DROP VIEW community_view; DROP VIEW post_view; ALTER TABLE community DROP COLUMN nsfw; ALTER TABLE post DROP COLUMN nsfw; ALTER TABLE user_ DROP COLUMN show_nsfw; -- the views CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- Post view CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-08-11-000918_add_nsfw_columns/up.sql ================================================ ALTER TABLE community ADD COLUMN nsfw boolean DEFAULT FALSE NOT NULL; ALTER TABLE post ADD COLUMN nsfw boolean DEFAULT FALSE NOT NULL; ALTER TABLE user_ ADD COLUMN show_nsfw boolean DEFAULT FALSE NOT NULL; -- The views DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- Post view DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-08-29-040006_add_community_count/down.sql ================================================ DROP VIEW site_view; CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments FROM site s; ================================================ FILE: migrations/2019-08-29-040006_add_community_count/up.sql ================================================ DROP VIEW site_view; CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s; ================================================ FILE: migrations/2019-09-05-230317_add_mod_ban_views/down.sql ================================================ -- Post view DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-09-05-230317_add_mod_ban_views/up.sql ================================================ -- Create post view, adding banned_from_community DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-09-09-042010_add_stickied_posts/down.sql ================================================ DROP VIEW post_view; DROP VIEW mod_sticky_post_view; ALTER TABLE post DROP COLUMN stickied; DROP TABLE mod_sticky_post; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-09-09-042010_add_stickied_posts/up.sql ================================================ -- Add the column ALTER TABLE post ADD COLUMN stickied boolean DEFAULT FALSE NOT NULL; -- Add the mod table CREATE TABLE mod_sticky_post ( id serial PRIMARY KEY, mod_user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, stickied boolean DEFAULT TRUE, when_ timestamp NOT NULL DEFAULT now() ); -- Add mod view CREATE VIEW mod_sticky_post_view AS SELECT msp.*, ( SELECT name FROM user_ u WHERE msp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE msp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE msp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE msp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_sticky_post msp; -- Recreate the view DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2019-10-15-181630_add_themes/down.sql ================================================ ALTER TABLE user_ DROP COLUMN theme; ================================================ FILE: migrations/2019-10-15-181630_add_themes/up.sql ================================================ ALTER TABLE user_ ADD COLUMN theme varchar(20) DEFAULT 'darkly' NOT NULL; ================================================ FILE: migrations/2019-10-19-052737_create_user_mention/down.sql ================================================ DROP VIEW user_mention_view; DROP TABLE user_mention; ================================================ FILE: migrations/2019-10-19-052737_create_user_mention/up.sql ================================================ CREATE TABLE user_mention ( id serial PRIMARY KEY, recipient_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (recipient_id, comment_id) ); CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.banned, c.banned_from_community, c.creator_name, c.score, c.upvotes, c.downvotes, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; ================================================ FILE: migrations/2019-10-21-011237_add_default_sorts/down.sql ================================================ ALTER TABLE user_ DROP COLUMN default_sort_type; ALTER TABLE user_ DROP COLUMN default_listing_type; ================================================ FILE: migrations/2019-10-21-011237_add_default_sorts/up.sql ================================================ ALTER TABLE user_ ADD COLUMN default_sort_type smallint DEFAULT 0 NOT NULL; ALTER TABLE user_ ADD COLUMN default_listing_type smallint DEFAULT 1 NOT NULL; ================================================ FILE: migrations/2019-10-24-002614_create_password_reset_request/down.sql ================================================ DROP TABLE password_reset_request; ================================================ FILE: migrations/2019-10-24-002614_create_password_reset_request/up.sql ================================================ CREATE TABLE password_reset_request ( id serial PRIMARY KEY, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, token_encrypted text NOT NULL, published timestamp NOT NULL DEFAULT now() ); ================================================ FILE: migrations/2019-12-09-060754_add_lang/down.sql ================================================ ALTER TABLE user_ DROP COLUMN lang; ================================================ FILE: migrations/2019-12-09-060754_add_lang/up.sql ================================================ ALTER TABLE user_ ADD COLUMN lang varchar(20) DEFAULT 'browser' NOT NULL; ================================================ FILE: migrations/2019-12-11-181820_add_site_fields/down.sql ================================================ -- Drop the columns DROP VIEW site_view; ALTER TABLE site DROP COLUMN enable_downvotes; ALTER TABLE site DROP COLUMN open_registration; ALTER TABLE site DROP COLUMN enable_nsfw; -- Rebuild the views CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s; ================================================ FILE: migrations/2019-12-11-181820_add_site_fields/up.sql ================================================ -- Add the column ALTER TABLE site ADD COLUMN enable_downvotes boolean DEFAULT TRUE NOT NULL; ALTER TABLE site ADD COLUMN open_registration boolean DEFAULT TRUE NOT NULL; ALTER TABLE site ADD COLUMN enable_nsfw boolean DEFAULT TRUE NOT NULL; -- Reload the view DROP VIEW site_view; CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s; ================================================ FILE: migrations/2019-12-29-164820_add_avatar/down.sql ================================================ -- the views DROP VIEW user_mention_view; DROP VIEW reply_view; DROP VIEW comment_view; DROP VIEW user_view; -- user CREATE VIEW user_view AS SELECT id, name, fedi_name, admin, banned, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; -- post -- Recreate the view DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- community DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- Reply and comment view CREATE VIEW comment_view AS with all_comment AS ( SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.banned, c.banned_from_community, c.creator_name, c.score, c.upvotes, c.downvotes, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; -- community tables DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; DROP VIEW site_view; CREATE VIEW community_moderator_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_moderator cm; CREATE VIEW community_follower_view AS SELECT *, ( SELECT name FROM user_ u WHERE cf.user_id = u.id) AS user_name, ( SELECT name FROM community c WHERE cf.community_id = c.id) AS community_name FROM community_follower cf; CREATE VIEW community_user_ban_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_user_ban cm; CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s; ALTER TABLE user_ RENAME COLUMN avatar TO icon; ALTER TABLE user_ ALTER COLUMN icon TYPE bytea USING icon::bytea; ================================================ FILE: migrations/2019-12-29-164820_add_avatar/up.sql ================================================ -- Rename to avatar ALTER TABLE user_ RENAME COLUMN icon TO avatar; ALTER TABLE user_ ALTER COLUMN avatar TYPE text; -- Rebuild nearly all the views, to include the creator avatars -- user DROP VIEW user_view; CREATE VIEW user_view AS SELECT id, name, avatar, fedi_name, admin, banned, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; -- post -- Recreate the view DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- community DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE c.creator_id = u.id) AS creator_avatar, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- reply and comment view DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW comment_view; CREATE VIEW comment_view AS with all_comment AS ( SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; -- community views DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; DROP VIEW site_view; CREATE VIEW community_moderator_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cm.user_id = u.id), ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_moderator cm; CREATE VIEW community_follower_view AS SELECT *, ( SELECT name FROM user_ u WHERE cf.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cf.user_id = u.id), ( SELECT name FROM community c WHERE cf.community_id = c.id) AS community_name FROM community_follower cf; CREATE VIEW community_user_ban_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cm.user_id = u.id), ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_user_ban cm; CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE s.creator_id = u.id) AS creator_avatar, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s; ================================================ FILE: migrations/2020-01-01-200418_add_email_to_user_view/down.sql ================================================ -- user DROP VIEW user_view; CREATE VIEW user_view AS SELECT id, name, avatar, fedi_name, admin, banned, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; ================================================ FILE: migrations/2020-01-01-200418_add_email_to_user_view/up.sql ================================================ -- user DROP VIEW user_view; CREATE VIEW user_view AS SELECT id, name, avatar, email, fedi_name, admin, banned, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; ================================================ FILE: migrations/2020-01-02-172755_add_show_avatar_and_email_notifications_to_user/down.sql ================================================ -- Drop the columns DROP VIEW user_view; ALTER TABLE user_ DROP COLUMN show_avatars; ALTER TABLE user_ DROP COLUMN send_notifications_to_email; -- Rebuild the view CREATE VIEW user_view AS SELECT id, name, avatar, email, fedi_name, admin, banned, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; ================================================ FILE: migrations/2020-01-02-172755_add_show_avatar_and_email_notifications_to_user/up.sql ================================================ -- Add columns ALTER TABLE user_ ADD COLUMN show_avatars boolean DEFAULT TRUE NOT NULL; ALTER TABLE user_ ADD COLUMN send_notifications_to_email boolean DEFAULT FALSE NOT NULL; -- Rebuild the user_view DROP VIEW user_view; CREATE VIEW user_view AS SELECT id, name, avatar, email, fedi_name, admin, banned, show_avatars, send_notifications_to_email, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; ================================================ FILE: migrations/2020-01-11-012452_add_indexes/down.sql ================================================ DROP INDEX idx_post_creator; DROP INDEX idx_post_community; DROP INDEX idx_post_like_post; DROP INDEX idx_post_like_user; DROP INDEX idx_comment_creator; DROP INDEX idx_comment_parent; DROP INDEX idx_comment_post; DROP INDEX idx_comment_like_comment; DROP INDEX idx_comment_like_user; DROP INDEX idx_comment_like_post; DROP INDEX idx_community_creator; DROP INDEX idx_community_category; ================================================ FILE: migrations/2020-01-11-012452_add_indexes/up.sql ================================================ -- Go through all the tables joins, optimize every view, CTE, etc. CREATE INDEX idx_post_creator ON post (creator_id); CREATE INDEX idx_post_community ON post (community_id); CREATE INDEX idx_post_like_post ON post_like (post_id); CREATE INDEX idx_post_like_user ON post_like (user_id); CREATE INDEX idx_comment_creator ON comment (creator_id); CREATE INDEX idx_comment_parent ON comment (parent_id); CREATE INDEX idx_comment_post ON comment (post_id); CREATE INDEX idx_comment_like_comment ON comment_like (comment_id); CREATE INDEX idx_comment_like_user ON comment_like (user_id); CREATE INDEX idx_comment_like_post ON comment_like (post_id); CREATE INDEX idx_community_creator ON community (creator_id); CREATE INDEX idx_community_category ON community (category_id); ================================================ FILE: migrations/2020-01-13-025151_create_materialized_views/down.sql ================================================ -- functions and triggers DROP TRIGGER refresh_user ON user_; DROP FUNCTION refresh_user (); DROP TRIGGER refresh_post ON post; DROP FUNCTION refresh_post (); DROP TRIGGER refresh_post_like ON post_like; DROP FUNCTION refresh_post_like (); DROP TRIGGER refresh_community ON community; DROP FUNCTION refresh_community (); DROP TRIGGER refresh_community_follower ON community_follower; DROP FUNCTION refresh_community_follower (); DROP TRIGGER refresh_community_user_ban ON community_user_ban; DROP FUNCTION refresh_community_user_ban (); DROP TRIGGER refresh_comment ON comment; DROP FUNCTION refresh_comment (); DROP TRIGGER refresh_comment_like ON comment_like; DROP FUNCTION refresh_comment_like (); -- post -- Recreate the view DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- user DROP MATERIALIZED VIEW user_mview; DROP VIEW user_view; CREATE VIEW user_view AS SELECT id, name, avatar, email, fedi_name, admin, banned, show_avatars, send_notifications_to_email, published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; -- community DROP VIEW community_mview; DROP MATERIALIZED VIEW community_aggregates_mview; DROP VIEW community_view; DROP VIEW community_aggregates_view; CREATE VIEW community_view AS with all_community AS ( SELECT *, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE c.creator_id = u.id) AS creator_avatar, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- reply and comment view DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW comment_view; DROP VIEW comment_mview; DROP MATERIALIZED VIEW comment_aggregates_mview; DROP VIEW comment_aggregates_view; CREATE VIEW comment_view AS with all_comment AS ( SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; ================================================ FILE: migrations/2020-01-13-025151_create_materialized_views/up.sql ================================================ -- post CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); DROP VIEW post_view; CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- user_view DROP VIEW user_view; CREATE VIEW user_view AS SELECT u.id, u.name, u.avatar, u.email, u.fedi_name, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); -- community CREATE VIEW community_aggregates_view AS SELECT c.*, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE c.creator_id = u.id) AS creator_avatar, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c; CREATE MATERIALIZED VIEW community_aggregates_mview AS SELECT * FROM community_aggregates_view; CREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id); DROP VIEW community_view; CREATE VIEW community_view AS with all_community AS ( SELECT ca.* FROM community_aggregates_view ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; CREATE VIEW community_mview AS with all_community AS ( SELECT ca.* FROM community_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- reply and comment view CREATE VIEW comment_aggregates_view AS SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id; CREATE MATERIALIZED VIEW comment_aggregates_mview AS SELECT * FROM comment_aggregates_view; CREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id); DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW comment_view; CREATE VIEW comment_view AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_view ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW comment_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; -- user CREATE OR REPLACE FUNCTION refresh_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview; -- cause of bans REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_user AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON user_ FOR EACH statement EXECUTE PROCEDURE refresh_user (); -- post CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_post AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON post FOR EACH statement EXECUTE PROCEDURE refresh_post (); -- post_like CREATE OR REPLACE FUNCTION refresh_post_like () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_post_like AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON post_like FOR EACH statement EXECUTE PROCEDURE refresh_post_like (); -- community CREATE OR REPLACE FUNCTION refresh_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_community AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community FOR EACH statement EXECUTE PROCEDURE refresh_community (); -- community_follower CREATE OR REPLACE FUNCTION refresh_community_follower () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_community_follower AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community_follower FOR EACH statement EXECUTE PROCEDURE refresh_community_follower (); -- community_user_ban CREATE OR REPLACE FUNCTION refresh_community_user_ban () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_community_user_ban AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community_user_ban FOR EACH statement EXECUTE PROCEDURE refresh_community_user_ban (); -- comment CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_comment AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON comment FOR EACH statement EXECUTE PROCEDURE refresh_comment (); -- comment_like CREATE OR REPLACE FUNCTION refresh_comment_like () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_comment_like AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON comment_like FOR EACH statement EXECUTE PROCEDURE refresh_comment_like (); ================================================ FILE: migrations/2020-01-21-001001_create_private_message/down.sql ================================================ -- Drop the triggers DROP TRIGGER refresh_private_message ON private_message; DROP FUNCTION refresh_private_message (); -- Drop the view and table DROP VIEW private_message_view CASCADE; DROP TABLE private_message; -- Rebuild the old views DROP VIEW user_view CASCADE; CREATE VIEW user_view AS SELECT u.id, u.name, u.avatar, u.email, u.fedi_name, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); -- Drop the columns ALTER TABLE user_ DROP COLUMN matrix_user_id; ================================================ FILE: migrations/2020-01-21-001001_create_private_message/up.sql ================================================ -- Creating private message CREATE TABLE private_message ( id serial PRIMARY KEY, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, recipient_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, content text NOT NULL, deleted boolean DEFAULT FALSE NOT NULL, read boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), updated timestamp ); -- Create the view and materialized view which has the avatar and creator name CREATE VIEW private_message_view AS SELECT pm.*, u.name AS creator_name, u.avatar AS creator_avatar, u2.name AS recipient_name, u2.avatar AS recipient_avatar FROM private_message pm INNER JOIN user_ u ON u.id = pm.creator_id INNER JOIN user_ u2 ON u2.id = pm.recipient_id; CREATE MATERIALIZED VIEW private_message_mview AS SELECT * FROM private_message_view; CREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id); -- Create the triggers CREATE OR REPLACE FUNCTION refresh_private_message () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY private_message_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_private_message AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON private_message FOR EACH statement EXECUTE PROCEDURE refresh_private_message (); -- Update user to include matrix id ALTER TABLE user_ ADD COLUMN matrix_user_id text UNIQUE; DROP VIEW user_view CASCADE; CREATE VIEW user_view AS SELECT u.id, u.name, u.avatar, u.email, u.matrix_user_id, u.fedi_name, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); -- This is what a group pm table would look like -- Not going to do it now because of the complications -- -- create table private_message ( -- id serial primary key, -- creator_id int references user_ on update cascade on delete cascade not null, -- content text not null, -- deleted boolean default false not null, -- published timestamp not null default now(), -- updated timestamp -- ); -- -- create table private_message_recipient ( -- id serial primary key, -- private_message_id int references private_message on update cascade on delete cascade not null, -- recipient_id int references user_ on update cascade on delete cascade not null, -- read boolean default false not null, -- published timestamp not null default now(), -- unique(private_message_id, recipient_id) -- ) ================================================ FILE: migrations/2020-01-29-011901_create_reply_materialized_view/down.sql ================================================ -- Drop the materialized / built views DROP VIEW reply_view; CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_view cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2020-01-29-011901_create_reply_materialized_view/up.sql ================================================ -- https://github.com/dessalines/lemmy/issues/197 DROP VIEW reply_view; -- Do the reply_view referencing the comment_mview CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_mview cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2020-01-29-030825_create_user_mention_materialized_view/down.sql ================================================ DROP VIEW user_mention_mview; ================================================ FILE: migrations/2020-01-29-030825_create_user_mention_materialized_view/up.sql ================================================ CREATE VIEW user_mention_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id FROM all_comment ac LEFT JOIN user_mention um ON um.comment_id = ac.id; ================================================ FILE: migrations/2020-02-02-004806_add_case_insensitive_usernames/down.sql ================================================ DROP INDEX idx_user_name_lower; DROP INDEX idx_user_email_lower; ================================================ FILE: migrations/2020-02-02-004806_add_case_insensitive_usernames/up.sql ================================================ -- Add case insensitive username and email uniqueness -- An example of showing the dupes: -- select -- max(id) as id, -- lower(name) as lname, -- count(*) -- from user_ -- group by lower(name) -- having count(*) > 1; -- Delete username dupes, keeping the first one DELETE FROM user_ WHERE id NOT IN ( SELECT min(id) FROM user_ GROUP BY lower(name), lower(fedi_name)); -- The user index CREATE UNIQUE INDEX idx_user_name_lower ON user_ (lower(name)); -- Email lower CREATE UNIQUE INDEX idx_user_email_lower ON user_ (lower(email)); -- Set empty emails properly to null UPDATE user_ SET email = NULL WHERE email = ''; ================================================ FILE: migrations/2020-02-06-165953_change_post_title_length/down.sql ================================================ -- Drop the dependent views DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; DROP VIEW mod_remove_post_view; DROP VIEW mod_sticky_post_view; DROP VIEW mod_lock_post_view; DROP VIEW mod_remove_comment_view; ALTER TABLE post ALTER COLUMN name TYPE varchar(100); -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- The mod views CREATE VIEW mod_remove_post_view AS SELECT mrp.*, ( SELECT name FROM user_ u WHERE mrp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE mrp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE mrp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE mrp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_remove_post mrp; CREATE VIEW mod_lock_post_view AS SELECT mlp.*, ( SELECT name FROM user_ u WHERE mlp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE mlp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE mlp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE mlp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_lock_post mlp; CREATE VIEW mod_remove_comment_view AS SELECT mrc.*, ( SELECT name FROM user_ u WHERE mrc.mod_user_id = u.id) AS mod_user_name, ( SELECT c.id FROM comment c WHERE mrc.comment_id = c.id) AS comment_user_id, ( SELECT name FROM user_ u, comment c WHERE mrc.comment_id = c.id AND u.id = c.creator_id) AS comment_user_name, ( SELECT content FROM comment c WHERE mrc.comment_id = c.id) AS comment_content, ( SELECT p.id FROM post p, comment c WHERE mrc.comment_id = c.id AND c.post_id = p.id) AS post_id, ( SELECT p.name FROM post p, comment c WHERE mrc.comment_id = c.id AND c.post_id = p.id) AS post_name, ( SELECT co.id FROM comment c, post p, community co WHERE mrc.comment_id = c.id AND c.post_id = p.id AND p.community_id = co.id) AS community_id, ( SELECT co.name FROM comment c, post p, community co WHERE mrc.comment_id = c.id AND c.post_id = p.id AND p.community_id = co.id) AS community_name FROM mod_remove_comment mrc; CREATE VIEW mod_sticky_post_view AS SELECT msp.*, ( SELECT name FROM user_ u WHERE msp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE msp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE msp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE msp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_sticky_post msp; ================================================ FILE: migrations/2020-02-06-165953_change_post_title_length/up.sql ================================================ -- Drop the dependent views DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; DROP VIEW mod_remove_post_view; DROP VIEW mod_sticky_post_view; DROP VIEW mod_lock_post_view; DROP VIEW mod_remove_comment_view; -- Add the extra post limit ALTER TABLE post ALTER COLUMN name TYPE varchar(200); -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- The mod views CREATE VIEW mod_remove_post_view AS SELECT mrp.*, ( SELECT name FROM user_ u WHERE mrp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE mrp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE mrp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE mrp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_remove_post mrp; CREATE VIEW mod_lock_post_view AS SELECT mlp.*, ( SELECT name FROM user_ u WHERE mlp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE mlp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE mlp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE mlp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_lock_post mlp; CREATE VIEW mod_remove_comment_view AS SELECT mrc.*, ( SELECT name FROM user_ u WHERE mrc.mod_user_id = u.id) AS mod_user_name, ( SELECT c.id FROM comment c WHERE mrc.comment_id = c.id) AS comment_user_id, ( SELECT name FROM user_ u, comment c WHERE mrc.comment_id = c.id AND u.id = c.creator_id) AS comment_user_name, ( SELECT content FROM comment c WHERE mrc.comment_id = c.id) AS comment_content, ( SELECT p.id FROM post p, comment c WHERE mrc.comment_id = c.id AND c.post_id = p.id) AS post_id, ( SELECT p.name FROM post p, comment c WHERE mrc.comment_id = c.id AND c.post_id = p.id) AS post_name, ( SELECT co.id FROM comment c, post p, community co WHERE mrc.comment_id = c.id AND c.post_id = p.id AND p.community_id = co.id) AS community_id, ( SELECT co.name FROM comment c, post p, community co WHERE mrc.comment_id = c.id AND c.post_id = p.id AND p.community_id = co.id) AS community_name FROM mod_remove_comment mrc; CREATE VIEW mod_sticky_post_view AS SELECT msp.*, ( SELECT name FROM user_ u WHERE msp.mod_user_id = u.id) AS mod_user_name, ( SELECT name FROM post p WHERE msp.post_id = p.id) AS post_name, ( SELECT c.id FROM post p, community c WHERE msp.post_id = p.id AND p.community_id = c.id) AS community_id, ( SELECT c.name FROM post p, community c WHERE msp.post_id = p.id AND p.community_id = c.id) AS community_name FROM mod_sticky_post msp; ================================================ FILE: migrations/2020-02-07-210055_add_comment_subscribed/down.sql ================================================ DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW user_mention_mview; DROP VIEW comment_view; DROP VIEW comment_mview; DROP MATERIALIZED VIEW comment_aggregates_mview; DROP VIEW comment_aggregates_view; -- reply and comment view CREATE VIEW comment_aggregates_view AS SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id; CREATE MATERIALIZED VIEW comment_aggregates_mview AS SELECT * FROM comment_aggregates_view; CREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id); CREATE VIEW comment_view AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_view ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; CREATE VIEW comment_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS saved FROM all_comment ac; -- Do the reply_view referencing the comment_mview CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_mview cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id FROM all_comment ac LEFT JOIN user_mention um ON um.comment_id = ac.id; ================================================ FILE: migrations/2020-02-07-210055_add_comment_subscribed/up.sql ================================================ -- Adding community name, hot_rank, to comment_view, user_mention_view, and subscribed to comment_view -- Rebuild the comment view DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW user_mention_mview; DROP VIEW comment_view; DROP VIEW comment_mview; DROP MATERIALIZED VIEW comment_aggregates_mview; DROP VIEW comment_aggregates_view; -- reply and comment view CREATE VIEW comment_aggregates_view AS SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT co.name FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_name, ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id; CREATE MATERIALIZED VIEW comment_aggregates_mview AS SELECT * FROM comment_aggregates_view; CREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id); CREATE VIEW comment_view AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_view ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; CREATE VIEW comment_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; -- Do the reply_view referencing the comment_mview CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_mview cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id FROM all_comment ac LEFT JOIN user_mention um ON um.comment_id = ac.id; ================================================ FILE: migrations/2020-02-08-145624_add_post_newest_activity_time/down.sql ================================================ DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), p.published) AS hot_rank FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.id; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2020-02-08-145624_add_post_newest_activity_time/up.sql ================================================ -- Adds a newest_activity_time for the post_views, in order to sort by newest comment DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id LEFT JOIN ( SELECT post_id, max(published) AS recent_comment_time FROM comment GROUP BY 1) c ON p.id = c.post_id GROUP BY p.id, c.recent_comment_time; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2020-03-06-202329_add_post_iframely_data/down.sql ================================================ -- Adds a newest_activity_time for the post_views, in order to sort by newest comment DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- Drop the columns ALTER TABLE post DROP COLUMN embed_title; ALTER TABLE post DROP COLUMN embed_description; ALTER TABLE post DROP COLUMN embed_html; ALTER TABLE post DROP COLUMN thumbnail_url; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id LEFT JOIN ( SELECT post_id, max(published) AS recent_comment_time FROM comment GROUP BY 1) c ON p.id = c.post_id GROUP BY p.id, c.recent_comment_time; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2020-03-06-202329_add_post_iframely_data/up.sql ================================================ -- Add the columns ALTER TABLE post ADD COLUMN embed_title text; ALTER TABLE post ADD COLUMN embed_description text; ALTER TABLE post ADD COLUMN embed_html text; ALTER TABLE post ADD COLUMN thumbnail_url text; -- Regenerate the views -- Adds a newest_activity_time for the post_views, in order to sort by newest comment DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id LEFT JOIN ( SELECT post_id, max(published) AS recent_comment_time FROM comment GROUP BY 1) c ON p.id = c.post_id GROUP BY p.id, c.recent_comment_time; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; ================================================ FILE: migrations/2020-03-26-192410_add_activitypub_tables/down.sql ================================================ DROP TABLE activity; ALTER TABLE user_ DROP COLUMN actor_id, DROP COLUMN private_key, DROP COLUMN public_key, DROP COLUMN bio, DROP COLUMN local, DROP COLUMN last_refreshed_at; ALTER TABLE community DROP COLUMN actor_id, DROP COLUMN private_key, DROP COLUMN public_key, DROP COLUMN local, DROP COLUMN last_refreshed_at; ================================================ FILE: migrations/2020-03-26-192410_add_activitypub_tables/up.sql ================================================ -- The Activitypub activity table -- All user actions must create a row here. CREATE TABLE activity ( id serial PRIMARY KEY, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- Ensures that the user is set up here. data jsonb NOT NULL, local boolean NOT NULL DEFAULT TRUE, published timestamp NOT NULL DEFAULT now(), updated timestamp ); -- Making sure that id is unique CREATE UNIQUE INDEX idx_activity_unique_apid ON activity ((data ->> 'id'::text)); -- Add federation columns to the two actor tables ALTER TABLE user_ -- TODO uniqueness constraints should be added on these 3 columns later ADD COLUMN actor_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local ADD COLUMN bio text, -- not on community, already has description ADD COLUMN local boolean NOT NULL DEFAULT TRUE, ADD COLUMN private_key text, -- These need to be generated from code ADD COLUMN public_key text, ADD COLUMN last_refreshed_at timestamp NOT NULL DEFAULT now() -- Used to re-fetch federated actor periodically ; -- Community ALTER TABLE community ADD COLUMN actor_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local ADD COLUMN local boolean NOT NULL DEFAULT TRUE, ADD COLUMN private_key text, -- These need to be generated from code ADD COLUMN public_key text, ADD COLUMN last_refreshed_at timestamp NOT NULL DEFAULT now() -- Used to re-fetch federated actor periodically ; -- Don't worry about rebuilding the views right now. ================================================ FILE: migrations/2020-04-03-194936_add_activitypub_for_posts_and_comments/down.sql ================================================ ALTER TABLE post DROP COLUMN ap_id, DROP COLUMN local; ALTER TABLE comment DROP COLUMN ap_id, DROP COLUMN local; ================================================ FILE: migrations/2020-04-03-194936_add_activitypub_for_posts_and_comments/up.sql ================================================ -- Add federation columns to post, comment ALTER TABLE post -- TODO uniqueness constraints should be added on these 3 columns later ADD COLUMN ap_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local ADD COLUMN local boolean NOT NULL DEFAULT TRUE; ALTER TABLE comment -- TODO uniqueness constraints should be added on these 3 columns later ADD COLUMN ap_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local ADD COLUMN local boolean NOT NULL DEFAULT TRUE; ================================================ FILE: migrations/2020-04-07-135912_add_user_community_apub_constraints/down.sql ================================================ -- User table DROP VIEW user_view CASCADE; ALTER TABLE user_ ADD COLUMN fedi_name varchar(40) NOT NULL DEFAULT 'http://fake.com'; ALTER TABLE user_ -- Default is only for existing rows ALTER COLUMN fedi_name DROP DEFAULT, ADD CONSTRAINT user__name_fedi_name_key UNIQUE (name, fedi_name); -- Community ALTER TABLE community ADD CONSTRAINT community_name_key UNIQUE (name); CREATE VIEW user_view AS SELECT u.id, u.name, u.avatar, u.email, u.matrix_user_id, u.fedi_name, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); ================================================ FILE: migrations/2020-04-07-135912_add_user_community_apub_constraints/up.sql ================================================ -- User table -- Need to regenerate user_view, user_mview DROP VIEW user_view CASCADE; -- Remove the fedi_name constraint, drop that useless column ALTER TABLE user_ DROP CONSTRAINT user__name_fedi_name_key; ALTER TABLE user_ DROP COLUMN fedi_name; -- Community ALTER TABLE community DROP CONSTRAINT community_name_key; CREATE VIEW user_view AS SELECT u.id, u.name, u.avatar, u.email, u.matrix_user_id, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); ================================================ FILE: migrations/2020-04-14-163701_update_views_for_activitypub/down.sql ================================================ -- user_view DROP VIEW user_view CASCADE; CREATE VIEW user_view AS SELECT u.id, u.name, u.avatar, u.email, u.matrix_user_id, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); -- community_view DROP VIEW community_aggregates_view CASCADE; CREATE VIEW community_aggregates_view AS SELECT c.*, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE c.creator_id = u.id) AS creator_avatar, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c; CREATE MATERIALIZED VIEW community_aggregates_mview AS SELECT * FROM community_aggregates_view; CREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id); CREATE VIEW community_view AS with all_community AS ( SELECT ca.* FROM community_aggregates_view ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; CREATE VIEW community_mview AS with all_community AS ( SELECT ca.* FROM community_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- community views DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; CREATE VIEW community_moderator_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cm.user_id = u.id), ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_moderator cm; CREATE VIEW community_follower_view AS SELECT *, ( SELECT name FROM user_ u WHERE cf.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cf.user_id = u.id), ( SELECT name FROM community c WHERE cf.community_id = c.id) AS community_name FROM community_follower cf; CREATE VIEW community_user_ban_view AS SELECT *, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cm.user_id = u.id), ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_user_ban cm; -- post_view DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id LEFT JOIN ( SELECT post_id, max(published) AS recent_comment_time FROM comment GROUP BY 1) c ON p.id = c.post_id GROUP BY p.id, c.recent_comment_time; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- reply_view, comment_view, user_mention DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW user_mention_mview; DROP VIEW comment_view; DROP VIEW comment_mview; DROP MATERIALIZED VIEW comment_aggregates_mview; DROP VIEW comment_aggregates_view; -- reply and comment view CREATE VIEW comment_aggregates_view AS SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT co.name FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_name, ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id; CREATE MATERIALIZED VIEW comment_aggregates_mview AS SELECT * FROM comment_aggregates_view; CREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id); CREATE VIEW comment_view AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_view ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; CREATE VIEW comment_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; -- Do the reply_view referencing the comment_mview CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_mview cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id FROM all_comment ac LEFT JOIN user_mention um ON um.comment_id = ac.id; ================================================ FILE: migrations/2020-04-14-163701_update_views_for_activitypub/up.sql ================================================ -- user_view DROP VIEW user_view CASCADE; CREATE VIEW user_view AS SELECT u.id, u.actor_id, u.name, u.avatar, u.email, u.matrix_user_id, u.bio, u.local, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); -- community_view DROP VIEW community_aggregates_view CASCADE; CREATE VIEW community_aggregates_view AS -- Now that there's public and private keys, you have to be explicit here SELECT c.id, c.name, c.title, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, ( SELECT actor_id FROM user_ u WHERE c.creator_id = u.id) AS creator_actor_id, ( SELECT local FROM user_ u WHERE c.creator_id = u.id) AS creator_local, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE c.creator_id = u.id) AS creator_avatar, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c; CREATE MATERIALIZED VIEW community_aggregates_mview AS SELECT * FROM community_aggregates_view; CREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id); CREATE VIEW community_view AS with all_community AS ( SELECT ca.* FROM community_aggregates_view ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; CREATE VIEW community_mview AS with all_community AS ( SELECT ca.* FROM community_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- community views DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; CREATE VIEW community_moderator_view AS SELECT *, ( SELECT actor_id FROM user_ u WHERE cm.user_id = u.id) AS user_actor_id, ( SELECT local FROM user_ u WHERE cm.user_id = u.id) AS user_local, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cm.user_id = u.id), ( SELECT actor_id FROM community c WHERE cm.community_id = c.id) AS community_actor_id, ( SELECT local FROM community c WHERE cm.community_id = c.id) AS community_local, ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_moderator cm; CREATE VIEW community_follower_view AS SELECT *, ( SELECT actor_id FROM user_ u WHERE cf.user_id = u.id) AS user_actor_id, ( SELECT local FROM user_ u WHERE cf.user_id = u.id) AS user_local, ( SELECT name FROM user_ u WHERE cf.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cf.user_id = u.id), ( SELECT actor_id FROM community c WHERE cf.community_id = c.id) AS community_actor_id, ( SELECT local FROM community c WHERE cf.community_id = c.id) AS community_local, ( SELECT name FROM community c WHERE cf.community_id = c.id) AS community_name FROM community_follower cf; CREATE VIEW community_user_ban_view AS SELECT *, ( SELECT actor_id FROM user_ u WHERE cm.user_id = u.id) AS user_actor_id, ( SELECT local FROM user_ u WHERE cm.user_id = u.id) AS user_local, ( SELECT name FROM user_ u WHERE cm.user_id = u.id) AS user_name, ( SELECT avatar FROM user_ u WHERE cm.user_id = u.id), ( SELECT actor_id FROM community c WHERE cm.community_id = c.id) AS community_actor_id, ( SELECT local FROM community c WHERE cm.community_id = c.id) AS community_local, ( SELECT name FROM community c WHERE cm.community_id = c.id) AS community_name FROM community_user_ban cm; -- post_view DROP VIEW post_view; DROP VIEW post_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP VIEW post_aggregates_view; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT actor_id FROM user_ WHERE p.creator_id = user_.id) AS creator_actor_id, ( SELECT local FROM user_ WHERE p.creator_id = user_.id) AS creator_local, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT actor_id FROM community WHERE p.community_id = community.id) AS community_actor_id, ( SELECT local FROM community WHERE p.community_id = community.id) AS community_local, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id LEFT JOIN ( SELECT post_id, max(published) AS recent_comment_time FROM comment GROUP BY 1) c ON p.id = c.post_id GROUP BY p.id, c.recent_comment_time; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; -- reply_view, comment_view, user_mention DROP VIEW reply_view; DROP VIEW user_mention_view; DROP VIEW user_mention_mview; DROP VIEW comment_view; DROP VIEW comment_mview; DROP MATERIALIZED VIEW comment_aggregates_mview; DROP VIEW comment_aggregates_view; -- reply and comment view CREATE VIEW comment_aggregates_view AS SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT co.actor_id FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_actor_id, ( SELECT co.local FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_local, ( SELECT co.name FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_name, ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT actor_id FROM user_ WHERE c.creator_id = user_.id) AS creator_actor_id, ( SELECT local FROM user_ WHERE c.creator_id = user_.id) AS creator_local, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id; CREATE MATERIALIZED VIEW comment_aggregates_mview AS SELECT * FROM comment_aggregates_view; CREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id); CREATE VIEW comment_view AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_view ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; CREATE VIEW comment_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; -- Do the reply_view referencing the comment_mview CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_mview cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM all_comment ac LEFT JOIN user_mention um ON um.comment_id = ac.id; ================================================ FILE: migrations/2020-04-21-123957_remove_unique_user_constraints/down.sql ================================================ -- The username index DROP INDEX idx_user_name_lower_actor_id; CREATE UNIQUE INDEX idx_user_name_lower ON user_ (lower(name)); ================================================ FILE: migrations/2020-04-21-123957_remove_unique_user_constraints/up.sql ================================================ DROP INDEX idx_user_name_lower; CREATE UNIQUE INDEX idx_user_name_lower_actor_id ON user_ (lower(name), lower(actor_id)); ================================================ FILE: migrations/2020-05-05-210233_add_activitypub_for_private_messages/down.sql ================================================ DROP MATERIALIZED VIEW private_message_mview; DROP VIEW private_message_view; ALTER TABLE private_message DROP COLUMN ap_id, DROP COLUMN local; CREATE VIEW private_message_view AS SELECT pm.*, u.name AS creator_name, u.avatar AS creator_avatar, u2.name AS recipient_name, u2.avatar AS recipient_avatar FROM private_message pm INNER JOIN user_ u ON u.id = pm.creator_id INNER JOIN user_ u2 ON u2.id = pm.recipient_id; CREATE MATERIALIZED VIEW private_message_mview AS SELECT * FROM private_message_view; CREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id); ================================================ FILE: migrations/2020-05-05-210233_add_activitypub_for_private_messages/up.sql ================================================ ALTER TABLE private_message ADD COLUMN ap_id character varying(255) NOT NULL DEFAULT 'http://fake.com', -- This needs to be checked and updated in code, building from the site url if local ADD COLUMN local boolean NOT NULL DEFAULT TRUE; DROP MATERIALIZED VIEW private_message_mview; DROP VIEW private_message_view; CREATE VIEW private_message_view AS SELECT pm.*, u.name AS creator_name, u.avatar AS creator_avatar, u.actor_id AS creator_actor_id, u.local AS creator_local, u2.name AS recipient_name, u2.avatar AS recipient_avatar, u2.actor_id AS recipient_actor_id, u2.local AS recipient_local FROM private_message pm INNER JOIN user_ u ON u.id = pm.creator_id INNER JOIN user_ u2 ON u2.id = pm.recipient_id; CREATE MATERIALIZED VIEW private_message_mview AS SELECT * FROM private_message_view; CREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id); ================================================ FILE: migrations/2020-06-30-135809_remove_mat_views/down.sql ================================================ -- Dropping all the fast tables DROP TABLE user_fast; DROP VIEW post_fast_view; DROP TABLE post_aggregates_fast; DROP VIEW community_fast_view; DROP TABLE community_aggregates_fast; DROP VIEW reply_fast_view; DROP VIEW user_mention_fast_view; DROP VIEW comment_fast_view; DROP TABLE comment_aggregates_fast; -- Re-adding all the triggers, functions, and mviews -- private message CREATE MATERIALIZED VIEW private_message_mview AS SELECT * FROM private_message_view; CREATE UNIQUE INDEX idx_private_message_mview_id ON private_message_mview (id); -- Create the triggers CREATE OR REPLACE FUNCTION refresh_private_message () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY private_message_mview; RETURN NULL; END $$; CREATE TRIGGER refresh_private_message AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON private_message FOR EACH statement EXECUTE PROCEDURE refresh_private_message (); -- user CREATE OR REPLACE FUNCTION refresh_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY comment_aggregates_mview; -- cause of bans REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; RETURN NULL; END $$; DROP TRIGGER refresh_user ON user_; CREATE TRIGGER refresh_user AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON user_ FOR EACH statement EXECUTE PROCEDURE refresh_user (); DROP VIEW user_view CASCADE; CREATE VIEW user_view AS SELECT u.id, u.actor_id, u.name, u.avatar, u.email, u.matrix_user_id, u.bio, u.local, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, ( SELECT count(*) FROM post p WHERE p.creator_id = u.id) AS number_of_posts, ( SELECT coalesce(sum(score), 0) FROM post p, post_like pl WHERE u.id = p.creator_id AND p.id = pl.post_id) AS post_score, ( SELECT count(*) FROM comment c WHERE c.creator_id = u.id) AS number_of_comments, ( SELECT coalesce(sum(score), 0) FROM comment c, comment_like cl WHERE u.id = c.creator_id AND c.id = cl.comment_id) AS comment_score FROM user_ u; CREATE MATERIALIZED VIEW user_mview AS SELECT * FROM user_view; CREATE UNIQUE INDEX idx_user_mview_id ON user_mview (id); -- community DROP TRIGGER refresh_community ON community; CREATE TRIGGER refresh_community AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON community FOR EACH statement EXECUTE PROCEDURE refresh_community (); CREATE OR REPLACE FUNCTION refresh_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY community_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; DROP VIEW community_aggregates_view CASCADE; CREATE VIEW community_aggregates_view AS -- Now that there's public and private keys, you have to be explicit here SELECT c.id, c.name, c.title, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, ( SELECT actor_id FROM user_ u WHERE c.creator_id = u.id) AS creator_actor_id, ( SELECT local FROM user_ u WHERE c.creator_id = u.id) AS creator_local, ( SELECT name FROM user_ u WHERE c.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE c.creator_id = u.id) AS creator_avatar, ( SELECT name FROM category ct WHERE c.category_id = ct.id) AS category_name, ( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id) AS number_of_subscribers, ( SELECT count(*) FROM post p WHERE p.community_id = c.id) AS number_of_posts, ( SELECT count(*) FROM comment co, post p WHERE c.id = p.community_id AND p.id = co.post_id) AS number_of_comments, hot_rank (( SELECT count(*) FROM community_follower cf WHERE cf.community_id = c.id), c.published) AS hot_rank FROM community c; CREATE MATERIALIZED VIEW community_aggregates_mview AS SELECT * FROM community_aggregates_view; CREATE UNIQUE INDEX idx_community_aggregates_mview_id ON community_aggregates_mview (id); CREATE VIEW community_view AS with all_community AS ( SELECT ca.* FROM community_aggregates_view ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; CREATE VIEW community_mview AS with all_community AS ( SELECT ca.* FROM community_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN all_community ac UNION ALL SELECT ac.*, NULL AS user_id, NULL AS subscribed FROM all_community ac; -- Post DROP VIEW post_view; DROP VIEW post_aggregates_view; -- regen post view CREATE VIEW post_aggregates_view AS SELECT p.*, ( SELECT u.banned FROM user_ u WHERE p.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb WHERE p.creator_id = cb.user_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT actor_id FROM user_ WHERE p.creator_id = user_.id) AS creator_actor_id, ( SELECT local FROM user_ WHERE p.creator_id = user_.id) AS creator_local, ( SELECT name FROM user_ WHERE p.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE p.creator_id = user_.id) AS creator_avatar, ( SELECT actor_id FROM community WHERE p.community_id = community.id) AS community_actor_id, ( SELECT local FROM community WHERE p.community_id = community.id) AS community_local, ( SELECT name FROM community WHERE p.community_id = community.id) AS community_name, ( SELECT removed FROM community c WHERE p.community_id = c.id) AS community_removed, ( SELECT deleted FROM community c WHERE p.community_id = c.id) AS community_deleted, ( SELECT nsfw FROM community c WHERE p.community_id = c.id) AS community_nsfw, ( SELECT count(*) FROM comment WHERE comment.post_id = p.id) AS number_of_comments, coalesce(sum(pl.score), 0) AS score, count( CASE WHEN pl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN pl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(pl.score), 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published -- Prevents necro-bumps ELSE greatest (c.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id LEFT JOIN ( SELECT post_id, max(published) AS recent_comment_time FROM comment GROUP BY 1) c ON p.id = c.post_id GROUP BY p.id, c.recent_comment_time; CREATE MATERIALIZED VIEW post_aggregates_mview AS SELECT * FROM post_aggregates_view; CREATE UNIQUE INDEX idx_post_aggregates_mview_id ON post_aggregates_mview (id); CREATE VIEW post_view AS with all_post AS ( SELECT pa.* FROM post_aggregates_view pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; CREATE VIEW post_mview AS with all_post AS ( SELECT pa.* FROM post_aggregates_mview pa ) SELECT ap.*, u.id AS user_id, coalesce(pl.score, 0) AS my_vote, ( SELECT cf.id::bool FROM community_follower cf WHERE u.id = cf.user_id AND cf.community_id = ap.community_id) AS subscribed, ( SELECT pr.id::bool FROM post_read pr WHERE u.id = pr.user_id AND pr.post_id = ap.id) AS read, ( SELECT ps.id::bool FROM post_saved ps WHERE u.id = ps.user_id AND ps.post_id = ap.id) AS saved FROM user_ u CROSS JOIN all_post ap LEFT JOIN post_like pl ON u.id = pl.user_id AND ap.id = pl.post_id UNION ALL SELECT ap.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM all_post ap; DROP TRIGGER refresh_post ON post; CREATE TRIGGER refresh_post AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON post FOR EACH statement EXECUTE PROCEDURE refresh_post (); CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY post_aggregates_mview; REFRESH MATERIALIZED VIEW CONCURRENTLY user_mview; RETURN NULL; END $$; -- User mention, comment, reply DROP VIEW user_mention_view; DROP VIEW comment_view; DROP VIEW comment_aggregates_view; -- reply and comment view CREATE VIEW comment_aggregates_view AS SELECT c.*, ( SELECT community_id FROM post p WHERE p.id = c.post_id), ( SELECT co.actor_id FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_actor_id, ( SELECT co.local FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_local, ( SELECT co.name FROM post p, community co WHERE p.id = c.post_id AND p.community_id = co.id) AS community_name, ( SELECT u.banned FROM user_ u WHERE c.creator_id = u.id) AS banned, ( SELECT cb.id::bool FROM community_user_ban cb, post p WHERE c.creator_id = cb.user_id AND p.id = c.post_id AND p.community_id = cb.community_id) AS banned_from_community, ( SELECT actor_id FROM user_ WHERE c.creator_id = user_.id) AS creator_actor_id, ( SELECT local FROM user_ WHERE c.creator_id = user_.id) AS creator_local, ( SELECT name FROM user_ WHERE c.creator_id = user_.id) AS creator_name, ( SELECT avatar FROM user_ WHERE c.creator_id = user_.id) AS creator_avatar, coalesce(sum(cl.score), 0) AS score, count( CASE WHEN cl.score = 1 THEN 1 ELSE NULL END) AS upvotes, count( CASE WHEN cl.score = -1 THEN 1 ELSE NULL END) AS downvotes, hot_rank (coalesce(sum(cl.score), 0), c.published) AS hot_rank FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.id; CREATE MATERIALIZED VIEW comment_aggregates_mview AS SELECT * FROM comment_aggregates_view; CREATE UNIQUE INDEX idx_comment_aggregates_mview_id ON comment_aggregates_mview (id); CREATE VIEW comment_view AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_view ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; CREATE VIEW comment_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.*, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.community_id = cf.community_id) AS subscribed, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id UNION ALL SELECT ac.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM all_comment ac; -- Do the reply_view referencing the comment_mview CREATE VIEW reply_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_mview cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_mview AS with all_comment AS ( SELECT ca.* FROM comment_aggregates_mview ca ) SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN all_comment ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM all_comment ac LEFT JOIN user_mention um ON um.comment_id = ac.id; ================================================ FILE: migrations/2020-06-30-135809_remove_mat_views/up.sql ================================================ -- Drop the mviews DROP VIEW post_mview; DROP MATERIALIZED VIEW user_mview; DROP VIEW community_mview; DROP MATERIALIZED VIEW private_message_mview; DROP VIEW user_mention_mview; DROP VIEW reply_view; DROP VIEW comment_mview; DROP MATERIALIZED VIEW post_aggregates_mview; DROP MATERIALIZED VIEW community_aggregates_mview; DROP MATERIALIZED VIEW comment_aggregates_mview; DROP TRIGGER refresh_private_message ON private_message; -- User DROP VIEW user_view; CREATE VIEW user_view AS SELECT u.id, u.actor_id, u.name, u.avatar, u.email, u.matrix_user_id, u.bio, u.local, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, coalesce(pd.posts, 0) AS number_of_posts, coalesce(pd.score, 0) AS post_score, coalesce(cd.comments, 0) AS number_of_comments, coalesce(cd.score, 0) AS comment_score FROM user_ u LEFT JOIN ( SELECT p.creator_id AS creator_id, count(DISTINCT p.id) AS posts, sum(pl.score) AS score FROM post p JOIN post_like pl ON p.id = pl.post_id GROUP BY p.creator_id) pd ON u.id = pd.creator_id LEFT JOIN ( SELECT c.creator_id, count(DISTINCT c.id) AS comments, sum(cl.score) AS score FROM comment c JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.creator_id) cd ON u.id = cd.creator_id; CREATE TABLE user_fast AS SELECT * FROM user_view; ALTER TABLE user_fast ADD PRIMARY KEY (id); DROP TRIGGER refresh_user ON user_; CREATE TRIGGER refresh_user AFTER INSERT OR UPDATE OR DELETE ON user_ FOR EACH ROW EXECUTE PROCEDURE refresh_user (); -- Sample insert -- insert into user_(name, password_encrypted) values ('test_name', 'bleh'); -- Sample delete -- delete from user_ where name like 'test_name'; -- Sample update -- update user_ set avatar = 'hai' where name like 'test_name'; CREATE OR REPLACE FUNCTION refresh_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM user_fast WHERE id = OLD.id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM user_fast WHERE id = OLD.id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.id; -- Refresh post_fast, cause of user info changes DELETE FROM post_aggregates_fast WHERE creator_id = NEW.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE creator_id = NEW.id; DELETE FROM comment_aggregates_fast WHERE creator_id = NEW.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE creator_id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.id; END IF; RETURN NULL; END $$; -- Post -- Redoing the views : Credit eiknat DROP VIEW post_view; DROP VIEW post_aggregates_view; CREATE VIEW post_aggregates_view AS SELECT p.*, -- creator details u.actor_id AS creator_actor_id, u."local" AS creator_local, u."name" AS creator_name, u.avatar AS creator_avatar, u.banned AS banned, cb.id::bool AS banned_from_community, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, c.removed AS community_removed, c.deleted AS community_deleted, c.nsfw AS community_nsfw, -- post score data/comment count coalesce(ct.comments, 0) AS number_of_comments, coalesce(pl.score, 0) AS score, coalesce(pl.upvotes, 0) AS upvotes, coalesce(pl.downvotes, 0) AS downvotes, hot_rank (coalesce(pl.score, 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN user_ u ON p.creator_id = u.id LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id AND p.community_id = cb.community_id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN ( SELECT post_id, count(*) AS comments, max(published) AS recent_comment_time FROM comment GROUP BY post_id) ct ON ct.post_id = p.id LEFT JOIN ( SELECT post_id, sum(score) AS score, sum(score) FILTER (WHERE score = 1) AS upvotes, - sum(score) FILTER (WHERE score = -1) AS downvotes FROM post_like GROUP BY post_id) pl ON pl.post_id = p.id ORDER BY p.id; CREATE VIEW post_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_view pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_view pav; -- The post fast table CREATE TABLE post_aggregates_fast AS SELECT * FROM post_aggregates_view; ALTER TABLE post_aggregates_fast ADD PRIMARY KEY (id); -- For the hot rank resorting CREATE INDEX idx_post_aggregates_fast_hot_rank_published ON post_aggregates_fast (hot_rank DESC, published DESC); CREATE VIEW post_fast_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_fast pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_fast pav; DROP TRIGGER refresh_post ON post; CREATE TRIGGER refresh_post AFTER INSERT OR UPDATE OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE refresh_post (); -- Sample select -- select id, name from post_fast_view where name like 'test_post' and user_id is null; -- Sample insert -- insert into post(name, creator_id, community_id) values ('test_post', 2, 2); -- Sample delete -- delete from post where name like 'test_post'; -- Sample update -- update post set community_id = 4 where name like 'test_post'; CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; -- Community -- Redoing the views : Credit eiknat DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; DROP VIEW community_view; DROP VIEW community_aggregates_view; CREATE VIEW community_aggregates_view AS SELECT c.id, c.name, c.title, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.avatar AS creator_avatar, cat.name AS category_name, coalesce(cf.subs, 0) AS number_of_subscribers, coalesce(cd.posts, 0) AS number_of_posts, coalesce(cd.comments, 0) AS number_of_comments, hot_rank (cf.subs, c.published) AS hot_rank FROM community c LEFT JOIN user_ u ON c.creator_id = u.id LEFT JOIN category cat ON c.category_id = cat.id LEFT JOIN ( SELECT p.community_id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM post p JOIN comment ct ON p.id = ct.post_id GROUP BY p.community_id) cd ON cd.community_id = c.id LEFT JOIN ( SELECT community_id, count(*) AS subs FROM community_follower GROUP BY community_id) cf ON cf.community_id = c.id; CREATE VIEW community_view AS SELECT cv.*, us.user AS user_id, us.is_subbed::bool AS subscribed FROM community_aggregates_view cv CROSS JOIN LATERAL ( SELECT u.id AS user, coalesce(cf.community_id, 0) AS is_subbed FROM user_ u LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = cv.id) AS us UNION ALL SELECT cv.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_view cv; CREATE VIEW community_moderator_view AS SELECT cm.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name FROM community_moderator cm LEFT JOIN user_ u ON cm.user_id = u.id LEFT JOIN community c ON cm.community_id = c.id; CREATE VIEW community_follower_view AS SELECT cf.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name FROM community_follower cf LEFT JOIN user_ u ON cf.user_id = u.id LEFT JOIN community c ON cf.community_id = c.id; CREATE VIEW community_user_ban_view AS SELECT cb.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name FROM community_user_ban cb LEFT JOIN user_ u ON cb.user_id = u.id LEFT JOIN community c ON cb.community_id = c.id; -- The community fast table CREATE TABLE community_aggregates_fast AS SELECT * FROM community_aggregates_view; ALTER TABLE community_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW community_fast_view AS SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN ( SELECT ca.* FROM community_aggregates_fast ca) ac UNION ALL SELECT caf.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_fast caf; DROP TRIGGER refresh_community ON community; CREATE TRIGGER refresh_community AFTER INSERT OR UPDATE OR DELETE ON community FOR EACH ROW EXECUTE PROCEDURE refresh_community (); -- Sample select -- select * from community_fast_view where name like 'test_community_name' and user_id is null; -- Sample insert -- insert into community(name, title, category_id, creator_id) values ('test_community_name', 'test_community_title', 1, 2); -- Sample delete -- delete from community where name like 'test_community_name'; -- Sample update -- update community set title = 'test_community_title_2' where name like 'test_community_name'; CREATE OR REPLACE FUNCTION refresh_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates_fast WHERE id = OLD.id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM community_aggregates_fast WHERE id = OLD.id; INSERT INTO community_aggregates_fast SELECT * FROM community_aggregates_view WHERE id = NEW.id; -- Update user view due to owner changes DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id; -- Update post view due to community changes DELETE FROM post_aggregates_fast WHERE community_id = NEW.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE community_id = NEW.id; -- TODO make sure this shows up in the users page ? ELSIF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates_fast SELECT * FROM community_aggregates_view WHERE id = NEW.id; END IF; RETURN NULL; END $$; -- Comment DROP VIEW user_mention_view; DROP VIEW comment_view; DROP VIEW comment_aggregates_view; CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- community details p.community_id, c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); -- The fast view CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; -- user mention CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; DROP TRIGGER refresh_comment ON comment; CREATE TRIGGER refresh_comment AFTER INSERT OR UPDATE OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE refresh_comment (); -- Sample select -- select * from comment_fast_view where content = 'test_comment' and user_id is null; -- Sample insert -- insert into comment(creator_id, post_id, content) values (2, 2, 'test_comment'); -- Sample delete -- delete from comment where content like 'test_comment'; -- Sample update -- update comment set removed = true where content like 'test_comment'; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id; -- Force the hot rank as zero on week-older posts UPDATE post_aggregates_fast AS paf SET hot_rank = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '1 week'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; -- post_like -- select id, score, my_vote from post_fast_view where id = 29 and user_id = 4; -- Sample insert -- insert into post_like(user_id, post_id, score) values (4, 29, 1); -- Sample delete -- delete from post_like where user_id = 4 and post_id = 29; -- Sample update -- update post_like set score = -1 where user_id = 4 and post_id = 29; -- TODO test this a LOT CREATE OR REPLACE FUNCTION refresh_post_like () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN UPDATE post_aggregates_fast SET score = CASE WHEN (OLD.score = 1) THEN score - 1 ELSE score + 1 END, upvotes = CASE WHEN (OLD.score = 1) THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN (OLD.score = -1) THEN downvotes - 1 ELSE downvotes END WHERE id = OLD.post_id; ELSIF (TG_OP = 'INSERT') THEN UPDATE post_aggregates_fast SET score = CASE WHEN (NEW.score = 1) THEN score + 1 ELSE score - 1 END, upvotes = CASE WHEN (NEW.score = 1) THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN (NEW.score = -1) THEN downvotes + 1 ELSE downvotes END WHERE id = NEW.post_id; END IF; RETURN NULL; END $$; DROP TRIGGER refresh_post_like ON post_like; CREATE TRIGGER refresh_post_like AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE PROCEDURE refresh_post_like (); -- comment_like -- select id, score, my_vote from comment_fast_view where id = 29 and user_id = 4; -- Sample insert -- insert into comment_like(user_id, comment_id, post_id, score) values (4, 29, 51, 1); -- Sample delete -- delete from comment_like where user_id = 4 and comment_id = 29; -- Sample update -- update comment_like set score = -1 where user_id = 4 and comment_id = 29; CREATE OR REPLACE FUNCTION refresh_comment_like () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- TODO possibly select from comment_fast to get previous scores, instead of re-fetching the views? IF (TG_OP = 'DELETE') THEN UPDATE comment_aggregates_fast SET score = CASE WHEN (OLD.score = 1) THEN score - 1 ELSE score + 1 END, upvotes = CASE WHEN (OLD.score = 1) THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN (OLD.score = -1) THEN downvotes - 1 ELSE downvotes END WHERE id = OLD.comment_id; ELSIF (TG_OP = 'INSERT') THEN UPDATE comment_aggregates_fast SET score = CASE WHEN (NEW.score = 1) THEN score + 1 ELSE score - 1 END, upvotes = CASE WHEN (NEW.score = 1) THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN (NEW.score = -1) THEN downvotes + 1 ELSE downvotes END WHERE id = NEW.comment_id; END IF; RETURN NULL; END $$; DROP TRIGGER refresh_comment_like ON comment_like; CREATE TRIGGER refresh_comment_like AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE PROCEDURE refresh_comment_like (); -- Community user ban DROP TRIGGER refresh_community_user_ban ON community_user_ban; CREATE TRIGGER refresh_community_user_ban AFTER INSERT OR DELETE -- Note this is missing after update ON community_user_ban FOR EACH ROW EXECUTE PROCEDURE refresh_community_user_ban (); -- select creator_name, banned_from_community from comment_fast_view where user_id = 4 and content = 'test_before_ban'; -- select creator_name, banned_from_community, community_id from comment_aggregates_fast where content = 'test_before_ban'; -- Sample insert -- insert into comment(creator_id, post_id, content) values (1198, 341, 'test_before_ban'); -- insert into community_user_ban(community_id, user_id) values (2, 1198); -- Sample delete -- delete from community_user_ban where user_id = 1198 and community_id = 2; -- delete from comment where content = 'test_before_ban'; -- update comment_aggregates_fast set banned_from_community = false where creator_id = 1198 and community_id = 2; CREATE OR REPLACE FUNCTION refresh_community_user_ban () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- TODO possibly select from comment_fast to get previous scores, instead of re-fetching the views? IF (TG_OP = 'DELETE') THEN UPDATE comment_aggregates_fast SET banned_from_community = FALSE WHERE creator_id = OLD.user_id AND community_id = OLD.community_id; UPDATE post_aggregates_fast SET banned_from_community = FALSE WHERE creator_id = OLD.user_id AND community_id = OLD.community_id; ELSIF (TG_OP = 'INSERT') THEN UPDATE comment_aggregates_fast SET banned_from_community = TRUE WHERE creator_id = NEW.user_id AND community_id = NEW.community_id; UPDATE post_aggregates_fast SET banned_from_community = TRUE WHERE creator_id = NEW.user_id AND community_id = NEW.community_id; END IF; RETURN NULL; END $$; -- Community follower DROP TRIGGER refresh_community_follower ON community_follower; CREATE TRIGGER refresh_community_follower AFTER INSERT OR DELETE -- Note this is missing after update ON community_follower FOR EACH ROW EXECUTE PROCEDURE refresh_community_follower (); CREATE OR REPLACE FUNCTION refresh_community_follower () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN UPDATE community_aggregates_fast SET number_of_subscribers = number_of_subscribers - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'INSERT') THEN UPDATE community_aggregates_fast SET number_of_subscribers = number_of_subscribers + 1 WHERE id = NEW.community_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-07-08-202609_add_creator_published/down.sql ================================================ DROP VIEW user_mention_view; DROP VIEW reply_fast_view; DROP VIEW comment_fast_view; DROP VIEW comment_view; DROP VIEW user_mention_fast_view; DROP TABLE comment_aggregates_fast; DROP VIEW comment_aggregates_view; CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- community details p.community_id, c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; -- add creator_published to the post view DROP VIEW post_fast_view; DROP TABLE post_aggregates_fast; DROP VIEW post_view; DROP VIEW post_aggregates_view; CREATE VIEW post_aggregates_view AS SELECT p.*, -- creator details u.actor_id AS creator_actor_id, u."local" AS creator_local, u."name" AS creator_name, u.avatar AS creator_avatar, u.banned AS banned, cb.id::bool AS banned_from_community, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, c.removed AS community_removed, c.deleted AS community_deleted, c.nsfw AS community_nsfw, -- post score data/comment count coalesce(ct.comments, 0) AS number_of_comments, coalesce(pl.score, 0) AS score, coalesce(pl.upvotes, 0) AS upvotes, coalesce(pl.downvotes, 0) AS downvotes, hot_rank (coalesce(pl.score, 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN user_ u ON p.creator_id = u.id LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id AND p.community_id = cb.community_id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN ( SELECT post_id, count(*) AS comments, max(published) AS recent_comment_time FROM comment GROUP BY post_id) ct ON ct.post_id = p.id LEFT JOIN ( SELECT post_id, sum(score) AS score, sum(score) FILTER (WHERE score = 1) AS upvotes, - sum(score) FILTER (WHERE score = -1) AS downvotes FROM post_like GROUP BY post_id) pl ON pl.post_id = p.id ORDER BY p.id; CREATE VIEW post_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_view pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_view pav; CREATE TABLE post_aggregates_fast AS SELECT * FROM post_aggregates_view; ALTER TABLE post_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW post_fast_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_fast pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_fast pav; CREATE INDEX idx_post_aggregates_fast_hot_rank_published ON post_aggregates_fast (hot_rank DESC, published DESC); ================================================ FILE: migrations/2020-07-08-202609_add_creator_published/up.sql ================================================ DROP VIEW user_mention_view; DROP VIEW reply_fast_view; DROP VIEW comment_fast_view; DROP VIEW comment_view; DROP VIEW user_mention_fast_view; DROP TABLE comment_aggregates_fast; DROP VIEW comment_aggregates_view; CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- community details p.community_id, c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.published AS creator_published, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; -- add creator_published to the post view DROP VIEW post_fast_view; DROP TABLE post_aggregates_fast; DROP VIEW post_view; DROP VIEW post_aggregates_view; CREATE VIEW post_aggregates_view AS SELECT p.*, -- creator details u.actor_id AS creator_actor_id, u."local" AS creator_local, u."name" AS creator_name, u.published AS creator_published, u.avatar AS creator_avatar, u.banned AS banned, cb.id::bool AS banned_from_community, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, c.removed AS community_removed, c.deleted AS community_deleted, c.nsfw AS community_nsfw, -- post score data/comment count coalesce(ct.comments, 0) AS number_of_comments, coalesce(pl.score, 0) AS score, coalesce(pl.upvotes, 0) AS upvotes, coalesce(pl.downvotes, 0) AS downvotes, hot_rank (coalesce(pl.score, 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN user_ u ON p.creator_id = u.id LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id AND p.community_id = cb.community_id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN ( SELECT post_id, count(*) AS comments, max(published) AS recent_comment_time FROM comment GROUP BY post_id) ct ON ct.post_id = p.id LEFT JOIN ( SELECT post_id, sum(score) AS score, sum(score) FILTER (WHERE score = 1) AS upvotes, - sum(score) FILTER (WHERE score = -1) AS downvotes FROM post_like GROUP BY post_id) pl ON pl.post_id = p.id ORDER BY p.id; CREATE VIEW post_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_view pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_view pav; CREATE TABLE post_aggregates_fast AS SELECT * FROM post_aggregates_view; ALTER TABLE post_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW post_fast_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_fast pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_fast pav; ================================================ FILE: migrations/2020-07-12-100442_add_post_title_to_comments_view/down.sql ================================================ DROP VIEW user_mention_view; DROP VIEW reply_fast_view; DROP VIEW comment_fast_view; DROP VIEW comment_view; DROP VIEW user_mention_fast_view; DROP TABLE comment_aggregates_fast; DROP VIEW comment_aggregates_view; CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- community details p.community_id, c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.published AS creator_published, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2020-07-12-100442_add_post_title_to_comments_view/up.sql ================================================ DROP VIEW user_mention_view; DROP VIEW reply_fast_view; DROP VIEW comment_fast_view; DROP VIEW comment_view; DROP VIEW user_mention_fast_view; DROP TABLE comment_aggregates_fast; DROP VIEW comment_aggregates_view; CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- post details p."name" AS post_name, p.community_id, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.published AS creator_published, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.post_name, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.post_name, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.post_name, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; ================================================ FILE: migrations/2020-07-18-234519_add_unique_community_user_actor_ids/down.sql ================================================ ALTER TABLE community ALTER COLUMN actor_id SET NOT NULL; ALTER TABLE community ALTER COLUMN actor_id SET DEFAULT 'http://fake.com'; ALTER TABLE user_ ALTER COLUMN actor_id SET NOT NULL; ALTER TABLE user_ ALTER COLUMN actor_id SET DEFAULT 'http://fake.com'; DROP FUNCTION generate_unique_changeme; UPDATE community SET actor_id = 'http://fake.com' WHERE actor_id LIKE 'changeme_%'; UPDATE user_ SET actor_id = 'http://fake.com' WHERE actor_id LIKE 'changeme_%'; DROP INDEX idx_user_lower_actor_id; CREATE UNIQUE INDEX idx_user_name_lower_actor_id ON user_ (lower(name), lower(actor_id)); DROP INDEX idx_community_lower_actor_id; ================================================ FILE: migrations/2020-07-18-234519_add_unique_community_user_actor_ids/up.sql ================================================ -- Following this issue : https://github.com/LemmyNet/lemmy/issues/957 -- Creating a unique changeme actor_id CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '') FROM generate_series(1, 20) $$; -- Need to delete the possible community and user dupes for ones that don't start with the fake one -- A few test inserts, to make sure this removes later dupes -- insert into community (name, title, category_id, creator_id) values ('testcom', 'another testcom', 1, 2); DELETE FROM community a USING ( SELECT min(id) AS id, actor_id FROM community GROUP BY actor_id HAVING count(*) > 1) b WHERE a.actor_id = b.actor_id AND a.id <> b.id; DELETE FROM user_ a USING ( SELECT min(id) AS id, actor_id FROM user_ GROUP BY actor_id HAVING count(*) > 1) b WHERE a.actor_id = b.actor_id AND a.id <> b.id; -- Replacing the current default on the columns, to the unique one UPDATE community SET actor_id = generate_unique_changeme () WHERE actor_id = 'http://fake.com'; UPDATE user_ SET actor_id = generate_unique_changeme () WHERE actor_id = 'http://fake.com'; -- Add the unique indexes ALTER TABLE community ALTER COLUMN actor_id SET NOT NULL; ALTER TABLE community ALTER COLUMN actor_id SET DEFAULT generate_unique_changeme (); ALTER TABLE user_ ALTER COLUMN actor_id SET NOT NULL; ALTER TABLE user_ ALTER COLUMN actor_id SET DEFAULT generate_unique_changeme (); -- Add lowercase uniqueness too DROP INDEX idx_user_name_lower_actor_id; CREATE UNIQUE INDEX idx_user_lower_actor_id ON user_ (lower(actor_id)); CREATE UNIQUE INDEX idx_community_lower_actor_id ON community (lower(actor_id)); ================================================ FILE: migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/down.sql ================================================ -- Drops first DROP VIEW site_view; DROP TABLE user_fast; DROP VIEW user_view; DROP VIEW post_fast_view; DROP TABLE post_aggregates_fast; DROP VIEW post_view; DROP VIEW post_aggregates_view; DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; DROP VIEW community_view; DROP VIEW community_aggregates_view; DROP VIEW community_fast_view; DROP TABLE community_aggregates_fast; DROP VIEW private_message_view; DROP VIEW user_mention_view; DROP VIEW reply_fast_view; DROP VIEW comment_fast_view; DROP VIEW comment_view; DROP VIEW user_mention_fast_view; DROP TABLE comment_aggregates_fast; DROP VIEW comment_aggregates_view; ALTER TABLE site DROP COLUMN icon, DROP COLUMN banner; ALTER TABLE community DROP COLUMN icon, DROP COLUMN banner; ALTER TABLE user_ DROP COLUMN banner; -- Site CREATE VIEW site_view AS SELECT *, ( SELECT name FROM user_ u WHERE s.creator_id = u.id) AS creator_name, ( SELECT avatar FROM user_ u WHERE s.creator_id = u.id) AS creator_avatar, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s; -- User CREATE VIEW user_view AS SELECT u.id, u.actor_id, u.name, u.avatar, u.email, u.matrix_user_id, u.bio, u.local, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, coalesce(pd.posts, 0) AS number_of_posts, coalesce(pd.score, 0) AS post_score, coalesce(cd.comments, 0) AS number_of_comments, coalesce(cd.score, 0) AS comment_score FROM user_ u LEFT JOIN ( SELECT p.creator_id AS creator_id, count(DISTINCT p.id) AS posts, sum(pl.score) AS score FROM post p JOIN post_like pl ON p.id = pl.post_id GROUP BY p.creator_id) pd ON u.id = pd.creator_id LEFT JOIN ( SELECT c.creator_id, count(DISTINCT c.id) AS comments, sum(cl.score) AS score FROM comment c JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.creator_id) cd ON u.id = cd.creator_id; CREATE TABLE user_fast AS SELECT * FROM user_view; ALTER TABLE user_fast ADD PRIMARY KEY (id); -- Post fast CREATE VIEW post_aggregates_view AS SELECT p.*, -- creator details u.actor_id AS creator_actor_id, u."local" AS creator_local, u."name" AS creator_name, u.published AS creator_published, u.avatar AS creator_avatar, u.banned AS banned, cb.id::bool AS banned_from_community, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, c.removed AS community_removed, c.deleted AS community_deleted, c.nsfw AS community_nsfw, -- post score data/comment count coalesce(ct.comments, 0) AS number_of_comments, coalesce(pl.score, 0) AS score, coalesce(pl.upvotes, 0) AS upvotes, coalesce(pl.downvotes, 0) AS downvotes, hot_rank (coalesce(pl.score, 0), ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END)) AS hot_rank, ( CASE WHEN (p.published < ('now'::timestamp - '1 month'::interval)) THEN p.published ELSE greatest (ct.recent_comment_time, p.published) END) AS newest_activity_time FROM post p LEFT JOIN user_ u ON p.creator_id = u.id LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id AND p.community_id = cb.community_id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN ( SELECT post_id, count(*) AS comments, max(published) AS recent_comment_time FROM comment GROUP BY post_id) ct ON ct.post_id = p.id LEFT JOIN ( SELECT post_id, sum(score) AS score, sum(score) FILTER (WHERE score = 1) AS upvotes, - sum(score) FILTER (WHERE score = -1) AS downvotes FROM post_like GROUP BY post_id) pl ON pl.post_id = p.id ORDER BY p.id; CREATE VIEW post_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_view pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_view pav; CREATE TABLE post_aggregates_fast AS SELECT * FROM post_aggregates_view; ALTER TABLE post_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW post_fast_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_fast pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_fast pav; -- Community CREATE VIEW community_aggregates_view AS SELECT c.id, c.name, c.title, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.avatar AS creator_avatar, cat.name AS category_name, coalesce(cf.subs, 0) AS number_of_subscribers, coalesce(cd.posts, 0) AS number_of_posts, coalesce(cd.comments, 0) AS number_of_comments, hot_rank (cf.subs, c.published) AS hot_rank FROM community c LEFT JOIN user_ u ON c.creator_id = u.id LEFT JOIN category cat ON c.category_id = cat.id LEFT JOIN ( SELECT p.community_id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM post p JOIN comment ct ON p.id = ct.post_id GROUP BY p.community_id) cd ON cd.community_id = c.id LEFT JOIN ( SELECT community_id, count(*) AS subs FROM community_follower GROUP BY community_id) cf ON cf.community_id = c.id; CREATE VIEW community_view AS SELECT cv.*, us.user AS user_id, us.is_subbed::bool AS subscribed FROM community_aggregates_view cv CROSS JOIN LATERAL ( SELECT u.id AS user, coalesce(cf.community_id, 0) AS is_subbed FROM user_ u LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = cv.id) AS us UNION ALL SELECT cv.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_view cv; CREATE VIEW community_moderator_view AS SELECT cm.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name FROM community_moderator cm LEFT JOIN user_ u ON cm.user_id = u.id LEFT JOIN community c ON cm.community_id = c.id; CREATE VIEW community_follower_view AS SELECT cf.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name FROM community_follower cf LEFT JOIN user_ u ON cf.user_id = u.id LEFT JOIN community c ON cf.community_id = c.id; CREATE VIEW community_user_ban_view AS SELECT cb.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name FROM community_user_ban cb LEFT JOIN user_ u ON cb.user_id = u.id LEFT JOIN community c ON cb.community_id = c.id; -- The community fast table CREATE TABLE community_aggregates_fast AS SELECT * FROM community_aggregates_view; ALTER TABLE community_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW community_fast_view AS SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN ( SELECT ca.* FROM community_aggregates_fast ca) ac UNION ALL SELECT caf.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_fast caf; -- Private message CREATE VIEW private_message_view AS SELECT pm.*, u.name AS creator_name, u.avatar AS creator_avatar, u.actor_id AS creator_actor_id, u.local AS creator_local, u2.name AS recipient_name, u2.avatar AS recipient_avatar, u2.actor_id AS recipient_actor_id, u2.local AS recipient_local FROM private_message pm INNER JOIN user_ u ON u.id = pm.creator_id INNER JOIN user_ u2 ON u2.id = pm.recipient_id; -- Comments, mentions, replies CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- post details p."name" AS post_name, p.community_id, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.published AS creator_published, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 0), ct.published) AS hot_rank FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.post_name, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.banned, c.banned_from_community, c.creator_name, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.post_name, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.post_name, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; -- redoing the triggers CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id; -- Force the hot rank as zero on week-older posts UPDATE post_aggregates_fast AS paf SET hot_rank = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '1 week'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-08-03-000110_add_preferred_usernames_banners_and_icons/up.sql ================================================ -- This adds the following columns, as well as updates the views: -- Site icon -- Site banner -- Community icon -- Community Banner -- User Banner (User avatar is already there) -- User preferred name (already in table, needs to be added to view) -- It also adds hot_rank_active to post_view ALTER TABLE site ADD COLUMN icon text, ADD COLUMN banner text; ALTER TABLE community ADD COLUMN icon text, ADD COLUMN banner text; ALTER TABLE user_ ADD COLUMN banner text; DROP VIEW site_view; CREATE VIEW site_view AS SELECT s.*, u.name AS creator_name, u.preferred_username AS creator_preferred_username, u.avatar AS creator_avatar, ( SELECT count(*) FROM user_) AS number_of_users, ( SELECT count(*) FROM post) AS number_of_posts, ( SELECT count(*) FROM comment) AS number_of_comments, ( SELECT count(*) FROM community) AS number_of_communities FROM site s LEFT JOIN user_ u ON s.creator_id = u.id; -- User DROP TABLE user_fast; DROP VIEW user_view; CREATE VIEW user_view AS SELECT u.id, u.actor_id, u.name, u.preferred_username, u.avatar, u.banner, u.email, u.matrix_user_id, u.bio, u.local, u.admin, u.banned, u.show_avatars, u.send_notifications_to_email, u.published, coalesce(pd.posts, 0) AS number_of_posts, coalesce(pd.score, 0) AS post_score, coalesce(cd.comments, 0) AS number_of_comments, coalesce(cd.score, 0) AS comment_score FROM user_ u LEFT JOIN ( SELECT p.creator_id AS creator_id, count(DISTINCT p.id) AS posts, sum(pl.score) AS score FROM post p JOIN post_like pl ON p.id = pl.post_id GROUP BY p.creator_id) pd ON u.id = pd.creator_id LEFT JOIN ( SELECT c.creator_id, count(DISTINCT c.id) AS comments, sum(cl.score) AS score FROM comment c JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.creator_id) cd ON u.id = cd.creator_id; CREATE TABLE user_fast AS SELECT * FROM user_view; ALTER TABLE user_fast ADD PRIMARY KEY (id); -- private message DROP VIEW private_message_view; CREATE VIEW private_message_view AS SELECT pm.*, u.name AS creator_name, u.preferred_username AS creator_preferred_username, u.avatar AS creator_avatar, u.actor_id AS creator_actor_id, u.local AS creator_local, u2.name AS recipient_name, u2.preferred_username AS recipient_preferred_username, u2.avatar AS recipient_avatar, u2.actor_id AS recipient_actor_id, u2.local AS recipient_local FROM private_message pm INNER JOIN user_ u ON u.id = pm.creator_id INNER JOIN user_ u2 ON u2.id = pm.recipient_id; -- Post fast DROP VIEW post_fast_view; DROP TABLE post_aggregates_fast; DROP VIEW post_view; DROP VIEW post_aggregates_view; CREATE VIEW post_aggregates_view AS SELECT p.*, -- creator details u.actor_id AS creator_actor_id, u."local" AS creator_local, u."name" AS creator_name, u."preferred_username" AS creator_preferred_username, u.published AS creator_published, u.avatar AS creator_avatar, u.banned AS banned, cb.id::bool AS banned_from_community, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, c.icon AS community_icon, c.removed AS community_removed, c.deleted AS community_deleted, c.nsfw AS community_nsfw, -- post score data/comment count coalesce(ct.comments, 0) AS number_of_comments, coalesce(pl.score, 0) AS score, coalesce(pl.upvotes, 0) AS upvotes, coalesce(pl.downvotes, 0) AS downvotes, hot_rank (coalesce(pl.score, 1), p.published) AS hot_rank, hot_rank (coalesce(pl.score, 1), greatest (ct.recent_comment_time, p.published)) AS hot_rank_active, greatest (ct.recent_comment_time, p.published) AS newest_activity_time FROM post p LEFT JOIN user_ u ON p.creator_id = u.id LEFT JOIN community_user_ban cb ON p.creator_id = cb.user_id AND p.community_id = cb.community_id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN ( SELECT post_id, count(*) AS comments, max(published) AS recent_comment_time FROM comment GROUP BY post_id) ct ON ct.post_id = p.id LEFT JOIN ( SELECT post_id, sum(score) AS score, sum(score) FILTER (WHERE score = 1) AS upvotes, - sum(score) FILTER (WHERE score = -1) AS downvotes FROM post_like GROUP BY post_id) pl ON pl.post_id = p.id ORDER BY p.id; CREATE VIEW post_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_view pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_view pav; CREATE TABLE post_aggregates_fast AS SELECT * FROM post_aggregates_view; ALTER TABLE post_aggregates_fast ADD PRIMARY KEY (id); -- For the hot rank resorting CREATE INDEX idx_post_aggregates_fast_hot_rank_published ON post_aggregates_fast (hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_fast_hot_rank_active_published ON post_aggregates_fast (hot_rank_active DESC, published DESC); CREATE VIEW post_fast_view AS SELECT pav.*, us.id AS user_id, us.user_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_read::bool AS read, us.is_saved::bool AS saved FROM post_aggregates_fast pav CROSS JOIN LATERAL ( SELECT u.id, coalesce(cf.community_id, 0) AS is_subbed, coalesce(pr.post_id, 0) AS is_read, coalesce(ps.post_id, 0) AS is_saved, coalesce(pl.score, 0) AS user_vote FROM user_ u LEFT JOIN community_user_ban cb ON u.id = cb.user_id AND cb.community_id = pav.community_id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = pav.community_id LEFT JOIN post_read pr ON u.id = pr.user_id AND pr.post_id = pav.id LEFT JOIN post_saved ps ON u.id = ps.user_id AND ps.post_id = pav.id LEFT JOIN post_like pl ON u.id = pl.user_id AND pav.id = pl.post_id) AS us UNION ALL SELECT pav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS read, NULL AS saved FROM post_aggregates_fast pav; -- Community DROP VIEW community_moderator_view; DROP VIEW community_follower_view; DROP VIEW community_user_ban_view; DROP VIEW community_view; DROP VIEW community_aggregates_view; DROP VIEW community_fast_view; DROP TABLE community_aggregates_fast; CREATE VIEW community_aggregates_view AS SELECT c.id, c.name, c.title, c.icon, c.banner, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.preferred_username AS creator_preferred_username, u.avatar AS creator_avatar, cat.name AS category_name, coalesce(cf.subs, 0) AS number_of_subscribers, coalesce(cd.posts, 0) AS number_of_posts, coalesce(cd.comments, 0) AS number_of_comments, hot_rank (cf.subs, c.published) AS hot_rank FROM community c LEFT JOIN user_ u ON c.creator_id = u.id LEFT JOIN category cat ON c.category_id = cat.id LEFT JOIN ( SELECT p.community_id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM post p JOIN comment ct ON p.id = ct.post_id GROUP BY p.community_id) cd ON cd.community_id = c.id LEFT JOIN ( SELECT community_id, count(*) AS subs FROM community_follower GROUP BY community_id) cf ON cf.community_id = c.id; CREATE VIEW community_view AS SELECT cv.*, us.user AS user_id, us.is_subbed::bool AS subscribed FROM community_aggregates_view cv CROSS JOIN LATERAL ( SELECT u.id AS user, coalesce(cf.community_id, 0) AS is_subbed FROM user_ u LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = cv.id) AS us UNION ALL SELECT cv.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_view cv; CREATE VIEW community_moderator_view AS SELECT cm.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.preferred_username AS user_preferred_username, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name, c.icon AS community_icon FROM community_moderator cm LEFT JOIN user_ u ON cm.user_id = u.id LEFT JOIN community c ON cm.community_id = c.id; CREATE VIEW community_follower_view AS SELECT cf.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.preferred_username AS user_preferred_username, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name, c.icon AS community_icon FROM community_follower cf LEFT JOIN user_ u ON cf.user_id = u.id LEFT JOIN community c ON cf.community_id = c.id; CREATE VIEW community_user_ban_view AS SELECT cb.*, u.actor_id AS user_actor_id, u.local AS user_local, u.name AS user_name, u.preferred_username AS user_preferred_username, u.avatar AS avatar, c.actor_id AS community_actor_id, c.local AS community_local, c.name AS community_name, c.icon AS community_icon FROM community_user_ban cb LEFT JOIN user_ u ON cb.user_id = u.id LEFT JOIN community c ON cb.community_id = c.id; -- The community fast table CREATE TABLE community_aggregates_fast AS SELECT * FROM community_aggregates_view; ALTER TABLE community_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW community_fast_view AS SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN ( SELECT ca.* FROM community_aggregates_fast ca) ac UNION ALL SELECT caf.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_fast caf; -- Comments, mentions, replies DROP VIEW user_mention_view; DROP VIEW reply_fast_view; DROP VIEW comment_fast_view; DROP VIEW comment_view; DROP VIEW user_mention_fast_view; DROP TABLE comment_aggregates_fast; DROP VIEW comment_aggregates_view; CREATE VIEW comment_aggregates_view AS SELECT ct.*, -- post details p."name" AS post_name, p.community_id, -- community details c.actor_id AS community_actor_id, c."local" AS community_local, c."name" AS community_name, c.icon AS community_icon, -- creator details u.banned AS banned, coalesce(cb.id, 0)::bool AS banned_from_community, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.preferred_username AS creator_preferred_username, u.published AS creator_published, u.avatar AS creator_avatar, -- score details coalesce(cl.total, 0) AS score, coalesce(cl.up, 0) AS upvotes, coalesce(cl.down, 0) AS downvotes, hot_rank (coalesce(cl.total, 1), p.published) AS hot_rank, hot_rank (coalesce(cl.total, 1), ct.published) AS hot_rank_active FROM comment ct LEFT JOIN post p ON ct.post_id = p.id LEFT JOIN community c ON p.community_id = c.id LEFT JOIN user_ u ON ct.creator_id = u.id LEFT JOIN community_user_ban cb ON ct.creator_id = cb.user_id AND p.id = ct.post_id AND p.community_id = cb.community_id LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL END) AS up, count( CASE WHEN l.score = -1 THEN 1 ELSE NULL END) AS down FROM comment_like l GROUP BY comment_id) AS cl ON cl.id = ct.id; CREATE OR REPLACE VIEW comment_view AS ( SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_view cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_view cav); CREATE TABLE comment_aggregates_fast AS SELECT * FROM comment_aggregates_view; ALTER TABLE comment_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW comment_fast_view AS SELECT cav.*, us.user_id AS user_id, us.my_vote AS my_vote, us.is_subbed::bool AS subscribed, us.is_saved::bool AS saved FROM comment_aggregates_fast cav CROSS JOIN LATERAL ( SELECT u.id AS user_id, coalesce(cl.score, 0) AS my_vote, coalesce(cf.id, 0) AS is_subbed, coalesce(cs.id, 0) AS is_saved FROM user_ u LEFT JOIN comment_like cl ON u.id = cl.user_id AND cav.id = cl.comment_id LEFT JOIN comment_saved cs ON u.id = cs.user_id AND cs.comment_id = cav.id LEFT JOIN community_follower cf ON u.id = cf.user_id AND cav.community_id = cf.community_id) AS us UNION ALL SELECT cav.*, NULL AS user_id, NULL AS my_vote, NULL AS subscribed, NULL AS saved FROM comment_aggregates_fast cav; CREATE VIEW user_mention_view AS SELECT c.id, um.id AS user_mention_id, c.creator_id, c.creator_actor_id, c.creator_local, c.post_id, c.post_name, c.parent_id, c.content, c.removed, um.read, c.published, c.updated, c.deleted, c.community_id, c.community_actor_id, c.community_local, c.community_name, c.community_icon, c.banned, c.banned_from_community, c.creator_name, c.creator_preferred_username, c.creator_avatar, c.score, c.upvotes, c.downvotes, c.hot_rank, c.hot_rank_active, c.user_id, c.my_vote, c.saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_mention um, comment_view c WHERE um.comment_id = c.id; CREATE VIEW user_mention_fast_view AS SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.post_name, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.community_icon, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_preferred_username, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, ac.hot_rank_active, u.id AS user_id, coalesce(cl.score, 0) AS my_vote, ( SELECT cs.id::bool FROM comment_saved cs WHERE u.id = cs.user_id AND cs.comment_id = ac.id) AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM user_ u CROSS JOIN ( SELECT ca.* FROM comment_aggregates_fast ca) ac LEFT JOIN comment_like cl ON u.id = cl.user_id AND ac.id = cl.comment_id LEFT JOIN user_mention um ON um.comment_id = ac.id UNION ALL SELECT ac.id, um.id AS user_mention_id, ac.creator_id, ac.creator_actor_id, ac.creator_local, ac.post_id, ac.post_name, ac.parent_id, ac.content, ac.removed, um.read, ac.published, ac.updated, ac.deleted, ac.community_id, ac.community_actor_id, ac.community_local, ac.community_name, ac.community_icon, ac.banned, ac.banned_from_community, ac.creator_name, ac.creator_preferred_username, ac.creator_avatar, ac.score, ac.upvotes, ac.downvotes, ac.hot_rank, ac.hot_rank_active, NULL AS user_id, NULL AS my_vote, NULL AS saved, um.recipient_id, ( SELECT actor_id FROM user_ u WHERE u.id = um.recipient_id) AS recipient_actor_id, ( SELECT local FROM user_ u WHERE u.id = um.recipient_id) AS recipient_local FROM comment_aggregates_fast ac LEFT JOIN user_mention um ON um.comment_id = ac.id; -- Do the reply_view referencing the comment_fast_view CREATE VIEW reply_fast_view AS with closereply AS ( SELECT c2.id, c2.creator_id AS sender_id, c.creator_id AS recipient_id FROM comment c INNER JOIN comment c2 ON c.id = c2.parent_id WHERE c2.creator_id != c.creator_id -- Do union where post is null UNION SELECT c.id, c.creator_id AS sender_id, p.creator_id AS recipient_id FROM comment c, post p WHERE c.post_id = p.id AND c.parent_id IS NULL AND c.creator_id != p.creator_id ) SELECT cv.*, closereply.recipient_id FROM comment_fast_view cv, closereply WHERE closereply.id = cv.id; -- Adding hot rank active to the triggers CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank, hot_rank_active = pav.hot_rank_active FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id; -- Update the comment hot_ranks as of last week UPDATE comment_aggregates_fast AS caf SET hot_rank = cav.hot_rank, hot_rank_active = cav.hot_rank_active FROM comment_aggregates_view AS cav WHERE caf.id = cav.id AND (cav.published > ('now'::timestamp - '1 week'::interval)); -- Update the post ranks UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank, hot_rank_active = pav.hot_rank_active FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); -- Force the hot rank active as zero on 2 day-older posts (necro-bump) UPDATE post_aggregates_fast AS paf SET hot_rank_active = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '2 days'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-08-06-205355_update_community_post_count/down.sql ================================================ -- Drop first DROP VIEW community_view; DROP VIEW community_aggregates_view; DROP VIEW community_fast_view; DROP TABLE community_aggregates_fast; CREATE VIEW community_aggregates_view AS SELECT c.id, c.name, c.title, c.icon, c.banner, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.preferred_username AS creator_preferred_username, u.avatar AS creator_avatar, cat.name AS category_name, coalesce(cf.subs, 0) AS number_of_subscribers, coalesce(cd.posts, 0) AS number_of_posts, coalesce(cd.comments, 0) AS number_of_comments, hot_rank (cf.subs, c.published) AS hot_rank FROM community c LEFT JOIN user_ u ON c.creator_id = u.id LEFT JOIN category cat ON c.category_id = cat.id LEFT JOIN ( SELECT p.community_id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM post p JOIN comment ct ON p.id = ct.post_id GROUP BY p.community_id) cd ON cd.community_id = c.id LEFT JOIN ( SELECT community_id, count(*) AS subs FROM community_follower GROUP BY community_id) cf ON cf.community_id = c.id; CREATE VIEW community_view AS SELECT cv.*, us.user AS user_id, us.is_subbed::bool AS subscribed FROM community_aggregates_view cv CROSS JOIN LATERAL ( SELECT u.id AS user, coalesce(cf.community_id, 0) AS is_subbed FROM user_ u LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = cv.id) AS us UNION ALL SELECT cv.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_view cv; -- The community fast table CREATE TABLE community_aggregates_fast AS SELECT * FROM community_aggregates_view; ALTER TABLE community_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW community_fast_view AS SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN ( SELECT ca.* FROM community_aggregates_fast ca) ac UNION ALL SELECT caf.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_fast caf; ================================================ FILE: migrations/2020-08-06-205355_update_community_post_count/up.sql ================================================ -- Drop first DROP VIEW community_view; DROP VIEW community_aggregates_view; DROP VIEW community_fast_view; DROP TABLE community_aggregates_fast; CREATE VIEW community_aggregates_view AS SELECT c.id, c.name, c.title, c.icon, c.banner, c.description, c.category_id, c.creator_id, c.removed, c.published, c.updated, c.deleted, c.nsfw, c.actor_id, c.local, c.last_refreshed_at, u.actor_id AS creator_actor_id, u.local AS creator_local, u.name AS creator_name, u.preferred_username AS creator_preferred_username, u.avatar AS creator_avatar, cat.name AS category_name, coalesce(cf.subs, 0) AS number_of_subscribers, coalesce(cd.posts, 0) AS number_of_posts, coalesce(cd.comments, 0) AS number_of_comments, hot_rank (cf.subs, c.published) AS hot_rank FROM community c LEFT JOIN user_ u ON c.creator_id = u.id LEFT JOIN category cat ON c.category_id = cat.id LEFT JOIN ( SELECT p.community_id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM post p LEFT JOIN comment ct ON p.id = ct.post_id GROUP BY p.community_id) cd ON cd.community_id = c.id LEFT JOIN ( SELECT community_id, count(*) AS subs FROM community_follower GROUP BY community_id) cf ON cf.community_id = c.id; CREATE VIEW community_view AS SELECT cv.*, us.user AS user_id, us.is_subbed::bool AS subscribed FROM community_aggregates_view cv CROSS JOIN LATERAL ( SELECT u.id AS user, coalesce(cf.community_id, 0) AS is_subbed FROM user_ u LEFT JOIN community_follower cf ON u.id = cf.user_id AND cf.community_id = cv.id) AS us UNION ALL SELECT cv.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_view cv; -- The community fast table CREATE TABLE community_aggregates_fast AS SELECT * FROM community_aggregates_view; ALTER TABLE community_aggregates_fast ADD PRIMARY KEY (id); CREATE VIEW community_fast_view AS SELECT ac.*, u.id AS user_id, ( SELECT cf.id::boolean FROM community_follower cf WHERE u.id = cf.user_id AND ac.id = cf.community_id) AS subscribed FROM user_ u CROSS JOIN ( SELECT ca.* FROM community_aggregates_fast ca) ac UNION ALL SELECT caf.*, NULL AS user_id, NULL AS subscribed FROM community_aggregates_fast caf; ================================================ FILE: migrations/2020-08-25-132005_add_unique_ap_ids/down.sql ================================================ -- Drop the uniques ALTER TABLE private_message DROP CONSTRAINT idx_private_message_ap_id; ALTER TABLE post DROP CONSTRAINT idx_post_ap_id; ALTER TABLE comment DROP CONSTRAINT idx_comment_ap_id; ALTER TABLE user_ DROP CONSTRAINT idx_user_actor_id; ALTER TABLE community DROP CONSTRAINT idx_community_actor_id; ALTER TABLE private_message ALTER COLUMN ap_id SET NOT NULL; ALTER TABLE private_message ALTER COLUMN ap_id SET DEFAULT 'http://fake.com'; ALTER TABLE post ALTER COLUMN ap_id SET NOT NULL; ALTER TABLE post ALTER COLUMN ap_id SET DEFAULT 'http://fake.com'; ALTER TABLE comment ALTER COLUMN ap_id SET NOT NULL; ALTER TABLE comment ALTER COLUMN ap_id SET DEFAULT 'http://fake.com'; UPDATE private_message SET ap_id = 'http://fake.com' WHERE ap_id LIKE 'changeme_%'; UPDATE post SET ap_id = 'http://fake.com' WHERE ap_id LIKE 'changeme_%'; UPDATE comment SET ap_id = 'http://fake.com' WHERE ap_id LIKE 'changeme_%'; ================================================ FILE: migrations/2020-08-25-132005_add_unique_ap_ids/up.sql ================================================ -- Add unique ap_id for private_message, comment, and post -- Need to delete the possible dupes for ones that don't start with the fake one DELETE FROM private_message a USING ( SELECT min(id) AS id, ap_id FROM private_message GROUP BY ap_id HAVING count(*) > 1) b WHERE a.ap_id = b.ap_id AND a.id <> b.id; DELETE FROM post a USING ( SELECT min(id) AS id, ap_id FROM post GROUP BY ap_id HAVING count(*) > 1) b WHERE a.ap_id = b.ap_id AND a.id <> b.id; DELETE FROM comment a USING ( SELECT min(id) AS id, ap_id FROM comment GROUP BY ap_id HAVING count(*) > 1) b WHERE a.ap_id = b.ap_id AND a.id <> b.id; -- Replacing the current default on the columns, to the unique one UPDATE private_message SET ap_id = generate_unique_changeme () WHERE ap_id = 'http://fake.com'; UPDATE post SET ap_id = generate_unique_changeme () WHERE ap_id = 'http://fake.com'; UPDATE comment SET ap_id = generate_unique_changeme () WHERE ap_id = 'http://fake.com'; -- Add the unique indexes ALTER TABLE private_message ALTER COLUMN ap_id SET NOT NULL; ALTER TABLE private_message ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme (); ALTER TABLE post ALTER COLUMN ap_id SET NOT NULL; ALTER TABLE post ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme (); ALTER TABLE comment ALTER COLUMN ap_id SET NOT NULL; ALTER TABLE comment ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme (); -- Add the uniques, for user_ and community too ALTER TABLE private_message ADD CONSTRAINT idx_private_message_ap_id UNIQUE (ap_id); ALTER TABLE post ADD CONSTRAINT idx_post_ap_id UNIQUE (ap_id); ALTER TABLE comment ADD CONSTRAINT idx_comment_ap_id UNIQUE (ap_id); ALTER TABLE user_ ADD CONSTRAINT idx_user_actor_id UNIQUE (actor_id); ALTER TABLE community ADD CONSTRAINT idx_community_actor_id UNIQUE (actor_id); ================================================ FILE: migrations/2020-09-07-231141_add_migration_utils/down.sql ================================================ DROP SCHEMA utils CASCADE; ================================================ FILE: migrations/2020-09-07-231141_add_migration_utils/up.sql ================================================ CREATE SCHEMA utils; CREATE TABLE utils.deps_saved_ddl ( id serial NOT NULL, view_schema character varying(255), view_name character varying(255), ddl_to_run text, CONSTRAINT deps_saved_ddl_pkey PRIMARY KEY (id) ); CREATE OR REPLACE FUNCTION utils.save_and_drop_views (p_view_schema name, p_view_name name) RETURNS void LANGUAGE plpgsql COST 100 AS $BODY$ DECLARE v_curr record; BEGIN FOR v_curr IN ( SELECT obj_schema, obj_name, obj_type FROM ( WITH RECURSIVE recursive_deps ( obj_schema, obj_name, obj_type, depth ) AS ( SELECT p_view_schema::name, p_view_name, NULL::varchar, 0 UNION SELECT dep_schema::varchar, dep_name::varchar, dep_type::varchar, recursive_deps.depth + 1 FROM ( SELECT ref_nsp.nspname ref_schema, ref_cl.relname ref_name, rwr_cl.relkind dep_type, rwr_nsp.nspname dep_schema, rwr_cl.relname dep_name FROM pg_depend dep JOIN pg_class ref_cl ON dep.refobjid = ref_cl.oid JOIN pg_namespace ref_nsp ON ref_cl.relnamespace = ref_nsp.oid JOIN pg_rewrite rwr ON dep.objid = rwr.oid JOIN pg_class rwr_cl ON rwr.ev_class = rwr_cl.oid JOIN pg_namespace rwr_nsp ON rwr_cl.relnamespace = rwr_nsp.oid WHERE dep.deptype = 'n' AND dep.classid = 'pg_rewrite'::regclass) deps JOIN recursive_deps ON deps.ref_schema = recursive_deps.obj_schema AND deps.ref_name = recursive_deps.obj_name WHERE (deps.ref_schema != deps.dep_schema OR deps.ref_name != deps.dep_name)) SELECT obj_schema, obj_name, obj_type, depth FROM recursive_deps WHERE depth > 0) t GROUP BY obj_schema, obj_name, obj_type ORDER BY max(depth) DESC) LOOP IF v_curr.obj_type = 'v' THEN INSERT INTO utils.deps_saved_ddl (view_schema, view_name, ddl_to_run) SELECT p_view_schema, p_view_name, 'CREATE VIEW ' || v_curr.obj_schema || '.' || v_curr.obj_name || ' AS ' || view_definition FROM information_schema.views WHERE table_schema = v_curr.obj_schema AND table_name = v_curr.obj_name; EXECUTE 'DROP VIEW' || ' ' || v_curr.obj_schema || '.' || v_curr.obj_name; END IF; END LOOP; END; $BODY$; CREATE OR REPLACE FUNCTION utils.restore_views (p_view_schema character varying, p_view_name character varying) RETURNS void LANGUAGE plpgsql COST 100 AS $BODY$ DECLARE v_curr record; BEGIN FOR v_curr IN ( SELECT ddl_to_run, id FROM utils.deps_saved_ddl WHERE view_schema = p_view_schema AND view_name = p_view_name ORDER BY id DESC) LOOP BEGIN EXECUTE v_curr.ddl_to_run; DELETE FROM utils.deps_saved_ddl WHERE id = v_curr.id; EXCEPTION WHEN OTHERS THEN -- keep looping, but please check for errors or remove left overs to handle manually END; END LOOP; END; $BODY$; ================================================ FILE: migrations/2020-10-07-234221_fix_fast_triggers/down.sql ================================================ CREATE OR REPLACE FUNCTION refresh_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates_fast WHERE id = OLD.id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM community_aggregates_fast WHERE id = OLD.id; INSERT INTO community_aggregates_fast SELECT * FROM community_aggregates_view WHERE id = NEW.id; -- Update user view due to owner changes DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id; -- Update post view due to community changes DELETE FROM post_aggregates_fast WHERE community_id = NEW.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE community_id = NEW.id; -- TODO make sure this shows up in the users page ? ELSIF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates_fast SELECT * FROM community_aggregates_view WHERE id = NEW.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM user_fast WHERE id = OLD.id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM user_fast WHERE id = OLD.id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.id; -- Refresh post_fast, cause of user info changes DELETE FROM post_aggregates_fast WHERE creator_id = NEW.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE creator_id = NEW.id; DELETE FROM comment_aggregates_fast WHERE creator_id = NEW.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE creator_id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id; -- Force the hot rank as zero on week-older posts UPDATE post_aggregates_fast AS paf SET hot_rank = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '1 week'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-10-07-234221_fix_fast_triggers/up.sql ================================================ -- This adds on conflict do nothing triggers to all the insert_intos -- Github issue: https://github.com/LemmyNet/lemmy/issues/1179 CREATE OR REPLACE FUNCTION refresh_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates_fast WHERE id = OLD.id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM community_aggregates_fast WHERE id = OLD.id; INSERT INTO community_aggregates_fast SELECT * FROM community_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; -- Update user view due to owner changes DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id ON CONFLICT (id) DO NOTHING; -- Update post view due to community changes DELETE FROM post_aggregates_fast WHERE community_id = NEW.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE community_id = NEW.id ON CONFLICT (id) DO NOTHING; -- TODO make sure this shows up in the users page ? ELSIF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates_fast SELECT * FROM community_aggregates_view WHERE id = NEW.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM user_fast WHERE id = OLD.id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM user_fast WHERE id = OLD.id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; -- Refresh post_fast, cause of user info changes DELETE FROM post_aggregates_fast WHERE creator_id = NEW.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE creator_id = NEW.id ON CONFLICT (id) DO NOTHING; DELETE FROM comment_aggregates_fast WHERE creator_id = NEW.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE creator_id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id ON CONFLICT (id) DO NOTHING; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id ON CONFLICT (id) DO NOTHING; -- Force the hot rank as zero on week-older posts UPDATE post_aggregates_fast AS paf SET hot_rank = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '1 week'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-10-10-035723_fix_fast_triggers_2/down.sql ================================================ CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id ON CONFLICT (id) DO NOTHING; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id ON CONFLICT (id) DO NOTHING; -- Force the hot rank as zero on week-older posts UPDATE post_aggregates_fast AS paf SET hot_rank = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '1 week'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-10-10-035723_fix_fast_triggers_2/up.sql ================================================ -- Forgot to add hot rank active to these two triggers CREATE OR REPLACE FUNCTION refresh_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts - 1 WHERE id = OLD.community_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM post_aggregates_fast WHERE id = OLD.id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.id; -- Update that users number of posts, post score DELETE FROM user_fast WHERE id = NEW.creator_id; INSERT INTO user_fast SELECT * FROM user_view WHERE id = NEW.creator_id ON CONFLICT (id) DO NOTHING; -- Update community number of posts UPDATE community_aggregates_fast SET number_of_posts = number_of_posts + 1 WHERE id = NEW.community_id; -- Update the hot rank on the post table -- TODO this might not correctly update it, using a 1 week interval UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank, hot_rank_active = pav.hot_rank_active FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION refresh_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments - 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN DELETE FROM comment_aggregates_fast WHERE id = OLD.id; INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id ON CONFLICT (id) DO NOTHING; ELSIF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates_fast SELECT * FROM comment_aggregates_view WHERE id = NEW.id; -- Update user view due to comment count UPDATE user_fast SET number_of_comments = number_of_comments + 1 WHERE id = NEW.creator_id; -- Update post view due to comment count, new comment activity time, but only on new posts -- TODO this could be done more efficiently DELETE FROM post_aggregates_fast WHERE id = NEW.post_id; INSERT INTO post_aggregates_fast SELECT * FROM post_aggregates_view WHERE id = NEW.post_id ON CONFLICT (id) DO NOTHING; -- Update the comment hot_ranks as of last week UPDATE comment_aggregates_fast AS caf SET hot_rank = cav.hot_rank, hot_rank_active = cav.hot_rank_active FROM comment_aggregates_view AS cav WHERE caf.id = cav.id AND (cav.published > ('now'::timestamp - '1 week'::interval)); -- Update the post ranks UPDATE post_aggregates_fast AS paf SET hot_rank = pav.hot_rank, hot_rank_active = pav.hot_rank_active FROM post_aggregates_view AS pav WHERE paf.id = pav.id AND (pav.published > ('now'::timestamp - '1 week'::interval)); -- Force the hot rank active as zero on 2 day-older posts (necro-bump) UPDATE post_aggregates_fast AS paf SET hot_rank_active = 0 WHERE paf.id = NEW.post_id AND (paf.published < ('now'::timestamp - '2 days'::interval)); -- Update community number of comments UPDATE community_aggregates_fast AS caf SET number_of_comments = number_of_comments + 1 FROM post AS p WHERE caf.id = p.community_id AND p.id = NEW.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2020-10-13-212240_create_report_tables/down.sql ================================================ DROP VIEW comment_report_view; DROP VIEW post_report_view; DROP TABLE comment_report; DROP TABLE post_report; ================================================ FILE: migrations/2020-10-13-212240_create_report_tables/up.sql ================================================ CREATE TABLE comment_report ( id serial PRIMARY KEY, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- user reporting comment comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- comment being reported original_comment_text text NOT NULL, reason text NOT NULL, resolved bool NOT NULL DEFAULT FALSE, resolver_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE, -- user resolving report published timestamp NOT NULL DEFAULT now(), updated timestamp NULL, UNIQUE (comment_id, creator_id) -- users should only be able to report a comment once ); CREATE TABLE post_report ( id serial PRIMARY KEY, creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- user reporting post post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- post being reported original_post_name varchar(100) NOT NULL, original_post_url text, original_post_body text, reason text NOT NULL, resolved bool NOT NULL DEFAULT FALSE, resolver_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE, -- user resolving report published timestamp NOT NULL DEFAULT now(), updated timestamp NULL, UNIQUE (post_id, creator_id) -- users should only be able to report a post once ); CREATE OR REPLACE VIEW comment_report_view AS SELECT cr.*, c.post_id, c.content AS current_comment_text, p.community_id, -- report creator details f.actor_id AS creator_actor_id, f.name AS creator_name, f.preferred_username AS creator_preferred_username, f.avatar AS creator_avatar, f.local AS creator_local, -- comment creator details u.id AS comment_creator_id, u.actor_id AS comment_creator_actor_id, u.name AS comment_creator_name, u.preferred_username AS comment_creator_preferred_username, u.avatar AS comment_creator_avatar, u.local AS comment_creator_local, -- resolver details r.actor_id AS resolver_actor_id, r.name AS resolver_name, r.preferred_username AS resolver_preferred_username, r.avatar AS resolver_avatar, r.local AS resolver_local FROM comment_report cr LEFT JOIN comment c ON c.id = cr.comment_id LEFT JOIN post p ON p.id = c.post_id LEFT JOIN user_ u ON u.id = c.creator_id LEFT JOIN user_ f ON f.id = cr.creator_id LEFT JOIN user_ r ON r.id = cr.resolver_id; CREATE OR REPLACE VIEW post_report_view AS SELECT pr.*, p.name AS current_post_name, p.url AS current_post_url, p.body AS current_post_body, p.community_id, -- report creator details f.actor_id AS creator_actor_id, f.name AS creator_name, f.preferred_username AS creator_preferred_username, f.avatar AS creator_avatar, f.local AS creator_local, -- post creator details u.id AS post_creator_id, u.actor_id AS post_creator_actor_id, u.name AS post_creator_name, u.preferred_username AS post_creator_preferred_username, u.avatar AS post_creator_avatar, u.local AS post_creator_local, -- resolver details r.actor_id AS resolver_actor_id, r.name AS resolver_name, r.preferred_username AS resolver_preferred_username, r.avatar AS resolver_avatar, r.local AS resolver_local FROM post_report pr LEFT JOIN post p ON p.id = pr.post_id LEFT JOIN user_ u ON u.id = p.creator_id LEFT JOIN user_ f ON f.id = pr.creator_id LEFT JOIN user_ r ON r.id = pr.resolver_id; ================================================ FILE: migrations/2020-10-23-115011_activity_ap_id_column/down.sql ================================================ ALTER TABLE activity DROP COLUMN ap_id; ================================================ FILE: migrations/2020-10-23-115011_activity_ap_id_column/up.sql ================================================ ALTER TABLE activity ADD COLUMN ap_id text; ================================================ FILE: migrations/2020-11-05-152724_activity_remove_user_id/down.sql ================================================ ALTER TABLE activity ADD COLUMN user_id integer REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL; ALTER TABLE activity DROP COLUMN sensitive; ================================================ FILE: migrations/2020-11-05-152724_activity_remove_user_id/up.sql ================================================ ALTER TABLE activity DROP COLUMN user_id; ALTER TABLE activity ADD COLUMN sensitive boolean DEFAULT TRUE; ================================================ FILE: migrations/2020-11-10-150835_community_follower_pending/down.sql ================================================ ALTER TABLE community_follower DROP COLUMN pending; ================================================ FILE: migrations/2020-11-10-150835_community_follower_pending/up.sql ================================================ ALTER TABLE community_follower ADD COLUMN pending boolean DEFAULT FALSE; ================================================ FILE: migrations/2020-11-26-134531_delete_user/down.sql ================================================ ALTER TABLE user_ DROP COLUMN deleted; ================================================ FILE: migrations/2020-11-26-134531_delete_user/up.sql ================================================ ALTER TABLE user_ ADD COLUMN deleted boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2020-12-02-152437_create_site_aggregates/down.sql ================================================ -- Site aggregates DROP TABLE site_aggregates; DROP TRIGGER site_aggregates_site ON site; DROP TRIGGER site_aggregates_user_insert ON user_; DROP TRIGGER site_aggregates_user_delete ON user_; DROP TRIGGER site_aggregates_post_insert ON post; DROP TRIGGER site_aggregates_post_delete ON post; DROP TRIGGER site_aggregates_comment_insert ON comment; DROP TRIGGER site_aggregates_comment_delete ON comment; DROP TRIGGER site_aggregates_community_insert ON community; DROP TRIGGER site_aggregates_community_delete ON community; DROP FUNCTION site_aggregates_site, site_aggregates_user_insert, site_aggregates_user_delete, site_aggregates_post_insert, site_aggregates_post_delete, site_aggregates_comment_insert, site_aggregates_comment_delete, site_aggregates_community_insert, site_aggregates_community_delete; ================================================ FILE: migrations/2020-12-02-152437_create_site_aggregates/up.sql ================================================ -- Add site aggregates CREATE TABLE site_aggregates ( id serial PRIMARY KEY, site_id int REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, users bigint NOT NULL DEFAULT 1, posts bigint NOT NULL DEFAULT 0, comments bigint NOT NULL DEFAULT 0, communities bigint NOT NULL DEFAULT 0 ); INSERT INTO site_aggregates (site_id, users, posts, comments, communities) SELECT id AS site_id, ( SELECT coalesce(count(*), 0) FROM user_ WHERE local = TRUE) AS users, ( SELECT coalesce(count(*), 0) FROM post WHERE local = TRUE) AS posts, ( SELECT coalesce(count(*), 0) FROM comment WHERE local = TRUE) AS comments, ( SELECT coalesce(count(*), 0) FROM community WHERE local = TRUE) AS communities FROM site; -- initial site add CREATE FUNCTION site_aggregates_site () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO site_aggregates (site_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM site_aggregates WHERE site_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER site_aggregates_site AFTER INSERT OR DELETE ON site FOR EACH ROW EXECUTE PROCEDURE site_aggregates_site (); -- Add site aggregate triggers -- user CREATE FUNCTION site_aggregates_user_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET users = users + 1; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_user_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Join to site since the creator might not be there anymore UPDATE site_aggregates sa SET users = users - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE TRIGGER site_aggregates_user_insert AFTER INSERT ON user_ FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_user_insert (); CREATE TRIGGER site_aggregates_user_delete AFTER DELETE ON user_ FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_user_delete (); -- post CREATE FUNCTION site_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET posts = posts + 1; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_post_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET posts = posts - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE TRIGGER site_aggregates_post_insert AFTER INSERT ON post FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_insert (); CREATE TRIGGER site_aggregates_post_delete AFTER DELETE ON post FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_delete (); -- comment CREATE FUNCTION site_aggregates_comment_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET comments = comments + 1; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_comment_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET comments = comments - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE TRIGGER site_aggregates_comment_insert AFTER INSERT ON comment FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_comment_insert (); CREATE TRIGGER site_aggregates_comment_delete AFTER DELETE ON comment FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_comment_delete (); -- community CREATE FUNCTION site_aggregates_community_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET communities = communities + 1; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_community_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET communities = communities - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE TRIGGER site_aggregates_community_insert AFTER INSERT ON community FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_insert (); CREATE TRIGGER site_aggregates_community_delete AFTER DELETE ON community FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_delete (); ================================================ FILE: migrations/2020-12-03-035643_create_user_aggregates/down.sql ================================================ -- User aggregates DROP TABLE user_aggregates; DROP TRIGGER user_aggregates_user ON user_; DROP TRIGGER user_aggregates_post_count ON post; DROP TRIGGER user_aggregates_post_score ON post_like; DROP TRIGGER user_aggregates_comment_count ON comment; DROP TRIGGER user_aggregates_comment_score ON comment_like; DROP FUNCTION user_aggregates_user, user_aggregates_post_count, user_aggregates_post_score, user_aggregates_comment_count, user_aggregates_comment_score; ================================================ FILE: migrations/2020-12-03-035643_create_user_aggregates/up.sql ================================================ -- Add user aggregates CREATE TABLE user_aggregates ( id serial PRIMARY KEY, user_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_count bigint NOT NULL DEFAULT 0, post_score bigint NOT NULL DEFAULT 0, comment_count bigint NOT NULL DEFAULT 0, comment_score bigint NOT NULL DEFAULT 0, UNIQUE (user_id) ); INSERT INTO user_aggregates (user_id, post_count, post_score, comment_count, comment_score) SELECT u.id, coalesce(pd.posts, 0), coalesce(pd.score, 0), coalesce(cd.comments, 0), coalesce(cd.score, 0) FROM user_ u LEFT JOIN ( SELECT p.creator_id, count(DISTINCT p.id) AS posts, sum(pl.score) AS score FROM post p LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY p.creator_id) pd ON u.id = pd.creator_id LEFT JOIN ( SELECT c.creator_id, count(DISTINCT c.id) AS comments, sum(cl.score) AS score FROM comment c LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY c.creator_id) cd ON u.id = cd.creator_id; -- Add user aggregate triggers -- initial user add CREATE FUNCTION user_aggregates_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO user_aggregates (user_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM user_aggregates WHERE user_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_user AFTER INSERT OR DELETE ON user_ FOR EACH ROW EXECUTE PROCEDURE user_aggregates_user (); -- post count CREATE FUNCTION user_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE user_aggregates SET post_count = post_count + 1 WHERE user_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates SET post_count = post_count - 1 WHERE user_id = OLD.creator_id; -- If the post gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE user_aggregates ua SET post_score = pd.score FROM ( SELECT u.id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM user_ u LEFT JOIN post p ON u.id = p.creator_id LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.user_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_post_count AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE user_aggregates_post_count (); -- post score CREATE FUNCTION user_aggregates_post_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE user_aggregates ua SET post_score = post_score + NEW.score FROM post p WHERE ua.user_id = p.creator_id AND p.id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates ua SET post_score = post_score - OLD.score FROM post p WHERE ua.user_id = p.creator_id AND p.id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_post_score AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE PROCEDURE user_aggregates_post_score (); -- comment count CREATE FUNCTION user_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE user_aggregates SET comment_count = comment_count + 1 WHERE user_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates SET comment_count = comment_count - 1 WHERE user_id = OLD.creator_id; -- If the comment gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE user_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM user_ u LEFT JOIN comment c ON u.id = c.creator_id LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.user_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE user_aggregates_comment_count (); -- comment score CREATE FUNCTION user_aggregates_comment_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE user_aggregates ua SET comment_score = comment_score + NEW.score FROM comment c WHERE ua.user_id = c.creator_id AND c.id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates ua SET comment_score = comment_score - OLD.score FROM comment c WHERE ua.user_id = c.creator_id AND c.id = OLD.comment_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_comment_score AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE PROCEDURE user_aggregates_comment_score (); ================================================ FILE: migrations/2020-12-04-183345_create_community_aggregates/down.sql ================================================ -- community aggregates DROP TABLE community_aggregates; DROP TRIGGER community_aggregates_community ON community; DROP TRIGGER community_aggregates_post_count ON post; DROP TRIGGER community_aggregates_comment_count ON comment; DROP TRIGGER community_aggregates_subscriber_count ON community_follower; DROP FUNCTION community_aggregates_community, community_aggregates_post_count, community_aggregates_comment_count, community_aggregates_subscriber_count; ================================================ FILE: migrations/2020-12-04-183345_create_community_aggregates/up.sql ================================================ -- Add community aggregates CREATE TABLE community_aggregates ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, subscribers bigint NOT NULL DEFAULT 0, posts bigint NOT NULL DEFAULT 0, comments bigint NOT NULL DEFAULT 0, published timestamp NOT NULL DEFAULT now(), UNIQUE (community_id) ); INSERT INTO community_aggregates (community_id, subscribers, posts, comments, published) SELECT c.id, coalesce(cf.subs, 0) AS subscribers, coalesce(cd.posts, 0) AS posts, coalesce(cd.comments, 0) AS comments, c.published FROM community c LEFT JOIN ( SELECT p.community_id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM post p LEFT JOIN comment ct ON p.id = ct.post_id GROUP BY p.community_id) cd ON cd.community_id = c.id LEFT JOIN ( SELECT community_follower.community_id, count(*) AS subs FROM community_follower GROUP BY community_follower.community_id) cf ON cf.community_id = c.id; -- Add community aggregate triggers -- initial community add CREATE FUNCTION community_aggregates_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates (community_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates WHERE community_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER community_aggregates_community AFTER INSERT OR DELETE ON community FOR EACH ROW EXECUTE PROCEDURE community_aggregates_community (); -- post count CREATE FUNCTION community_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates SET posts = posts + 1 WHERE community_id = NEW.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates SET posts = posts - 1 WHERE community_id = OLD.community_id; -- Update the counts if the post got deleted UPDATE community_aggregates ca SET posts = coalesce(cd.posts, 0), comments = coalesce(cd.comments, 0) FROM ( SELECT c.id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM community c LEFT JOIN post p ON c.id = p.community_id LEFT JOIN comment ct ON p.id = ct.post_id GROUP BY c.id) cd WHERE ca.community_id = OLD.community_id; END IF; RETURN NULL; END $$; CREATE TRIGGER community_aggregates_post_count AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE community_aggregates_post_count (); -- comment count CREATE FUNCTION community_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates ca SET comments = comments + 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = NEW.post_id AND ca.community_id = p.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates ca SET comments = comments - 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = OLD.post_id AND ca.community_id = p.community_id; END IF; RETURN NULL; END $$; CREATE TRIGGER community_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE community_aggregates_comment_count (); -- subscriber count CREATE FUNCTION community_aggregates_subscriber_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates SET subscribers = subscribers + 1 WHERE community_id = NEW.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates SET subscribers = subscribers - 1 WHERE community_id = OLD.community_id; END IF; RETURN NULL; END $$; CREATE TRIGGER community_aggregates_subscriber_count AFTER INSERT OR DELETE ON community_follower FOR EACH ROW EXECUTE PROCEDURE community_aggregates_subscriber_count (); ================================================ FILE: migrations/2020-12-10-152350_create_post_aggregates/down.sql ================================================ -- post aggregates DROP TABLE post_aggregates; DROP TRIGGER post_aggregates_post ON post; DROP TRIGGER post_aggregates_comment_count ON comment; DROP TRIGGER post_aggregates_score ON post_like; DROP TRIGGER post_aggregates_stickied ON post; DROP FUNCTION post_aggregates_post, post_aggregates_comment_count, post_aggregates_score, post_aggregates_stickied; ================================================ FILE: migrations/2020-12-10-152350_create_post_aggregates/up.sql ================================================ -- Add post aggregates CREATE TABLE post_aggregates ( id serial PRIMARY KEY, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, comments bigint NOT NULL DEFAULT 0, score bigint NOT NULL DEFAULT 0, upvotes bigint NOT NULL DEFAULT 0, downvotes bigint NOT NULL DEFAULT 0, stickied boolean NOT NULL DEFAULT FALSE, published timestamp NOT NULL DEFAULT now(), newest_comment_time timestamp NOT NULL DEFAULT now(), UNIQUE (post_id) ); INSERT INTO post_aggregates (post_id, comments, score, upvotes, downvotes, stickied, published, newest_comment_time) SELECT p.id, coalesce(ct.comments, 0::bigint) AS comments, coalesce(pl.score, 0::bigint) AS score, coalesce(pl.upvotes, 0::bigint) AS upvotes, coalesce(pl.downvotes, 0::bigint) AS downvotes, p.stickied, p.published, greatest (ct.recent_comment_time, p.published) AS newest_activity_time FROM post p LEFT JOIN ( SELECT comment.post_id, count(*) AS comments, max(comment.published) AS recent_comment_time FROM comment GROUP BY comment.post_id) ct ON ct.post_id = p.id LEFT JOIN ( SELECT post_like.post_id, sum(post_like.score) AS score, sum(post_like.score) FILTER (WHERE post_like.score = 1) AS upvotes, - sum(post_like.score) FILTER (WHERE post_like.score = '-1'::integer) AS downvotes FROM post_like GROUP BY post_like.post_id) pl ON pl.post_id = p.id; -- Add community aggregate triggers -- initial post add CREATE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_post AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE post_aggregates_post (); -- comment count CREATE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET comments = comments + 1 WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id AND published > ('now'::timestamp - '2 days'::interval); ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE post_aggregates_comment_count (); -- post score CREATE FUNCTION post_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END WHERE pa.post_id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_score AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE PROCEDURE post_aggregates_score (); -- post stickied CREATE FUNCTION post_aggregates_stickied () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_aggregates pa SET stickied = NEW.stickied WHERE pa.post_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_stickied AFTER UPDATE ON post FOR EACH ROW WHEN (OLD.stickied IS DISTINCT FROM NEW.stickied) EXECUTE PROCEDURE post_aggregates_stickied (); ================================================ FILE: migrations/2020-12-14-020038_create_comment_aggregates/down.sql ================================================ -- comment aggregates DROP TABLE comment_aggregates; DROP TRIGGER comment_aggregates_comment ON comment; DROP TRIGGER comment_aggregates_score ON comment_like; DROP FUNCTION comment_aggregates_comment, comment_aggregates_score; ================================================ FILE: migrations/2020-12-14-020038_create_comment_aggregates/up.sql ================================================ -- Add comment aggregates CREATE TABLE comment_aggregates ( id serial PRIMARY KEY, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, score bigint NOT NULL DEFAULT 0, upvotes bigint NOT NULL DEFAULT 0, downvotes bigint NOT NULL DEFAULT 0, published timestamp NOT NULL DEFAULT now(), UNIQUE (comment_id) ); INSERT INTO comment_aggregates (comment_id, score, upvotes, downvotes, published) SELECT c.id, COALESCE(cl.total, 0::bigint) AS score, COALESCE(cl.up, 0::bigint) AS upvotes, COALESCE(cl.down, 0::bigint) AS downvotes, c.published FROM comment c LEFT JOIN ( SELECT l.comment_id AS id, sum(l.score) AS total, count( CASE WHEN l.score = 1 THEN 1 ELSE NULL::integer END) AS up, count( CASE WHEN l.score = '-1'::integer THEN 1 ELSE NULL::integer END) AS down FROM comment_like l GROUP BY l.comment_id) cl ON cl.id = c.id; -- Add comment aggregate triggers -- initial comment add CREATE FUNCTION comment_aggregates_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates (comment_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates WHERE comment_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER comment_aggregates_comment AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE comment_aggregates_comment (); -- comment score CREATE FUNCTION comment_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE comment_aggregates ca SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END WHERE ca.comment_id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to comment because that comment may not exist anymore UPDATE comment_aggregates ca SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END FROM comment c WHERE ca.comment_id = c.id AND ca.comment_id = OLD.comment_id; END IF; RETURN NULL; END $$; CREATE TRIGGER comment_aggregates_score AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE PROCEDURE comment_aggregates_score (); ================================================ FILE: migrations/2020-12-17-030456_create_alias_views/down.sql ================================================ DROP VIEW user_alias_1, user_alias_2, comment_alias_1; ================================================ FILE: migrations/2020-12-17-030456_create_alias_views/up.sql ================================================ -- Some view that act as aliases -- unfortunately necessary, since diesel doesn't have self joins -- or alias support yet CREATE VIEW user_alias_1 AS SELECT * FROM user_; CREATE VIEW user_alias_2 AS SELECT * FROM user_; CREATE VIEW comment_alias_1 AS SELECT * FROM comment; ================================================ FILE: migrations/2020-12-17-031053_remove_fast_tables_and_views/down.sql ================================================ -- There is no restore for this, it would require every view, table, index, etc. -- If you want to save past this point, you should make a DB backup. SELECT * FROM user_ LIMIT 1; ================================================ FILE: migrations/2020-12-17-031053_remove_fast_tables_and_views/up.sql ================================================ -- Drop triggers DROP TRIGGER IF EXISTS refresh_comment ON comment; DROP TRIGGER IF EXISTS refresh_comment_like ON comment_like; DROP TRIGGER IF EXISTS refresh_community ON community; DROP TRIGGER IF EXISTS refresh_community_follower ON community_follower; DROP TRIGGER IF EXISTS refresh_community_user_ban ON community_user_ban; DROP TRIGGER IF EXISTS refresh_post ON post; DROP TRIGGER IF EXISTS refresh_post_like ON post_like; DROP TRIGGER IF EXISTS refresh_user ON user_; -- Drop functions DROP FUNCTION IF EXISTS refresh_comment, refresh_comment_like, refresh_community, refresh_community_follower, refresh_community_user_ban, refresh_post, refresh_post_like, refresh_private_message, refresh_user CASCADE; -- Drop views DROP VIEW IF EXISTS comment_aggregates_view, comment_fast_view, comment_report_view, comment_view, community_aggregates_view, community_fast_view, community_follower_view, community_moderator_view, community_user_ban_view, community_view, mod_add_community_view, mod_add_view, mod_ban_from_community_view, mod_ban_view, mod_lock_post_view, mod_remove_comment_view, mod_remove_community_view, mod_remove_post_view, mod_sticky_post_view, post_aggregates_view, post_fast_view, post_report_view, post_view, private_message_view, reply_fast_view, site_view, user_mention_fast_view, user_mention_view, user_view CASCADE; -- Drop fast tables DROP TABLE IF EXISTS comment_aggregates_fast, community_aggregates_fast, post_aggregates_fast, user_fast CASCADE; ================================================ FILE: migrations/2021-01-05-200932_add_hot_rank_indexes/down.sql ================================================ -- Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone) RETURNS integer AS $$ BEGIN -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600 RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer; END; $$ LANGUAGE plpgsql; DROP INDEX idx_post_aggregates_hot, idx_post_aggregates_stickied_hot, idx_post_aggregates_active, idx_post_aggregates_stickied_active, idx_post_aggregates_score, idx_post_aggregates_stickied_score, idx_post_aggregates_published, idx_post_aggregates_stickied_published, idx_comment_published, idx_comment_aggregates_hot, idx_comment_aggregates_score, idx_user_published, idx_user_aggregates_comment_score, idx_community_published, idx_community_aggregates_hot, idx_community_aggregates_subscribers; ================================================ FILE: migrations/2021-01-05-200932_add_hot_rank_indexes/up.sql ================================================ -- Need to add immutable to the hot_rank function in order to index by it -- Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone) RETURNS integer AS $$ BEGIN -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600 RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer; END; $$ LANGUAGE plpgsql IMMUTABLE; -- Post_aggregates CREATE INDEX idx_post_aggregates_stickied_hot ON post_aggregates (stickied DESC, hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_stickied_active ON post_aggregates (stickied DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_stickied_score ON post_aggregates (stickied DESC, score DESC); CREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC); CREATE INDEX idx_post_aggregates_stickied_published ON post_aggregates (stickied DESC, published DESC); CREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC); -- Comment CREATE INDEX idx_comment_published ON comment (published DESC); -- Comment_aggregates CREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC); -- User CREATE INDEX idx_user_published ON user_ (published DESC); -- User_aggregates CREATE INDEX idx_user_aggregates_comment_score ON user_aggregates (comment_score DESC); -- Community CREATE INDEX idx_community_published ON community (published DESC); -- Community_aggregates CREATE INDEX idx_community_aggregates_hot ON community_aggregates (hot_rank (subscribers, published) DESC, published DESC); CREATE INDEX idx_community_aggregates_subscribers ON community_aggregates (subscribers DESC); ================================================ FILE: migrations/2021-01-26-173850_default_actor_id/down.sql ================================================ CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '') FROM generate_series(1, 20) $$; ================================================ FILE: migrations/2021-01-26-173850_default_actor_id/up.sql ================================================ CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'http://changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '') FROM generate_series(1, 20) $$; ================================================ FILE: migrations/2021-01-27-202728_active_users_monthly/down.sql ================================================ ALTER TABLE site_aggregates DROP COLUMN users_active_day, DROP COLUMN users_active_week, DROP COLUMN users_active_month, DROP COLUMN users_active_half_year; ALTER TABLE community_aggregates DROP COLUMN users_active_day, DROP COLUMN users_active_week, DROP COLUMN users_active_month, DROP COLUMN users_active_half_year; DROP FUNCTION site_aggregates_activity (i text); DROP FUNCTION community_aggregates_activity (i text); ================================================ FILE: migrations/2021-01-27-202728_active_users_monthly/up.sql ================================================ -- Add monthly and half yearly active columns for site and community aggregates -- These columns don't need to be updated with a trigger, so they're saved daily via queries ALTER TABLE site_aggregates ADD COLUMN users_active_day bigint NOT NULL DEFAULT 0; ALTER TABLE site_aggregates ADD COLUMN users_active_week bigint NOT NULL DEFAULT 0; ALTER TABLE site_aggregates ADD COLUMN users_active_month bigint NOT NULL DEFAULT 0; ALTER TABLE site_aggregates ADD COLUMN users_active_half_year bigint NOT NULL DEFAULT 0; ALTER TABLE community_aggregates ADD COLUMN users_active_day bigint NOT NULL DEFAULT 0; ALTER TABLE community_aggregates ADD COLUMN users_active_week bigint NOT NULL DEFAULT 0; ALTER TABLE community_aggregates ADD COLUMN users_active_month bigint NOT NULL DEFAULT 0; ALTER TABLE community_aggregates ADD COLUMN users_active_half_year bigint NOT NULL DEFAULT 0; CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS int LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN user_ u ON c.creator_id = u.id WHERE c.published > ('now'::timestamp - i::interval) AND u.local = TRUE UNION SELECT p.creator_id FROM post p INNER JOIN user_ u ON p.creator_id = u.id WHERE p.published > ('now'::timestamp - i::interval) AND u.local = TRUE) a; RETURN count_; END; $$; UPDATE site_aggregates SET users_active_day = ( SELECT * FROM site_aggregates_activity ('1 day')); UPDATE site_aggregates SET users_active_week = ( SELECT * FROM site_aggregates_activity ('1 week')); UPDATE site_aggregates SET users_active_month = ( SELECT * FROM site_aggregates_activity ('1 month')); UPDATE site_aggregates SET users_active_half_year = ( SELECT * FROM site_aggregates_activity ('6 months')); CREATE OR REPLACE FUNCTION community_aggregates_activity (i text) RETURNS TABLE ( count_ bigint, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*), community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id WHERE c.published > ('now'::timestamp - i::interval) UNION SELECT p.creator_id, p.community_id FROM post p WHERE p.published > ('now'::timestamp - i::interval)) a GROUP BY community_id; END; $$; UPDATE community_aggregates ca SET users_active_day = mv.count_ FROM community_aggregates_activity ('1 day') mv WHERE ca.community_id = mv.community_id_; UPDATE community_aggregates ca SET users_active_week = mv.count_ FROM community_aggregates_activity ('1 week') mv WHERE ca.community_id = mv.community_id_; UPDATE community_aggregates ca SET users_active_month = mv.count_ FROM community_aggregates_activity ('1 month') mv WHERE ca.community_id = mv.community_id_; UPDATE community_aggregates ca SET users_active_half_year = mv.count_ FROM community_aggregates_activity ('6 months') mv WHERE ca.community_id = mv.community_id_; ================================================ FILE: migrations/2021-01-31-050334_add_forum_sort_index/down.sql ================================================ DROP INDEX idx_post_aggregates_comments; ================================================ FILE: migrations/2021-01-31-050334_add_forum_sort_index/up.sql ================================================ CREATE INDEX idx_post_aggregates_comments ON post_aggregates (comments DESC); ================================================ FILE: migrations/2021-02-02-153240_apub_columns/down.sql ================================================ ALTER TABLE community DROP COLUMN followers_url; ALTER TABLE community DROP COLUMN inbox_url; ALTER TABLE community DROP COLUMN shared_inbox_url; ALTER TABLE user_ DROP COLUMN inbox_url; ALTER TABLE user_ DROP COLUMN shared_inbox_url; ================================================ FILE: migrations/2021-02-02-153240_apub_columns/up.sql ================================================ ALTER TABLE community ADD COLUMN followers_url varchar(255) NOT NULL DEFAULT generate_unique_changeme (); ALTER TABLE community ADD COLUMN inbox_url varchar(255) NOT NULL DEFAULT generate_unique_changeme (); ALTER TABLE community ADD COLUMN shared_inbox_url varchar(255); ALTER TABLE user_ ADD COLUMN inbox_url varchar(255) NOT NULL DEFAULT generate_unique_changeme (); ALTER TABLE user_ ADD COLUMN shared_inbox_url varchar(255); ALTER TABLE community ADD CONSTRAINT idx_community_followers_url UNIQUE (followers_url); ALTER TABLE community ADD CONSTRAINT idx_community_inbox_url UNIQUE (inbox_url); ALTER TABLE user_ ADD CONSTRAINT idx_user_inbox_url UNIQUE (inbox_url); ================================================ FILE: migrations/2021-02-10-164051_add_new_comments_sort_index/down.sql ================================================ DROP INDEX idx_post_aggregates_newest_comment_time, idx_post_aggregates_stickied_newest_comment_time, idx_post_aggregates_stickied_comments; ALTER TABLE post_aggregates DROP COLUMN newest_comment_time; ALTER TABLE post_aggregates RENAME COLUMN newest_comment_time_necro TO newest_comment_time; CREATE OR REPLACE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET comments = comments + 1 WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id AND published > ('now'::timestamp - '2 days'::interval); ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2021-02-10-164051_add_new_comments_sort_index/up.sql ================================================ -- First rename current newest comment time to newest_comment_time_necro -- necro means that time is limited to 2 days, whereas newest_comment_time ignores that. ALTER TABLE post_aggregates RENAME COLUMN newest_comment_time TO newest_comment_time_necro; -- Add the newest_comment_time column ALTER TABLE post_aggregates ADD COLUMN newest_comment_time timestamp NOT NULL DEFAULT now(); -- Set the current newest_comment_time based on the old ones UPDATE post_aggregates SET newest_comment_time = newest_comment_time_necro; -- Add the indexes for this new column CREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC); CREATE INDEX idx_post_aggregates_stickied_newest_comment_time ON post_aggregates (stickied DESC, newest_comment_time DESC); -- Forgot to add index w/ stickied first for most comments: CREATE INDEX idx_post_aggregates_stickied_comments ON post_aggregates (stickied DESC, comments DESC); -- Alter the comment trigger to set the newest_comment_time, and newest_comment_time_necro CREATE OR REPLACE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET comments = comments + 1, newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time_necro = NEW.published WHERE pa.post_id = NEW.post_id AND published > ('now'::timestamp - '2 days'::interval); ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2021-02-13-210612_set_correct_aggregates_time_columns/down.sql ================================================ CREATE OR REPLACE FUNCTION comment_aggregates_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates (comment_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates WHERE comment_id = OLD.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION community_aggregates_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates (community_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates WHERE community_id = OLD.id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2021-02-13-210612_set_correct_aggregates_time_columns/up.sql ================================================ -- The published and updated columns on the aggregates tables are using now(), -- when they should use the correct published or updated columns -- This is mainly a problem with federated posts being fetched CREATE OR REPLACE FUNCTION comment_aggregates_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates (comment_id, published) VALUES (NEW.id, NEW.published); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates WHERE comment_id = OLD.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro) VALUES (NEW.id, NEW.published, NEW.published, NEW.published); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION community_aggregates_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates (community_id, published) VALUES (NEW.id, NEW.published); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates WHERE community_id = OLD.id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2021-02-25-112959_remove-categories/down.sql ================================================ CREATE TABLE category ( id serial PRIMARY KEY, name varchar(100) NOT NULL UNIQUE ); INSERT INTO category (name) VALUES ('Discussion'), ('Humor/Memes'), ('Gaming'), ('Movies'), ('TV'), ('Music'), ('Literature'), ('Comics'), ('Photography'), ('Art'), ('Learning'), ('DIY'), ('Lifestyle'), ('News'), ('Politics'), ('Society'), ('Gender/Identity/Sexuality'), ('Race/Colonisation'), ('Religion'), ('Science/Technology'), ('Programming/Software'), ('Health/Sports/Fitness'), ('Porn'), ('Places'), ('Meta'), ('Other'); ALTER TABLE community ADD category_id int REFERENCES category ON UPDATE CASCADE ON DELETE CASCADE NOT NULL DEFAULT 1; -- Default is only for existing rows ALTER TABLE community ALTER COLUMN category_id DROP DEFAULT; CREATE INDEX idx_community_category ON community (category_id); ================================================ FILE: migrations/2021-02-25-112959_remove-categories/up.sql ================================================ ALTER TABLE community DROP COLUMN category_id; DROP TABLE category; ================================================ FILE: migrations/2021-02-28-162616_clean_empty_post_urls/down.sql ================================================ -- This is a clean-up migration that cannot be undone, -- but Diesel requires a non-empty script so run a no-op. SELECT 1; ================================================ FILE: migrations/2021-02-28-162616_clean_empty_post_urls/up.sql ================================================ UPDATE post SET url = NULL WHERE url = ''; ================================================ FILE: migrations/2021-03-04-040229_clean_icon_urls/down.sql ================================================ -- This is a clean-up migration that cannot be undone, -- but Diesel requires a non-empty script so run a no-op. SELECT 1; ================================================ FILE: migrations/2021-03-04-040229_clean_icon_urls/up.sql ================================================ -- If these are not urls, it will crash the server UPDATE user_ SET avatar = NULL WHERE avatar NOT LIKE 'http%'; UPDATE user_ SET banner = NULL WHERE banner NOT LIKE 'http%'; UPDATE community SET icon = NULL WHERE icon NOT LIKE 'http%'; UPDATE community SET banner = NULL WHERE banner NOT LIKE 'http%'; ================================================ FILE: migrations/2021-03-09-171136_split_user_table_2/down.sql ================================================ -- post_saved ALTER TABLE post_saved RENAME COLUMN person_id TO user_id; ALTER TABLE post_saved RENAME CONSTRAINT post_saved_post_id_person_id_key TO post_saved_post_id_user_id_key; ALTER TABLE post_saved RENAME CONSTRAINT post_saved_person_id_fkey TO post_saved_user_id_fkey; -- post_read ALTER TABLE post_read RENAME COLUMN person_id TO user_id; ALTER TABLE post_read RENAME CONSTRAINT post_read_post_id_person_id_key TO post_read_post_id_user_id_key; ALTER TABLE post_read RENAME CONSTRAINT post_read_person_id_fkey TO post_read_user_id_fkey; -- post_like ALTER TABLE post_like RENAME COLUMN person_id TO user_id; ALTER INDEX idx_post_like_person RENAME TO idx_post_like_user; ALTER TABLE post_like RENAME CONSTRAINT post_like_post_id_person_id_key TO post_like_post_id_user_id_key; ALTER TABLE post_like RENAME CONSTRAINT post_like_person_id_fkey TO post_like_user_id_fkey; -- password_reset_request DELETE FROM password_reset_request; ALTER TABLE password_reset_request DROP COLUMN local_user_id; ALTER TABLE password_reset_request ADD COLUMN user_id integer NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE; -- mod_sticky_post ALTER TABLE mod_sticky_post RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_sticky_post RENAME CONSTRAINT mod_sticky_post_mod_person_id_fkey TO mod_sticky_post_mod_user_id_fkey; -- mod_remove_post ALTER TABLE mod_remove_post RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_remove_post RENAME CONSTRAINT mod_remove_post_mod_person_id_fkey TO mod_remove_post_mod_user_id_fkey; -- mod_remove_community ALTER TABLE mod_remove_community RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_remove_community RENAME CONSTRAINT mod_remove_community_mod_person_id_fkey TO mod_remove_community_mod_user_id_fkey; -- mod_remove_comment ALTER TABLE mod_remove_comment RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_remove_comment RENAME CONSTRAINT mod_remove_comment_mod_person_id_fkey TO mod_remove_comment_mod_user_id_fkey; -- mod_lock_post ALTER TABLE mod_lock_post RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_lock_post RENAME CONSTRAINT mod_lock_post_mod_person_id_fkey TO mod_lock_post_mod_user_id_fkey; -- mod_add_community ALTER TABLE mod_ban_from_community RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_ban_from_community RENAME COLUMN other_person_id TO other_user_id; ALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_mod_person_id_fkey TO mod_ban_from_community_mod_user_id_fkey; ALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_other_person_id_fkey TO mod_ban_from_community_other_user_id_fkey; -- mod_ban ALTER TABLE mod_ban RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_ban RENAME COLUMN other_person_id TO other_user_id; ALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_mod_person_id_fkey TO mod_ban_mod_user_id_fkey; ALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_other_person_id_fkey TO mod_ban_other_user_id_fkey; -- mod_add_community ALTER TABLE mod_add_community RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_add_community RENAME COLUMN other_person_id TO other_user_id; ALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_mod_person_id_fkey TO mod_add_community_mod_user_id_fkey; ALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_other_person_id_fkey TO mod_add_community_other_user_id_fkey; -- mod_add ALTER TABLE mod_add RENAME COLUMN mod_person_id TO mod_user_id; ALTER TABLE mod_add RENAME COLUMN other_person_id TO other_user_id; ALTER TABLE mod_add RENAME CONSTRAINT mod_add_mod_person_id_fkey TO mod_add_mod_user_id_fkey; ALTER TABLE mod_add RENAME CONSTRAINT mod_add_other_person_id_fkey TO mod_add_other_user_id_fkey; -- community_user_ban ALTER TABLE community_person_ban RENAME TO community_user_ban; ALTER SEQUENCE community_person_ban_id_seq RENAME TO community_user_ban_id_seq; ALTER TABLE community_user_ban RENAME COLUMN person_id TO user_id; ALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_pkey TO community_user_ban_pkey; ALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_community_id_fkey TO community_user_ban_community_id_fkey; ALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_community_id_person_id_key TO community_user_ban_community_id_user_id_key; ALTER TABLE community_user_ban RENAME CONSTRAINT community_person_ban_person_id_fkey TO community_user_ban_user_id_fkey; -- community_moderator ALTER TABLE community_moderator RENAME COLUMN person_id TO user_id; ALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_community_id_person_id_key TO community_moderator_community_id_user_id_key; ALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_person_id_fkey TO community_moderator_user_id_fkey; -- community_follower ALTER TABLE community_follower RENAME COLUMN person_id TO user_id; ALTER TABLE community_follower RENAME CONSTRAINT community_follower_community_id_person_id_key TO community_follower_community_id_user_id_key; ALTER TABLE community_follower RENAME CONSTRAINT community_follower_person_id_fkey TO community_follower_user_id_fkey; -- comment_saved ALTER TABLE comment_saved RENAME COLUMN person_id TO user_id; ALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_comment_id_person_id_key TO comment_saved_comment_id_user_id_key; ALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_person_id_fkey TO comment_saved_user_id_fkey; -- comment_like ALTER TABLE comment_like RENAME COLUMN person_id TO user_id; ALTER INDEX idx_comment_like_person RENAME TO idx_comment_like_user; ALTER TABLE comment_like RENAME CONSTRAINT comment_like_comment_id_person_id_key TO comment_like_comment_id_user_id_key; ALTER TABLE comment_like RENAME CONSTRAINT comment_like_person_id_fkey TO comment_like_user_id_fkey; -- user_ban ALTER TABLE person_ban RENAME TO user_ban; ALTER SEQUENCE person_ban_id_seq RENAME TO user_ban_id_seq; ALTER INDEX person_ban_pkey RENAME TO user_ban_pkey; ALTER INDEX person_ban_person_id_key RENAME TO user_ban_user_id_key; ALTER TABLE user_ban RENAME COLUMN person_id TO user_id; ALTER TABLE user_ban RENAME CONSTRAINT person_ban_person_id_fkey TO user_ban_user_id_fkey; -- user_mention ALTER TABLE person_mention RENAME TO user_mention; ALTER SEQUENCE person_mention_id_seq RENAME TO user_mention_id_seq; ALTER INDEX person_mention_pkey RENAME TO user_mention_pkey; ALTER INDEX person_mention_recipient_id_comment_id_key RENAME TO user_mention_recipient_id_comment_id_key; ALTER TABLE user_mention RENAME CONSTRAINT person_mention_comment_id_fkey TO user_mention_comment_id_fkey; ALTER TABLE user_mention RENAME CONSTRAINT person_mention_recipient_id_fkey TO user_mention_recipient_id_fkey; -- User aggregates table ALTER TABLE person_aggregates RENAME TO user_aggregates; ALTER SEQUENCE person_aggregates_id_seq RENAME TO user_aggregates_id_seq; ALTER TABLE user_aggregates RENAME COLUMN person_id TO user_id; -- Indexes ALTER INDEX person_aggregates_pkey RENAME TO user_aggregates_pkey; ALTER INDEX idx_person_aggregates_comment_score RENAME TO idx_user_aggregates_comment_score; ALTER INDEX person_aggregates_person_id_key RENAME TO user_aggregates_user_id_key; ALTER TABLE user_aggregates RENAME CONSTRAINT person_aggregates_person_id_fkey TO user_aggregates_user_id_fkey; -- Redo the user_aggregates table DROP TRIGGER person_aggregates_person ON person; DROP TRIGGER person_aggregates_post_count ON post; DROP TRIGGER person_aggregates_post_score ON post_like; DROP TRIGGER person_aggregates_comment_count ON comment; DROP TRIGGER person_aggregates_comment_score ON comment_like; DROP FUNCTION person_aggregates_person, person_aggregates_post_count, person_aggregates_post_score, person_aggregates_comment_count, person_aggregates_comment_score; -- user_ table -- Drop views DROP VIEW person_alias_1, person_alias_2; -- Rename indexes ALTER INDEX person__pkey RENAME TO user__pkey; ALTER INDEX idx_person_actor_id RENAME TO idx_user_actor_id; ALTER INDEX idx_person_inbox_url RENAME TO idx_user_inbox_url; ALTER INDEX idx_person_lower_actor_id RENAME TO idx_user_lower_actor_id; ALTER INDEX idx_person_published RENAME TO idx_user_published; -- Rename triggers ALTER TRIGGER site_aggregates_person_delete ON person RENAME TO site_aggregates_user_delete; ALTER TRIGGER site_aggregates_person_insert ON person RENAME TO site_aggregates_user_insert; -- Rename the trigger functions ALTER FUNCTION site_aggregates_person_delete () RENAME TO site_aggregates_user_delete; ALTER FUNCTION site_aggregates_person_insert () RENAME TO site_aggregates_user_insert; -- Rename the table back to user_ ALTER TABLE person RENAME TO user_; ALTER SEQUENCE person_id_seq RENAME TO user__id_seq; -- Add the columns back in ALTER TABLE user_ ADD COLUMN password_encrypted text NOT NULL DEFAULT 'changeme', ADD COLUMN email text UNIQUE, ADD COLUMN admin boolean DEFAULT FALSE NOT NULL, ADD COLUMN show_nsfw boolean DEFAULT FALSE NOT NULL, ADD COLUMN theme character varying(20) DEFAULT 'darkly'::character varying NOT NULL, ADD COLUMN default_sort_type smallint DEFAULT 0 NOT NULL, ADD COLUMN default_listing_type smallint DEFAULT 1 NOT NULL, ADD COLUMN lang character varying(20) DEFAULT 'browser'::character varying NOT NULL, ADD COLUMN show_avatars boolean DEFAULT TRUE NOT NULL, ADD COLUMN send_notifications_to_email boolean DEFAULT FALSE NOT NULL, ADD COLUMN matrix_user_id text UNIQUE; -- Default is only for existing rows ALTER TABLE user_ ALTER COLUMN password_encrypted DROP DEFAULT; -- Update the user_ table with the local_user data UPDATE user_ u SET password_encrypted = lu.password_encrypted, email = lu.email, admin = lu.admin, show_nsfw = lu.show_nsfw, theme = lu.theme, default_sort_type = lu.default_sort_type, default_listing_type = lu.default_listing_type, lang = lu.lang, show_avatars = lu.show_avatars, send_notifications_to_email = lu.send_notifications_to_email, matrix_user_id = lu.matrix_user_id FROM local_user lu WHERE lu.person_id = u.id; CREATE UNIQUE INDEX idx_user_email_lower ON user_ (lower(email)); CREATE VIEW user_alias_1 AS SELECT * FROM user_; CREATE VIEW user_alias_2 AS SELECT * FROM user_; DROP TABLE local_user; -- Add the user_aggregates table triggers -- initial user add CREATE FUNCTION user_aggregates_user () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO user_aggregates (user_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM user_aggregates WHERE user_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_user AFTER INSERT OR DELETE ON user_ FOR EACH ROW EXECUTE PROCEDURE user_aggregates_user (); -- post count CREATE FUNCTION user_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE user_aggregates SET post_count = post_count + 1 WHERE user_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates SET post_count = post_count - 1 WHERE user_id = OLD.creator_id; -- If the post gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE user_aggregates ua SET post_score = pd.score FROM ( SELECT u.id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM user_ u LEFT JOIN post p ON u.id = p.creator_id LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.user_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_post_count AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE user_aggregates_post_count (); -- post score CREATE FUNCTION user_aggregates_post_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE user_aggregates ua SET post_score = post_score + NEW.score FROM post p WHERE ua.user_id = p.creator_id AND p.id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates ua SET post_score = post_score - OLD.score FROM post p WHERE ua.user_id = p.creator_id AND p.id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_post_score AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE PROCEDURE user_aggregates_post_score (); -- comment count CREATE FUNCTION user_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE user_aggregates SET comment_count = comment_count + 1 WHERE user_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates SET comment_count = comment_count - 1 WHERE user_id = OLD.creator_id; -- If the comment gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE user_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM user_ u LEFT JOIN comment c ON u.id = c.creator_id LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.user_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE user_aggregates_comment_count (); -- comment score CREATE FUNCTION user_aggregates_comment_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE user_aggregates ua SET comment_score = comment_score + NEW.score FROM comment c WHERE ua.user_id = c.creator_id AND c.id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE user_aggregates ua SET comment_score = comment_score - OLD.score FROM comment c WHERE ua.user_id = c.creator_id AND c.id = OLD.comment_id; END IF; RETURN NULL; END $$; CREATE TRIGGER user_aggregates_comment_score AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE PROCEDURE user_aggregates_comment_score (); -- redo site aggregates trigger CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN user_ u ON c.creator_id = u.id WHERE c.published > ('now'::timestamp - i::interval) AND u.local = TRUE UNION SELECT p.creator_id FROM post p INNER JOIN user_ u ON p.creator_id = u.id WHERE p.published > ('now'::timestamp - i::interval) AND u.local = TRUE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2021-03-09-171136_split_user_table_2/up.sql ================================================ -- Person -- Drop the 2 views user_alias_1, user_alias_2 DROP VIEW user_alias_1, user_alias_2; -- rename the user_ table to person ALTER TABLE user_ RENAME TO person; ALTER SEQUENCE user__id_seq RENAME TO person_id_seq; -- create a new table local_user CREATE TABLE local_user ( id serial PRIMARY KEY, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, password_encrypted text NOT NULL, email text UNIQUE, admin boolean DEFAULT FALSE NOT NULL, show_nsfw boolean DEFAULT FALSE NOT NULL, theme character varying(20) DEFAULT 'darkly'::character varying NOT NULL, default_sort_type smallint DEFAULT 0 NOT NULL, default_listing_type smallint DEFAULT 1 NOT NULL, lang character varying(20) DEFAULT 'browser'::character varying NOT NULL, show_avatars boolean DEFAULT TRUE NOT NULL, send_notifications_to_email boolean DEFAULT FALSE NOT NULL, matrix_user_id text, UNIQUE (person_id) ); -- Copy the local users over to the new table INSERT INTO local_user (person_id, password_encrypted, email, admin, show_nsfw, theme, default_sort_type, default_listing_type, lang, show_avatars, send_notifications_to_email, matrix_user_id) SELECT id, password_encrypted, email, admin, show_nsfw, theme, default_sort_type, default_listing_type, lang, show_avatars, send_notifications_to_email, matrix_user_id FROM person WHERE local = TRUE; -- Drop those columns from person ALTER TABLE person DROP COLUMN password_encrypted, DROP COLUMN email, DROP COLUMN admin, DROP COLUMN show_nsfw, DROP COLUMN theme, DROP COLUMN default_sort_type, DROP COLUMN default_listing_type, DROP COLUMN lang, DROP COLUMN show_avatars, DROP COLUMN send_notifications_to_email, DROP COLUMN matrix_user_id; -- Rename indexes ALTER INDEX user__pkey RENAME TO person__pkey; ALTER INDEX idx_user_actor_id RENAME TO idx_person_actor_id; ALTER INDEX idx_user_inbox_url RENAME TO idx_person_inbox_url; ALTER INDEX idx_user_lower_actor_id RENAME TO idx_person_lower_actor_id; ALTER INDEX idx_user_published RENAME TO idx_person_published; -- Rename triggers ALTER TRIGGER site_aggregates_user_delete ON person RENAME TO site_aggregates_person_delete; ALTER TRIGGER site_aggregates_user_insert ON person RENAME TO site_aggregates_person_insert; -- Rename the trigger functions ALTER FUNCTION site_aggregates_user_delete () RENAME TO site_aggregates_person_delete; ALTER FUNCTION site_aggregates_user_insert () RENAME TO site_aggregates_person_insert; -- Create views CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; -- Redo user aggregates into person_aggregates ALTER TABLE user_aggregates RENAME TO person_aggregates; ALTER SEQUENCE user_aggregates_id_seq RENAME TO person_aggregates_id_seq; ALTER TABLE person_aggregates RENAME COLUMN user_id TO person_id; -- index ALTER INDEX user_aggregates_pkey RENAME TO person_aggregates_pkey; ALTER INDEX idx_user_aggregates_comment_score RENAME TO idx_person_aggregates_comment_score; ALTER INDEX user_aggregates_user_id_key RENAME TO person_aggregates_person_id_key; ALTER TABLE person_aggregates RENAME CONSTRAINT user_aggregates_user_id_fkey TO person_aggregates_person_id_fkey; -- Drop all the old triggers and functions DROP TRIGGER user_aggregates_user ON person; DROP TRIGGER user_aggregates_post_count ON post; DROP TRIGGER user_aggregates_post_score ON post_like; DROP TRIGGER user_aggregates_comment_count ON comment; DROP TRIGGER user_aggregates_comment_score ON comment_like; DROP FUNCTION user_aggregates_user, user_aggregates_post_count, user_aggregates_post_score, user_aggregates_comment_count, user_aggregates_comment_score; -- initial user add CREATE FUNCTION person_aggregates_person () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO person_aggregates (person_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM person_aggregates WHERE person_id = OLD.id; END IF; RETURN NULL; END $$; CREATE TRIGGER person_aggregates_person AFTER INSERT OR DELETE ON person FOR EACH ROW EXECUTE PROCEDURE person_aggregates_person (); -- post count CREATE FUNCTION person_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE person_aggregates SET post_count = post_count + 1 WHERE person_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates SET post_count = post_count - 1 WHERE person_id = OLD.creator_id; -- If the post gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET post_score = pd.score FROM ( SELECT u.id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM person u LEFT JOIN post p ON u.id = p.creator_id LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE TRIGGER person_aggregates_post_count AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE person_aggregates_post_count (); -- post score CREATE FUNCTION person_aggregates_post_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE person_aggregates ua SET post_score = post_score + NEW.score FROM post p WHERE ua.person_id = p.creator_id AND p.id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates ua SET post_score = post_score - OLD.score FROM post p WHERE ua.person_id = p.creator_id AND p.id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER person_aggregates_post_score AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE PROCEDURE person_aggregates_post_score (); -- comment count CREATE FUNCTION person_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE person_aggregates SET comment_count = comment_count + 1 WHERE person_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates SET comment_count = comment_count - 1 WHERE person_id = OLD.creator_id; -- If the comment gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM person u LEFT JOIN comment c ON u.id = c.creator_id LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE TRIGGER person_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE person_aggregates_comment_count (); -- comment score CREATE FUNCTION person_aggregates_comment_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE person_aggregates ua SET comment_score = comment_score + NEW.score FROM comment c WHERE ua.person_id = c.creator_id AND c.id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates ua SET comment_score = comment_score - OLD.score FROM comment c WHERE ua.person_id = c.creator_id AND c.id = OLD.comment_id; END IF; RETURN NULL; END $$; CREATE TRIGGER person_aggregates_comment_score AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE PROCEDURE person_aggregates_comment_score (); -- person_mention ALTER TABLE user_mention RENAME TO person_mention; ALTER SEQUENCE user_mention_id_seq RENAME TO person_mention_id_seq; ALTER INDEX user_mention_pkey RENAME TO person_mention_pkey; ALTER INDEX user_mention_recipient_id_comment_id_key RENAME TO person_mention_recipient_id_comment_id_key; ALTER TABLE person_mention RENAME CONSTRAINT user_mention_comment_id_fkey TO person_mention_comment_id_fkey; ALTER TABLE person_mention RENAME CONSTRAINT user_mention_recipient_id_fkey TO person_mention_recipient_id_fkey; -- user_ban ALTER TABLE user_ban RENAME TO person_ban; ALTER SEQUENCE user_ban_id_seq RENAME TO person_ban_id_seq; ALTER INDEX user_ban_pkey RENAME TO person_ban_pkey; ALTER INDEX user_ban_user_id_key RENAME TO person_ban_person_id_key; ALTER TABLE person_ban RENAME COLUMN user_id TO person_id; ALTER TABLE person_ban RENAME CONSTRAINT user_ban_user_id_fkey TO person_ban_person_id_fkey; -- comment_like ALTER TABLE comment_like RENAME COLUMN user_id TO person_id; ALTER INDEX idx_comment_like_user RENAME TO idx_comment_like_person; ALTER TABLE comment_like RENAME CONSTRAINT comment_like_comment_id_user_id_key TO comment_like_comment_id_person_id_key; ALTER TABLE comment_like RENAME CONSTRAINT comment_like_user_id_fkey TO comment_like_person_id_fkey; -- comment_saved ALTER TABLE comment_saved RENAME COLUMN user_id TO person_id; ALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_comment_id_user_id_key TO comment_saved_comment_id_person_id_key; ALTER TABLE comment_saved RENAME CONSTRAINT comment_saved_user_id_fkey TO comment_saved_person_id_fkey; -- community_follower ALTER TABLE community_follower RENAME COLUMN user_id TO person_id; ALTER TABLE community_follower RENAME CONSTRAINT community_follower_community_id_user_id_key TO community_follower_community_id_person_id_key; ALTER TABLE community_follower RENAME CONSTRAINT community_follower_user_id_fkey TO community_follower_person_id_fkey; -- community_moderator ALTER TABLE community_moderator RENAME COLUMN user_id TO person_id; ALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_community_id_user_id_key TO community_moderator_community_id_person_id_key; ALTER TABLE community_moderator RENAME CONSTRAINT community_moderator_user_id_fkey TO community_moderator_person_id_fkey; -- community_user_ban ALTER TABLE community_user_ban RENAME TO community_person_ban; ALTER SEQUENCE community_user_ban_id_seq RENAME TO community_person_ban_id_seq; ALTER TABLE community_person_ban RENAME COLUMN user_id TO person_id; ALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_pkey TO community_person_ban_pkey; ALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_community_id_fkey TO community_person_ban_community_id_fkey; ALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_community_id_user_id_key TO community_person_ban_community_id_person_id_key; ALTER TABLE community_person_ban RENAME CONSTRAINT community_user_ban_user_id_fkey TO community_person_ban_person_id_fkey; -- mod_add ALTER TABLE mod_add RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_add RENAME COLUMN other_user_id TO other_person_id; ALTER TABLE mod_add RENAME CONSTRAINT mod_add_mod_user_id_fkey TO mod_add_mod_person_id_fkey; ALTER TABLE mod_add RENAME CONSTRAINT mod_add_other_user_id_fkey TO mod_add_other_person_id_fkey; -- mod_add_community ALTER TABLE mod_add_community RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_add_community RENAME COLUMN other_user_id TO other_person_id; ALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_mod_user_id_fkey TO mod_add_community_mod_person_id_fkey; ALTER TABLE mod_add_community RENAME CONSTRAINT mod_add_community_other_user_id_fkey TO mod_add_community_other_person_id_fkey; -- mod_ban ALTER TABLE mod_ban RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_ban RENAME COLUMN other_user_id TO other_person_id; ALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_mod_user_id_fkey TO mod_ban_mod_person_id_fkey; ALTER TABLE mod_ban RENAME CONSTRAINT mod_ban_other_user_id_fkey TO mod_ban_other_person_id_fkey; -- mod_ban_community ALTER TABLE mod_ban_from_community RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_ban_from_community RENAME COLUMN other_user_id TO other_person_id; ALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_mod_user_id_fkey TO mod_ban_from_community_mod_person_id_fkey; ALTER TABLE mod_ban_from_community RENAME CONSTRAINT mod_ban_from_community_other_user_id_fkey TO mod_ban_from_community_other_person_id_fkey; -- mod_lock_post ALTER TABLE mod_lock_post RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_lock_post RENAME CONSTRAINT mod_lock_post_mod_user_id_fkey TO mod_lock_post_mod_person_id_fkey; -- mod_remove_comment ALTER TABLE mod_remove_comment RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_remove_comment RENAME CONSTRAINT mod_remove_comment_mod_user_id_fkey TO mod_remove_comment_mod_person_id_fkey; -- mod_remove_community ALTER TABLE mod_remove_community RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_remove_community RENAME CONSTRAINT mod_remove_community_mod_user_id_fkey TO mod_remove_community_mod_person_id_fkey; -- mod_remove_post ALTER TABLE mod_remove_post RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_remove_post RENAME CONSTRAINT mod_remove_post_mod_user_id_fkey TO mod_remove_post_mod_person_id_fkey; -- mod_sticky_post ALTER TABLE mod_sticky_post RENAME COLUMN mod_user_id TO mod_person_id; ALTER TABLE mod_sticky_post RENAME CONSTRAINT mod_sticky_post_mod_user_id_fkey TO mod_sticky_post_mod_person_id_fkey; -- password_reset_request DELETE FROM password_reset_request; ALTER TABLE password_reset_request DROP COLUMN user_id; ALTER TABLE password_reset_request ADD COLUMN local_user_id integer NOT NULL REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE; -- post_like ALTER TABLE post_like RENAME COLUMN user_id TO person_id; ALTER INDEX idx_post_like_user RENAME TO idx_post_like_person; ALTER TABLE post_like RENAME CONSTRAINT post_like_post_id_user_id_key TO post_like_post_id_person_id_key; ALTER TABLE post_like RENAME CONSTRAINT post_like_user_id_fkey TO post_like_person_id_fkey; -- post_read ALTER TABLE post_read RENAME COLUMN user_id TO person_id; ALTER TABLE post_read RENAME CONSTRAINT post_read_post_id_user_id_key TO post_read_post_id_person_id_key; ALTER TABLE post_read RENAME CONSTRAINT post_read_user_id_fkey TO post_read_person_id_fkey; -- post_saved ALTER TABLE post_saved RENAME COLUMN user_id TO person_id; ALTER TABLE post_saved RENAME CONSTRAINT post_saved_post_id_user_id_key TO post_saved_post_id_person_id_key; ALTER TABLE post_saved RENAME CONSTRAINT post_saved_user_id_fkey TO post_saved_person_id_fkey; -- redo site aggregates trigger CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person u ON c.creator_id = u.id WHERE c.published > ('now'::timestamp - i::interval) AND u.local = TRUE UNION SELECT p.creator_id FROM post p INNER JOIN person u ON p.creator_id = u.id WHERE p.published > ('now'::timestamp - i::interval) AND u.local = TRUE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2021-03-19-014144_add_col_local_user_validator_time/down.sql ================================================ ALTER TABLE local_user DROP COLUMN validator_time; ================================================ FILE: migrations/2021-03-19-014144_add_col_local_user_validator_time/up.sql ================================================ ALTER TABLE local_user ADD COLUMN validator_time timestamp NOT NULL DEFAULT now(); ================================================ FILE: migrations/2021-03-20-185321_move_matrix_id_to_person/down.sql ================================================ ALTER TABLE local_user ADD COLUMN matrix_user_id text; ALTER TABLE local_user ADD COLUMN admin boolean DEFAULT FALSE NOT NULL; UPDATE local_user lu SET matrix_user_id = p.matrix_user_id, admin = p.admin FROM person p WHERE p.id = lu.person_id; DROP VIEW person_alias_1, person_alias_2; ALTER TABLE person DROP COLUMN matrix_user_id; ALTER TABLE person DROP COLUMN admin; -- Regenerate the person_alias views CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-03-20-185321_move_matrix_id_to_person/up.sql ================================================ ALTER TABLE person ADD COLUMN matrix_user_id text; ALTER TABLE person ADD COLUMN admin boolean DEFAULT FALSE NOT NULL; UPDATE person p SET matrix_user_id = lu.matrix_user_id, admin = lu.admin FROM local_user lu WHERE p.id = lu.person_id; ALTER TABLE local_user DROP COLUMN matrix_user_id; ALTER TABLE local_user DROP COLUMN admin; -- Regenerate the person_alias views DROP VIEW person_alias_1, person_alias_2; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-03-31-103917_add_show_score_setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN show_scores; ================================================ FILE: migrations/2021-03-31-103917_add_show_score_setting/up.sql ================================================ ALTER TABLE local_user ADD COLUMN show_scores boolean DEFAULT TRUE NOT NULL; ================================================ FILE: migrations/2021-03-31-105915_add_bot_account/down.sql ================================================ DROP VIEW person_alias_1, person_alias_2; ALTER TABLE person DROP COLUMN bot_account; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ALTER TABLE local_user DROP COLUMN show_bot_accounts; ================================================ FILE: migrations/2021-03-31-105915_add_bot_account/up.sql ================================================ -- Add the bot_account column to the person table DROP VIEW person_alias_1, person_alias_2; ALTER TABLE person ADD COLUMN bot_account boolean NOT NULL DEFAULT FALSE; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; -- Add the show_bot_accounts to the local user table as a setting ALTER TABLE local_user ADD COLUMN show_bot_accounts boolean NOT NULL DEFAULT TRUE; ================================================ FILE: migrations/2021-03-31-144349_add_site_short_description/down.sql ================================================ ALTER TABLE site DROP COLUMN description; ALTER TABLE site RENAME COLUMN sidebar TO description; ================================================ FILE: migrations/2021-03-31-144349_add_site_short_description/up.sql ================================================ -- Renaming description to sidebar ALTER TABLE site RENAME COLUMN description TO sidebar; -- Adding a short description column ALTER TABLE site ADD COLUMN description varchar(150); ================================================ FILE: migrations/2021-04-01-173552_rename_preferred_username_to_display_name/down.sql ================================================ ALTER TABLE person RENAME display_name TO preferred_username; -- Regenerate the person_alias views DROP VIEW person_alias_1, person_alias_2; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-04-01-173552_rename_preferred_username_to_display_name/up.sql ================================================ ALTER TABLE person RENAME preferred_username TO display_name; -- Regenerate the person_alias views DROP VIEW person_alias_1, person_alias_2; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-04-01-181826_add_community_agg_active_monthly_index/down.sql ================================================ DROP INDEX idx_community_aggregates_users_active_month; ================================================ FILE: migrations/2021-04-01-181826_add_community_agg_active_monthly_index/up.sql ================================================ CREATE INDEX idx_community_aggregates_users_active_month ON community_aggregates (users_active_month DESC); ================================================ FILE: migrations/2021-04-02-021422_remove_community_creator/down.sql ================================================ -- Add the column back ALTER TABLE community ADD COLUMN creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE; -- Recreate the index CREATE INDEX idx_community_creator ON community (creator_id); -- Add the data, selecting the highest mod UPDATE community SET creator_id = sub.person_id FROM ( SELECT cm.community_id, cm.person_id FROM community_moderator cm LIMIT 1) AS sub WHERE id = sub.community_id; -- Set to not null ALTER TABLE community ALTER COLUMN creator_id SET NOT NULL; ================================================ FILE: migrations/2021-04-02-021422_remove_community_creator/up.sql ================================================ -- Drop the column ALTER TABLE community DROP COLUMN creator_id; ================================================ FILE: migrations/2021-04-20-155001_limit-admins-create-community/down.sql ================================================ ALTER TABLE site DROP COLUMN community_creation_admin_only; ================================================ FILE: migrations/2021-04-20-155001_limit-admins-create-community/up.sql ================================================ ALTER TABLE site ADD COLUMN community_creation_admin_only bool NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2021-04-24-174047_add_show_read_post_setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN show_read_posts; ================================================ FILE: migrations/2021-04-24-174047_add_show_read_post_setting/up.sql ================================================ ALTER TABLE local_user ADD COLUMN show_read_posts boolean DEFAULT TRUE NOT NULL; ================================================ FILE: migrations/2021-07-19-130929_add_show_new_post_notifs_setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN show_new_post_notifs; ================================================ FILE: migrations/2021-07-19-130929_add_show_new_post_notifs_setting/up.sql ================================================ ALTER TABLE local_user ADD COLUMN show_new_post_notifs boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2021-07-20-102033_actor_name_length/down.sql ================================================ DROP VIEW person_alias_1; DROP VIEW person_alias_2; ALTER TABLE community ALTER COLUMN name TYPE varchar(20); ALTER TABLE community ALTER COLUMN title TYPE varchar(100); ALTER TABLE person ALTER COLUMN name TYPE varchar(20); ALTER TABLE person ALTER COLUMN display_name TYPE varchar(20); CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-07-20-102033_actor_name_length/up.sql ================================================ DROP VIEW person_alias_1; DROP VIEW person_alias_2; ALTER TABLE community ALTER COLUMN name TYPE varchar(255); ALTER TABLE community ALTER COLUMN title TYPE varchar(255); ALTER TABLE person ALTER COLUMN name TYPE varchar(255); ALTER TABLE person ALTER COLUMN display_name TYPE varchar(255); CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-08-02-002342_comment_count_fixes/down.sql ================================================ DROP TRIGGER post_aggregates_comment_set_deleted ON comment; DROP FUNCTION post_aggregates_comment_deleted; CREATE OR REPLACE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET comments = comments + 1, newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time_necro = NEW.published WHERE pa.post_id = NEW.post_id AND published > ('now'::timestamp - '2 days'::interval); ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2021-08-02-002342_comment_count_fixes/up.sql ================================================ -- Creating a new trigger for when comment.deleted is updated CREATE OR REPLACE FUNCTION post_aggregates_comment_deleted () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF NEW.deleted = TRUE THEN UPDATE post_aggregates pa SET comments = comments - 1 WHERE pa.post_id = NEW.post_id; ELSE UPDATE post_aggregates pa SET comments = comments + 1 WHERE pa.post_id = NEW.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_comment_set_deleted AFTER UPDATE OF deleted ON comment FOR EACH ROW EXECUTE PROCEDURE post_aggregates_comment_deleted (); -- Fix issue with being able to necro-bump your own post CREATE OR REPLACE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET comments = comments + 1, newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time_necro = NEW.published FROM post p WHERE pa.post_id = p.id AND pa.post_id = NEW.post_id -- Fix issue with being able to necro-bump your own post AND NEW.creator_id != p.creator_id AND pa.published > ('now'::timestamp - '2 days'::interval); ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2021-08-04-223559_create_user_community_block/down.sql ================================================ DROP TABLE person_block; DROP TABLE community_block; ================================================ FILE: migrations/2021-08-04-223559_create_user_community_block/up.sql ================================================ CREATE TABLE person_block ( id serial PRIMARY KEY, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, target_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (person_id, target_id) ); CREATE TABLE community_block ( id serial PRIMARY KEY, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (person_id, community_id) ); ================================================ FILE: migrations/2021-08-16-004209_fix_remove_bots_from_aggregates/down.sql ================================================ CREATE OR REPLACE FUNCTION community_aggregates_activity (i text) RETURNS TABLE ( count_ bigint, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*), community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id WHERE c.published > ('now'::timestamp - i::interval) UNION SELECT p.creator_id, p.community_id FROM post p WHERE p.published > ('now'::timestamp - i::interval)) a GROUP BY community_id; END; $$; CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person u ON c.creator_id = u.id WHERE c.published > ('now'::timestamp - i::interval) AND u.local = TRUE UNION SELECT p.creator_id FROM post p INNER JOIN person u ON p.creator_id = u.id WHERE p.published > ('now'::timestamp - i::interval) AND u.local = TRUE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2021-08-16-004209_fix_remove_bots_from_aggregates/up.sql ================================================ -- Make sure bots aren't included in aggregate counts CREATE OR REPLACE FUNCTION community_aggregates_activity (i text) RETURNS TABLE ( count_ bigint, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*), community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT p.creator_id, p.community_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE) a GROUP BY community_id; END; $$; CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person u ON c.creator_id = u.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND u.local = TRUE AND pe.bot_account = FALSE UNION SELECT p.creator_id FROM post p INNER JOIN person u ON p.creator_id = u.id INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND u.local = TRUE AND pe.bot_account = FALSE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2021-08-17-210508_create_mod_transfer_community/down.sql ================================================ DROP TABLE mod_transfer_community; ================================================ FILE: migrations/2021-08-17-210508_create_mod_transfer_community/up.sql ================================================ -- Add the mod_transfer_community log table CREATE TABLE mod_transfer_community ( id serial PRIMARY KEY, mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, other_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, removed boolean DEFAULT FALSE, when_ timestamp NOT NULL DEFAULT now() ); ================================================ FILE: migrations/2021-09-20-112945_jwt-secret/down.sql ================================================ DROP TABLE secret; DROP EXTENSION pgcrypto; ================================================ FILE: migrations/2021-09-20-112945_jwt-secret/up.sql ================================================ -- generate a jwt secret CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE secret ( id serial PRIMARY KEY, jwt_secret varchar NOT NULL DEFAULT gen_random_uuid () ); INSERT INTO secret DEFAULT VALUES; ================================================ FILE: migrations/2021-10-01-141650_create_admin_purge/down.sql ================================================ DROP TABLE admin_purge_person; DROP TABLE admin_purge_community; DROP TABLE admin_purge_post; DROP TABLE admin_purge_comment; ================================================ FILE: migrations/2021-10-01-141650_create_admin_purge/up.sql ================================================ -- Add the admin_purge tables CREATE TABLE admin_purge_person ( id serial PRIMARY KEY, admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE admin_purge_community ( id serial PRIMARY KEY, admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE admin_purge_post ( id serial PRIMARY KEY, admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, when_ timestamp NOT NULL DEFAULT now() ); CREATE TABLE admin_purge_comment ( id serial PRIMARY KEY, admin_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, reason text, when_ timestamp NOT NULL DEFAULT now() ); ================================================ FILE: migrations/2021-11-22-135324_add_activity_ap_id_index/down.sql ================================================ ALTER TABLE activity ALTER COLUMN ap_id DROP NOT NULL; CREATE UNIQUE INDEX idx_activity_unique_apid ON activity ((data ->> 'id'::text)); DROP INDEX idx_activity_ap_id; ================================================ FILE: migrations/2021-11-22-135324_add_activity_ap_id_index/up.sql ================================================ -- Delete the empty ap_ids DELETE FROM activity WHERE ap_id IS NULL; -- Make it required ALTER TABLE activity ALTER COLUMN ap_id SET NOT NULL; -- Delete dupes, keeping the first one DELETE FROM activity a USING ( SELECT min(id) AS id, ap_id FROM activity GROUP BY ap_id HAVING count(*) > 1) b WHERE a.ap_id = b.ap_id AND a.id <> b.id; -- The index CREATE UNIQUE INDEX idx_activity_ap_id ON activity (ap_id); -- Drop the old index DROP INDEX idx_activity_unique_apid; ================================================ FILE: migrations/2021-11-22-143904_add_required_public_key/down.sql ================================================ ALTER TABLE community ALTER COLUMN public_key DROP NOT NULL; ALTER TABLE person ALTER COLUMN public_key DROP NOT NULL; ================================================ FILE: migrations/2021-11-22-143904_add_required_public_key/up.sql ================================================ -- Delete the empty public keys DELETE FROM community WHERE public_key IS NULL; DELETE FROM person WHERE public_key IS NULL; -- Make it required ALTER TABLE community ALTER COLUMN public_key SET NOT NULL; ALTER TABLE person ALTER COLUMN public_key SET NOT NULL; ================================================ FILE: migrations/2021-11-23-031528_add_report_published_index/down.sql ================================================ DROP INDEX idx_comment_report_published; DROP INDEX idx_post_report_published; ================================================ FILE: migrations/2021-11-23-031528_add_report_published_index/up.sql ================================================ CREATE INDEX idx_comment_report_published ON comment_report (published DESC); CREATE INDEX idx_post_report_published ON post_report (published DESC); ================================================ FILE: migrations/2021-11-23-132840_email_verification/down.sql ================================================ -- revert defaults from db for local user init ALTER TABLE local_user ALTER COLUMN theme SET DEFAULT 'darkly'; ALTER TABLE local_user ALTER COLUMN default_listing_type SET DEFAULT 1; -- remove tables and columns for optional email verification ALTER TABLE site DROP COLUMN require_email_verification; ALTER TABLE local_user DROP COLUMN email_verified; DROP TABLE email_verification; ================================================ FILE: migrations/2021-11-23-132840_email_verification/up.sql ================================================ -- use defaults from db for local user init ALTER TABLE local_user ALTER COLUMN theme SET DEFAULT 'browser'; ALTER TABLE local_user ALTER COLUMN default_listing_type SET DEFAULT 2; -- add tables and columns for optional email verification ALTER TABLE site ADD COLUMN require_email_verification boolean NOT NULL DEFAULT FALSE; ALTER TABLE local_user ADD COLUMN email_verified boolean NOT NULL DEFAULT FALSE; CREATE TABLE email_verification ( id serial PRIMARY KEY, local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, email text NOT NULL, verification_token text NOT NULL ); ================================================ FILE: migrations/2021-11-23-153753_add_invite_only_columns/down.sql ================================================ -- Add columns to site table ALTER TABLE site DROP COLUMN require_application; ALTER TABLE site DROP COLUMN application_question; ALTER TABLE site DROP COLUMN private_instance; -- Add pending to local_user ALTER TABLE local_user DROP COLUMN accepted_application; DROP TABLE registration_application; ================================================ FILE: migrations/2021-11-23-153753_add_invite_only_columns/up.sql ================================================ -- Add columns to site table ALTER TABLE site ADD COLUMN require_application boolean NOT NULL DEFAULT FALSE; ALTER TABLE site ADD COLUMN application_question text; ALTER TABLE site ADD COLUMN private_instance boolean NOT NULL DEFAULT FALSE; -- Add pending to local_user ALTER TABLE local_user ADD COLUMN accepted_application boolean NOT NULL DEFAULT FALSE; CREATE TABLE registration_application ( id serial PRIMARY KEY, local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, answer text NOT NULL, admin_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, deny_reason text, published timestamp NOT NULL DEFAULT now(), UNIQUE (local_user_id) ); CREATE INDEX idx_registration_application_published ON registration_application (published DESC); ================================================ FILE: migrations/2021-12-09-225529_add_published_to_email_verification/down.sql ================================================ ALTER TABLE email_verification DROP COLUMN published; ================================================ FILE: migrations/2021-12-09-225529_add_published_to_email_verification/up.sql ================================================ ALTER TABLE email_verification ADD COLUMN published timestamp NOT NULL DEFAULT now(); ================================================ FILE: migrations/2021-12-14-181537_add_temporary_bans/down.sql ================================================ DROP VIEW person_alias_1, person_alias_2; ALTER TABLE person DROP COLUMN ban_expires; ALTER TABLE community_person_ban DROP COLUMN expires; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2021-12-14-181537_add_temporary_bans/up.sql ================================================ -- Add ban_expires to person, community_person_ban ALTER TABLE person ADD COLUMN ban_expires timestamp; ALTER TABLE community_person_ban ADD COLUMN expires timestamp; DROP VIEW person_alias_1, person_alias_2; CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2022-01-04-034553_add_hidden_column/down.sql ================================================ ALTER TABLE community DROP COLUMN hidden; DROP TABLE mod_hide_community; ================================================ FILE: migrations/2022-01-04-034553_add_hidden_column/up.sql ================================================ ALTER TABLE community ADD COLUMN hidden boolean DEFAULT FALSE; CREATE TABLE mod_hide_community ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, when_ timestamp NOT NULL DEFAULT now(), reason text, hidden boolean DEFAULT FALSE ); ================================================ FILE: migrations/2022-01-20-160328_remove_site_creator/down.sql ================================================ -- Add the column back ALTER TABLE site ADD COLUMN creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE; -- Add the data, selecting the highest admin UPDATE site SET creator_id = sub.id FROM ( SELECT id FROM person WHERE admin = TRUE LIMIT 1) AS sub; -- Set to not null ALTER TABLE site ALTER COLUMN creator_id SET NOT NULL; ================================================ FILE: migrations/2022-01-20-160328_remove_site_creator/up.sql ================================================ -- Drop the column ALTER TABLE site DROP COLUMN creator_id; ================================================ FILE: migrations/2022-01-28-104106_instance-actor/down.sql ================================================ ALTER TABLE site DROP COLUMN actor_id, DROP COLUMN last_refreshed_at, DROP COLUMN inbox_url, DROP COLUMN private_key, DROP COLUMN public_key; ================================================ FILE: migrations/2022-01-28-104106_instance-actor/up.sql ================================================ ALTER TABLE site ADD COLUMN actor_id varchar(255) NOT NULL UNIQUE DEFAULT generate_unique_changeme (), ADD COLUMN last_refreshed_at timestamp NOT NULL DEFAULT now(), ADD COLUMN inbox_url varchar(255) NOT NULL DEFAULT generate_unique_changeme (), ADD COLUMN private_key text, ADD COLUMN public_key text NOT NULL DEFAULT generate_unique_changeme (); ================================================ FILE: migrations/2022-02-01-154240_add_community_title_index/down.sql ================================================ DROP INDEX idx_community_title; ================================================ FILE: migrations/2022-02-01-154240_add_community_title_index/up.sql ================================================ CREATE INDEX idx_community_title ON community (title); ================================================ FILE: migrations/2022-02-18-210946_default_theme/down.sql ================================================ ALTER TABLE site DROP COLUMN default_theme; ================================================ FILE: migrations/2022-02-18-210946_default_theme/up.sql ================================================ ALTER TABLE site ADD COLUMN default_theme text NOT NULL DEFAULT 'browser'; ================================================ FILE: migrations/2022-04-04-183652_update_community_aggregates_on_soft_delete/down.sql ================================================ DROP TRIGGER IF EXISTS community_aggregates_post_count ON post; DROP TRIGGER IF EXISTS community_aggregates_comment_count ON comment; DROP TRIGGER IF EXISTS site_aggregates_comment_insert ON comment; DROP TRIGGER IF EXISTS site_aggregates_comment_delete ON comment; DROP TRIGGER IF EXISTS site_aggregates_post_insert ON post; DROP TRIGGER IF EXISTS site_aggregates_post_delete ON post; DROP TRIGGER IF EXISTS site_aggregates_community_insert ON community; DROP TRIGGER IF EXISTS site_aggregates_community_delete ON community; DROP TRIGGER IF EXISTS person_aggregates_post_count ON post; DROP TRIGGER IF EXISTS person_aggregates_comment_count ON comment; DROP FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record); DROP FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record); -- Community aggregate functions CREATE OR REPLACE FUNCTION community_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates SET posts = posts + 1 WHERE community_id = NEW.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates SET posts = posts - 1 WHERE community_id = OLD.community_id; -- Update the counts if the post got deleted UPDATE community_aggregates ca SET posts = coalesce(cd.posts, 0), comments = coalesce(cd.comments, 0) FROM ( SELECT c.id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM community c LEFT JOIN post p ON c.id = p.community_id LEFT JOIN comment ct ON p.id = ct.post_id GROUP BY c.id) cd WHERE ca.community_id = OLD.community_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION community_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates ca SET comments = comments + 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = NEW.post_id AND ca.community_id = p.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates ca SET comments = comments - 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = OLD.post_id AND ca.community_id = p.community_id; END IF; RETURN NULL; END $$; -- Community aggregate triggers CREATE TRIGGER community_aggregates_post_count AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE community_aggregates_post_count (); CREATE TRIGGER community_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE community_aggregates_comment_count (); -- Site aggregate functions CREATE OR REPLACE FUNCTION site_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET posts = posts + 1; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_post_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET posts = posts - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_comment_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET comments = comments + 1; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_comment_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET comments = comments - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_community_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET communities = communities + 1; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_community_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET communities = communities - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; -- Site update triggers CREATE TRIGGER site_aggregates_post_insert AFTER INSERT ON post FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_insert (); CREATE TRIGGER site_aggregates_post_delete AFTER DELETE ON post FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_delete (); CREATE TRIGGER site_aggregates_comment_insert AFTER INSERT ON comment FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_comment_insert (); CREATE TRIGGER site_aggregates_comment_delete AFTER DELETE ON comment FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_comment_delete (); CREATE TRIGGER site_aggregates_community_insert AFTER INSERT ON community FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_insert (); CREATE TRIGGER site_aggregates_community_delete AFTER DELETE ON community FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_delete (); -- Person aggregate functions CREATE OR REPLACE FUNCTION person_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE person_aggregates SET post_count = post_count + 1 WHERE person_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates SET post_count = post_count - 1 WHERE person_id = OLD.creator_id; -- If the post gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET post_score = pd.score FROM ( SELECT u.id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM person u LEFT JOIN post p ON u.id = p.creator_id LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION person_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE person_aggregates SET comment_count = comment_count + 1 WHERE person_id = NEW.creator_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates SET comment_count = comment_count - 1 WHERE person_id = OLD.creator_id; -- If the comment gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM person u LEFT JOIN comment c ON u.id = c.creator_id LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; -- Person aggregate triggers CREATE TRIGGER person_aggregates_post_count AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE person_aggregates_post_count (); CREATE TRIGGER person_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE person_aggregates_comment_count (); ================================================ FILE: migrations/2022-04-04-183652_update_community_aggregates_on_soft_delete/up.sql ================================================ DROP TRIGGER IF EXISTS community_aggregates_post_count ON post; DROP TRIGGER IF EXISTS community_aggregates_comment_count ON comment; DROP TRIGGER IF EXISTS site_aggregates_comment_insert ON comment; DROP TRIGGER IF EXISTS site_aggregates_comment_delete ON comment; DROP TRIGGER IF EXISTS site_aggregates_post_insert ON post; DROP TRIGGER IF EXISTS site_aggregates_post_delete ON post; DROP TRIGGER IF EXISTS site_aggregates_community_insert ON community; DROP TRIGGER IF EXISTS site_aggregates_community_delete ON community; DROP TRIGGER IF EXISTS person_aggregates_post_count ON post; DROP TRIGGER IF EXISTS person_aggregates_comment_count ON comment; CREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN RETURN FALSE; END IF; IF (TG_OP = 'DELETE') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND ((OLD.deleted = 'f' AND NEW.deleted = 't') OR (OLD.removed = 'f' AND NEW.removed = 't')); END $$; CREATE OR REPLACE FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN RETURN FALSE; END IF; IF (TG_OP = 'INSERT') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND ((OLD.deleted = 't' AND NEW.deleted = 'f') OR (OLD.removed = 't' AND NEW.removed = 'f')); END $$; -- Community aggregate functions CREATE OR REPLACE FUNCTION community_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates SET posts = posts + 1 WHERE community_id = NEW.community_id; IF (TG_OP = 'UPDATE') THEN -- Post was restored, so restore comment counts as well UPDATE community_aggregates ca SET posts = coalesce(cd.posts, 0), comments = coalesce(cd.comments, 0) FROM ( SELECT c.id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM community c LEFT JOIN post p ON c.id = p.community_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN comment ct ON p.id = ct.post_id AND ct.deleted = 'f' AND ct.removed = 'f' WHERE c.id = NEW.community_id GROUP BY c.id) cd WHERE ca.community_id = NEW.community_id; END IF; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates SET posts = posts - 1 WHERE community_id = OLD.community_id; -- Update the counts if the post got deleted UPDATE community_aggregates ca SET posts = coalesce(cd.posts, 0), comments = coalesce(cd.comments, 0) FROM ( SELECT c.id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM community c LEFT JOIN post p ON c.id = p.community_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN comment ct ON p.id = ct.post_id AND ct.deleted = 'f' AND ct.removed = 'f' WHERE c.id = OLD.community_id GROUP BY c.id) cd WHERE ca.community_id = OLD.community_id; END IF; RETURN NULL; END $$; -- comment count CREATE OR REPLACE FUNCTION community_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments + 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = NEW.post_id AND ca.community_id = p.community_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments - 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = OLD.post_id AND ca.community_id = p.community_id; END IF; RETURN NULL; END $$; -- Community aggregate triggers CREATE TRIGGER community_aggregates_post_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE PROCEDURE community_aggregates_post_count (); CREATE TRIGGER community_aggregates_comment_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW EXECUTE PROCEDURE community_aggregates_comment_count (); -- Site aggregate functions CREATE OR REPLACE FUNCTION site_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET posts = posts + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_post_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET posts = posts - 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_comment_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET comments = comments + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_comment_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET comments = comments - 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_community_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET communities = communities + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_community_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET communities = communities - 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; -- Site aggregate triggers CREATE TRIGGER site_aggregates_post_insert AFTER INSERT OR UPDATE OF removed, deleted ON post FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_insert (); CREATE TRIGGER site_aggregates_post_delete AFTER DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_delete (); CREATE TRIGGER site_aggregates_comment_insert AFTER INSERT OR UPDATE OF removed, deleted ON comment FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_comment_insert (); CREATE TRIGGER site_aggregates_comment_delete AFTER DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_comment_delete (); CREATE TRIGGER site_aggregates_community_insert AFTER INSERT OR UPDATE OF removed, deleted ON community FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_insert (); CREATE TRIGGER site_aggregates_community_delete AFTER DELETE OR UPDATE OF removed, deleted ON community FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_delete (); -- Person aggregate functions CREATE OR REPLACE FUNCTION person_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count - 1 WHERE person_id = OLD.creator_id; -- If the post gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET post_score = pd.score FROM ( SELECT u.id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM person u LEFT JOIN post p ON u.id = p.creator_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION person_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count - 1 WHERE person_id = OLD.creator_id; -- If the comment gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM person u LEFT JOIN comment c ON u.id = c.creator_id AND c.deleted = 'f' AND c.removed = 'f' LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; -- Person aggregate triggers CREATE TRIGGER person_aggregates_post_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE PROCEDURE person_aggregates_post_count (); CREATE TRIGGER person_aggregates_comment_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW EXECUTE PROCEDURE person_aggregates_comment_count (); ================================================ FILE: migrations/2022-04-11-210137_fix_unique_changeme/down.sql ================================================ CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'http://changeme_' || string_agg(substr('abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789', ceil(random() * 62)::integer, 1), '') FROM generate_series(1, 20) $$; ================================================ FILE: migrations/2022-04-11-210137_fix_unique_changeme/up.sql ================================================ CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'http://changeme.invalid/' || substr(md5(random()::text), 0, 25); $$; ================================================ FILE: migrations/2022-04-12-114352_default_post_listing_type/down.sql ================================================ ALTER TABLE site DROP COLUMN default_post_listing_type; ================================================ FILE: migrations/2022-04-12-114352_default_post_listing_type/up.sql ================================================ ALTER TABLE site ADD COLUMN default_post_listing_type text NOT NULL DEFAULT 'Local'; ================================================ FILE: migrations/2022-04-12-185205_change_default_listing_type_to_local/down.sql ================================================ -- 0 is All, 1 is Local, 2 is Subscribed ALTER TABLE ONLY local_user ALTER COLUMN default_listing_type SET DEFAULT 2; ================================================ FILE: migrations/2022-04-12-185205_change_default_listing_type_to_local/up.sql ================================================ -- 0 is All, 1 is Local, 2 is Subscribed ALTER TABLE ONLY local_user ALTER COLUMN default_listing_type SET DEFAULT 1; ================================================ FILE: migrations/2022-04-19-111004_default_require_application/down.sql ================================================ ALTER TABLE site ALTER COLUMN require_application SET DEFAULT FALSE; ALTER TABLE site ALTER COLUMN application_question SET DEFAULT NULL; ================================================ FILE: migrations/2022-04-19-111004_default_require_application/up.sql ================================================ ALTER TABLE site ALTER COLUMN require_application SET DEFAULT TRUE; ALTER TABLE site ALTER COLUMN application_question SET DEFAULT 'To verify that you are human, please explain why you want to create an account on this site'; ================================================ FILE: migrations/2022-04-26-105145_only_mod_can_post/down.sql ================================================ ALTER TABLE community DROP COLUMN posting_restricted_to_mods; ================================================ FILE: migrations/2022-04-26-105145_only_mod_can_post/up.sql ================================================ ALTER TABLE community ADD COLUMN posting_restricted_to_mods boolean DEFAULT FALSE; ================================================ FILE: migrations/2022-05-19-153931_legal-information/down.sql ================================================ ALTER TABLE site DROP COLUMN legal_information; ================================================ FILE: migrations/2022-05-19-153931_legal-information/up.sql ================================================ ALTER TABLE site ADD COLUMN legal_information text; ================================================ FILE: migrations/2022-05-20-135341_embed-url/down.sql ================================================ ALTER TABLE post DROP COLUMN embed_video_url; ALTER TABLE post ADD COLUMN embed_html text; ================================================ FILE: migrations/2022-05-20-135341_embed-url/up.sql ================================================ ALTER TABLE post DROP COLUMN embed_html; ALTER TABLE post ADD COLUMN embed_video_url text; ================================================ FILE: migrations/2022-06-12-012121_add_site_hide_modlog_names/down.sql ================================================ ALTER TABLE site DROP COLUMN hide_modlog_mod_names; ================================================ FILE: migrations/2022-06-12-012121_add_site_hide_modlog_names/up.sql ================================================ ALTER TABLE site ADD COLUMN hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL; ================================================ FILE: migrations/2022-06-13-124806_post_report_name_length/down.sql ================================================ ALTER TABLE post_report ALTER COLUMN original_post_name TYPE varchar(100); ================================================ FILE: migrations/2022-06-13-124806_post_report_name_length/up.sql ================================================ -- adjust length limit to match post.name ALTER TABLE post_report ALTER COLUMN original_post_name TYPE varchar(200); ================================================ FILE: migrations/2022-06-21-123144_language-tags/down.sql ================================================ ALTER TABLE post DROP COLUMN language_id; DROP TABLE local_user_language; DROP TABLE LANGUAGE; ALTER TABLE local_user RENAME COLUMN interface_language TO lang; ================================================ FILE: migrations/2022-06-21-123144_language-tags/up.sql ================================================ CREATE TABLE LANGUAGE ( id serial PRIMARY KEY, code varchar(3), name text ); CREATE TABLE local_user_language ( id serial PRIMARY KEY, local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, language_id int REFERENCES LANGUAGE ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, UNIQUE (local_user_id, language_id) ); ALTER TABLE local_user RENAME COLUMN lang TO interface_language; INSERT INTO LANGUAGE (id, code, name) VALUES (0, 'und', 'Undetermined'); ALTER TABLE post ADD COLUMN language_id integer REFERENCES LANGUAGE NOT NULL DEFAULT 0; INSERT INTO LANGUAGE (code, name) VALUES ('aa', 'Afaraf'); INSERT INTO LANGUAGE (code, name) VALUES ('ab', 'аҧсуа бызшәа'); INSERT INTO LANGUAGE (code, name) VALUES ('ae', 'avesta'); INSERT INTO LANGUAGE (code, name) VALUES ('af', 'Afrikaans'); INSERT INTO LANGUAGE (code, name) VALUES ('ak', 'Akan'); INSERT INTO LANGUAGE (code, name) VALUES ('am', 'አማርኛ'); INSERT INTO LANGUAGE (code, name) VALUES ('an', 'aragonés'); INSERT INTO LANGUAGE (code, name) VALUES ('ar', 'اَلْعَرَبِيَّةُ'); INSERT INTO LANGUAGE (code, name) VALUES ('as', 'অসমীয়া'); INSERT INTO LANGUAGE (code, name) VALUES ('av', 'авар мацӀ'); INSERT INTO LANGUAGE (code, name) VALUES ('ay', 'aymar aru'); INSERT INTO LANGUAGE (code, name) VALUES ('az', 'azərbaycan dili'); INSERT INTO LANGUAGE (code, name) VALUES ('ba', 'башҡорт теле'); INSERT INTO LANGUAGE (code, name) VALUES ('be', 'беларуская мова'); INSERT INTO LANGUAGE (code, name) VALUES ('bg', 'български език'); INSERT INTO LANGUAGE (code, name) VALUES ('bi', 'Bislama'); INSERT INTO LANGUAGE (code, name) VALUES ('bm', 'bamanankan'); INSERT INTO LANGUAGE (code, name) VALUES ('bn', 'বাংলা'); INSERT INTO LANGUAGE (code, name) VALUES ('bo', 'བོད་ཡིག'); INSERT INTO LANGUAGE (code, name) VALUES ('br', 'brezhoneg'); INSERT INTO LANGUAGE (code, name) VALUES ('bs', 'bosanski jezik'); INSERT INTO LANGUAGE (code, name) VALUES ('ca', 'Català'); INSERT INTO LANGUAGE (code, name) VALUES ('ce', 'нохчийн мотт'); INSERT INTO LANGUAGE (code, name) VALUES ('ch', 'Chamoru'); INSERT INTO LANGUAGE (code, name) VALUES ('co', 'corsu'); INSERT INTO LANGUAGE (code, name) VALUES ('cr', 'ᓀᐦᐃᔭᐍᐏᐣ'); INSERT INTO LANGUAGE (code, name) VALUES ('cs', 'čeština'); INSERT INTO LANGUAGE (code, name) VALUES ('cu', 'ѩзыкъ словѣньскъ'); INSERT INTO LANGUAGE (code, name) VALUES ('cv', 'чӑваш чӗлхи'); INSERT INTO LANGUAGE (code, name) VALUES ('cy', 'Cymraeg'); INSERT INTO LANGUAGE (code, name) VALUES ('da', 'dansk'); INSERT INTO LANGUAGE (code, name) VALUES ('de', 'Deutsch'); INSERT INTO LANGUAGE (code, name) VALUES ('dv', 'ދިވެހި'); INSERT INTO LANGUAGE (code, name) VALUES ('dz', 'རྫོང་ཁ'); INSERT INTO LANGUAGE (code, name) VALUES ('ee', 'Eʋegbe'); INSERT INTO LANGUAGE (code, name) VALUES ('el', 'Ελληνικά'); INSERT INTO LANGUAGE (code, name) VALUES ('en', 'English'); INSERT INTO LANGUAGE (code, name) VALUES ('eo', 'Esperanto'); INSERT INTO LANGUAGE (code, name) VALUES ('es', 'Español'); INSERT INTO LANGUAGE (code, name) VALUES ('et', 'eesti'); INSERT INTO LANGUAGE (code, name) VALUES ('eu', 'euskara'); INSERT INTO LANGUAGE (code, name) VALUES ('fa', 'فارسی'); INSERT INTO LANGUAGE (code, name) VALUES ('ff', 'Fulfulde'); INSERT INTO LANGUAGE (code, name) VALUES ('fi', 'suomi'); INSERT INTO LANGUAGE (code, name) VALUES ('fj', 'vosa Vakaviti'); INSERT INTO LANGUAGE (code, name) VALUES ('fo', 'føroyskt'); INSERT INTO LANGUAGE (code, name) VALUES ('fr', 'Français'); INSERT INTO LANGUAGE (code, name) VALUES ('fy', 'Frysk'); INSERT INTO LANGUAGE (code, name) VALUES ('ga', 'Gaeilge'); INSERT INTO LANGUAGE (code, name) VALUES ('gd', 'Gàidhlig'); INSERT INTO LANGUAGE (code, name) VALUES ('gl', 'galego'); INSERT INTO LANGUAGE (code, name) VALUES ('gn', E'Avañe\'ẽ'); INSERT INTO LANGUAGE (code, name) VALUES ('gu', 'ગુજરાતી'); INSERT INTO LANGUAGE (code, name) VALUES ('gv', 'Gaelg'); INSERT INTO LANGUAGE (code, name) VALUES ('ha', 'هَوُسَ'); INSERT INTO LANGUAGE (code, name) VALUES ('he', 'עברית'); INSERT INTO LANGUAGE (code, name) VALUES ('hi', 'हिन्दी'); INSERT INTO LANGUAGE (code, name) VALUES ('ho', 'Hiri Motu'); INSERT INTO LANGUAGE (code, name) VALUES ('hr', 'Hrvatski'); INSERT INTO LANGUAGE (code, name) VALUES ('ht', 'Kreyòl ayisyen'); INSERT INTO LANGUAGE (code, name) VALUES ('hu', 'magyar'); INSERT INTO LANGUAGE (code, name) VALUES ('hy', 'Հայերեն'); INSERT INTO LANGUAGE (code, name) VALUES ('hz', 'Otjiherero'); INSERT INTO LANGUAGE (code, name) VALUES ('ia', 'Interlingua'); INSERT INTO LANGUAGE (code, name) VALUES ('id', 'Bahasa Indonesia'); INSERT INTO LANGUAGE (code, name) VALUES ('ie', 'Interlingue'); INSERT INTO LANGUAGE (code, name) VALUES ('ig', 'Asụsụ Igbo'); INSERT INTO LANGUAGE (code, name) VALUES ('ii', 'ꆈꌠ꒿ Nuosuhxop'); INSERT INTO LANGUAGE (code, name) VALUES ('ik', 'Iñupiaq'); INSERT INTO LANGUAGE (code, name) VALUES ('io', 'Ido'); INSERT INTO LANGUAGE (code, name) VALUES ('is', 'Íslenska'); INSERT INTO LANGUAGE (code, name) VALUES ('it', 'Italiano'); INSERT INTO LANGUAGE (code, name) VALUES ('iu', 'ᐃᓄᒃᑎᑐᑦ'); INSERT INTO LANGUAGE (code, name) VALUES ('ja', '日本語'); INSERT INTO LANGUAGE (code, name) VALUES ('jv', 'basa Jawa'); INSERT INTO LANGUAGE (code, name) VALUES ('ka', 'ქართული'); INSERT INTO LANGUAGE (code, name) VALUES ('kg', 'Kikongo'); INSERT INTO LANGUAGE (code, name) VALUES ('ki', 'Gĩkũyũ'); INSERT INTO LANGUAGE (code, name) VALUES ('kj', 'Kuanyama'); INSERT INTO LANGUAGE (code, name) VALUES ('kk', 'қазақ тілі'); INSERT INTO LANGUAGE (code, name) VALUES ('kl', 'kalaallisut'); INSERT INTO LANGUAGE (code, name) VALUES ('km', 'ខេមរភាសា'); INSERT INTO LANGUAGE (code, name) VALUES ('kn', 'ಕನ್ನಡ'); INSERT INTO LANGUAGE (code, name) VALUES ('ko', '한국어'); INSERT INTO LANGUAGE (code, name) VALUES ('kr', 'Kanuri'); INSERT INTO LANGUAGE (code, name) VALUES ('ks', 'कश्मीरी'); INSERT INTO LANGUAGE (code, name) VALUES ('ku', 'Kurdî'); INSERT INTO LANGUAGE (code, name) VALUES ('kv', 'коми кыв'); INSERT INTO LANGUAGE (code, name) VALUES ('kw', 'Kernewek'); INSERT INTO LANGUAGE (code, name) VALUES ('ky', 'Кыргызча'); INSERT INTO LANGUAGE (code, name) VALUES ('la', 'latine'); INSERT INTO LANGUAGE (code, name) VALUES ('lb', 'Lëtzebuergesch'); INSERT INTO LANGUAGE (code, name) VALUES ('lg', 'Luganda'); INSERT INTO LANGUAGE (code, name) VALUES ('li', 'Limburgs'); INSERT INTO LANGUAGE (code, name) VALUES ('ln', 'Lingála'); INSERT INTO LANGUAGE (code, name) VALUES ('lo', 'ພາສາລາວ'); INSERT INTO LANGUAGE (code, name) VALUES ('lt', 'lietuvių kalba'); INSERT INTO LANGUAGE (code, name) VALUES ('lu', 'Kiluba'); INSERT INTO LANGUAGE (code, name) VALUES ('lv', 'latviešu valoda'); INSERT INTO LANGUAGE (code, name) VALUES ('mg', 'fiteny malagasy'); INSERT INTO LANGUAGE (code, name) VALUES ('mh', 'Kajin M̧ajeļ'); INSERT INTO LANGUAGE (code, name) VALUES ('mi', 'te reo Māori'); INSERT INTO LANGUAGE (code, name) VALUES ('mk', 'македонски јазик'); INSERT INTO LANGUAGE (code, name) VALUES ('ml', 'മലയാളം'); INSERT INTO LANGUAGE (code, name) VALUES ('mn', 'Монгол хэл'); INSERT INTO LANGUAGE (code, name) VALUES ('mr', 'मराठी'); INSERT INTO LANGUAGE (code, name) VALUES ('ms', 'Bahasa Melayu'); INSERT INTO LANGUAGE (code, name) VALUES ('mt', 'Malti'); INSERT INTO LANGUAGE (code, name) VALUES ('my', 'ဗမာစာ'); INSERT INTO LANGUAGE (code, name) VALUES ('na', 'Dorerin Naoero'); INSERT INTO LANGUAGE (code, name) VALUES ('nb', 'Norsk bokmål'); INSERT INTO LANGUAGE (code, name) VALUES ('nd', 'isiNdebele'); INSERT INTO LANGUAGE (code, name) VALUES ('ne', 'नेपाली'); INSERT INTO LANGUAGE (code, name) VALUES ('ng', 'Owambo'); INSERT INTO LANGUAGE (code, name) VALUES ('nl', 'Nederlands'); INSERT INTO LANGUAGE (code, name) VALUES ('nn', 'Norsk nynorsk'); INSERT INTO LANGUAGE (code, name) VALUES ('no', 'Norsk'); INSERT INTO LANGUAGE (code, name) VALUES ('nr', 'isiNdebele'); INSERT INTO LANGUAGE (code, name) VALUES ('nv', 'Diné bizaad'); INSERT INTO LANGUAGE (code, name) VALUES ('ny', 'chiCheŵa'); INSERT INTO LANGUAGE (code, name) VALUES ('oc', 'occitan'); INSERT INTO LANGUAGE (code, name) VALUES ('oj', 'ᐊᓂᔑᓈᐯᒧᐎᓐ'); INSERT INTO LANGUAGE (code, name) VALUES ('om', 'Afaan Oromoo'); INSERT INTO LANGUAGE (code, name) VALUES ('or', 'ଓଡ଼ିଆ'); INSERT INTO LANGUAGE (code, name) VALUES ('os', 'ирон æвзаг'); INSERT INTO LANGUAGE (code, name) VALUES ('pa', 'ਪੰਜਾਬੀ'); INSERT INTO LANGUAGE (code, name) VALUES ('pi', 'पाऴि'); INSERT INTO LANGUAGE (code, name) VALUES ('pl', 'Polski'); INSERT INTO LANGUAGE (code, name) VALUES ('ps', 'پښتو'); INSERT INTO LANGUAGE (code, name) VALUES ('pt', 'Português'); INSERT INTO LANGUAGE (code, name) VALUES ('qu', 'Runa Simi'); INSERT INTO LANGUAGE (code, name) VALUES ('rm', 'rumantsch grischun'); INSERT INTO LANGUAGE (code, name) VALUES ('rn', 'Ikirundi'); INSERT INTO LANGUAGE (code, name) VALUES ('ro', 'Română'); INSERT INTO LANGUAGE (code, name) VALUES ('ru', 'Русский'); INSERT INTO LANGUAGE (code, name) VALUES ('rw', 'Ikinyarwanda'); INSERT INTO LANGUAGE (code, name) VALUES ('sa', 'संस्कृतम्'); INSERT INTO LANGUAGE (code, name) VALUES ('sc', 'sardu'); INSERT INTO LANGUAGE (code, name) VALUES ('sd', 'सिन्धी'); INSERT INTO LANGUAGE (code, name) VALUES ('se', 'Davvisámegiella'); INSERT INTO LANGUAGE (code, name) VALUES ('sg', 'yângâ tî sängö'); INSERT INTO LANGUAGE (code, name) VALUES ('si', 'සිංහල'); INSERT INTO LANGUAGE (code, name) VALUES ('sk', 'slovenčina'); INSERT INTO LANGUAGE (code, name) VALUES ('sl', 'slovenščina'); INSERT INTO LANGUAGE (code, name) VALUES ('sm', E'gagana fa\'a Samoa'); INSERT INTO LANGUAGE (code, name) VALUES ('sn', 'chiShona'); INSERT INTO LANGUAGE (code, name) VALUES ('so', 'Soomaaliga'); INSERT INTO LANGUAGE (code, name) VALUES ('sq', 'Shqip'); INSERT INTO LANGUAGE (code, name) VALUES ('sr', 'српски језик'); INSERT INTO LANGUAGE (code, name) VALUES ('ss', 'SiSwati'); INSERT INTO LANGUAGE (code, name) VALUES ('st', 'Sesotho'); INSERT INTO LANGUAGE (code, name) VALUES ('su', 'Basa Sunda'); INSERT INTO LANGUAGE (code, name) VALUES ('sv', 'Svenska'); INSERT INTO LANGUAGE (code, name) VALUES ('sw', 'Kiswahili'); INSERT INTO LANGUAGE (code, name) VALUES ('ta', 'தமிழ்'); INSERT INTO LANGUAGE (code, name) VALUES ('te', 'తెలుగు'); INSERT INTO LANGUAGE (code, name) VALUES ('tg', 'тоҷикӣ'); INSERT INTO LANGUAGE (code, name) VALUES ('th', 'ไทย'); INSERT INTO LANGUAGE (code, name) VALUES ('ti', 'ትግርኛ'); INSERT INTO LANGUAGE (code, name) VALUES ('tk', 'Türkmençe'); INSERT INTO LANGUAGE (code, name) VALUES ('tl', 'Wikang Tagalog'); INSERT INTO LANGUAGE (code, name) VALUES ('tn', 'Setswana'); INSERT INTO LANGUAGE (code, name) VALUES ('to', 'faka Tonga'); INSERT INTO LANGUAGE (code, name) VALUES ('tr', 'Türkçe'); INSERT INTO LANGUAGE (code, name) VALUES ('ts', 'Xitsonga'); INSERT INTO LANGUAGE (code, name) VALUES ('tt', 'татар теле'); INSERT INTO LANGUAGE (code, name) VALUES ('tw', 'Twi'); INSERT INTO LANGUAGE (code, name) VALUES ('ty', 'Reo Tahiti'); INSERT INTO LANGUAGE (code, name) VALUES ('ug', 'ئۇيغۇرچە‎'); INSERT INTO LANGUAGE (code, name) VALUES ('uk', 'Українська'); INSERT INTO LANGUAGE (code, name) VALUES ('ur', 'اردو'); INSERT INTO LANGUAGE (code, name) VALUES ('uz', 'Ўзбек'); INSERT INTO LANGUAGE (code, name) VALUES ('ve', 'Tshivenḓa'); INSERT INTO LANGUAGE (code, name) VALUES ('vi', 'Tiếng Việt'); INSERT INTO LANGUAGE (code, name) VALUES ('vo', 'Volapük'); INSERT INTO LANGUAGE (code, name) VALUES ('wa', 'walon'); INSERT INTO LANGUAGE (code, name) VALUES ('wo', 'Wollof'); INSERT INTO LANGUAGE (code, name) VALUES ('xh', 'isiXhosa'); INSERT INTO LANGUAGE (code, name) VALUES ('yi', 'ייִדיש'); INSERT INTO LANGUAGE (code, name) VALUES ('yo', 'Yorùbá'); INSERT INTO LANGUAGE (code, name) VALUES ('za', 'Saɯ cueŋƅ'); INSERT INTO LANGUAGE (code, name) VALUES ('zh', '中文'); INSERT INTO LANGUAGE (code, name) VALUES ('zu', 'isiZulu'); ================================================ FILE: migrations/2022-07-07-182650_comment_ltrees/down.sql ================================================ ALTER TABLE comment ADD COLUMN parent_id integer; -- Constraints and index ALTER TABLE comment ADD CONSTRAINT comment_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE; CREATE INDEX idx_comment_parent ON comment (parent_id); -- Update the parent_id column -- subpath(subpath(0, -1), -1) gets the immediate parent but it fails null checks UPDATE comment SET parent_id = cast(ltree2text (nullif (subpath (nullif (subpath (path, 0, -1), '0'), -1), '0')) AS INTEGER); ALTER TABLE comment DROP COLUMN path; ALTER TABLE comment_aggregates DROP COLUMN child_count; DROP EXTENSION ltree; -- Add back in the read column ALTER TABLE comment ADD COLUMN read boolean DEFAULT FALSE NOT NULL; UPDATE comment c SET read = cr.read FROM comment_reply cr WHERE cr.comment_id = c.id; CREATE VIEW comment_alias_1 AS SELECT * FROM comment; DROP TABLE comment_reply; ================================================ FILE: migrations/2022-07-07-182650_comment_ltrees/up.sql ================================================ -- Remove the comment.read column, and create a new comment_reply table, -- similar to the person_mention table. -- -- This is necessary because self-joins using ltrees would be too tough with SQL views -- -- Every comment should have a row here, because all comments have a recipient, -- either the post creator, or the parent commenter. CREATE TABLE comment_reply ( id serial PRIMARY KEY, recipient_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read boolean DEFAULT FALSE NOT NULL, published timestamp NOT NULL DEFAULT now(), UNIQUE (recipient_id, comment_id) ); -- Ones where parent_id is null, use the post creator recipient INSERT INTO comment_reply (recipient_id, comment_id, read) SELECT p.creator_id, c.id, c.read FROM comment c INNER JOIN post p ON c.post_id = p.id WHERE c.parent_id IS NULL; -- Ones where there is a parent_id, self join to comment to get the parent comment creator INSERT INTO comment_reply (recipient_id, comment_id, read) SELECT c2.creator_id, c.id, c.read FROM comment c INNER JOIN comment c2 ON c.parent_id = c2.id; -- Drop comment_alias view DROP VIEW comment_alias_1; ALTER TABLE comment DROP COLUMN read; CREATE EXTENSION IF NOT EXISTS ltree; ALTER TABLE comment ADD COLUMN path ltree NOT NULL DEFAULT '0'; ALTER TABLE comment_aggregates ADD COLUMN child_count integer NOT NULL DEFAULT 0; -- The ltree path column should be the comment_id parent paths, separated by dots. -- Stackoverflow: building an ltree from a parent_id hierarchical tree: -- https://stackoverflow.com/a/1144848/1655478 CREATE TEMPORARY TABLE comment_temp AS WITH RECURSIVE q AS ( SELECT h, 1 AS level, ARRAY[id] AS breadcrumb FROM comment h WHERE parent_id IS NULL UNION ALL SELECT hi, q.level + 1 AS level, breadcrumb || id FROM q JOIN comment hi ON hi.parent_id = (q.h).id ) SELECT (q.h).id, (q.h).parent_id, level, breadcrumb::varchar AS path, text2ltree ('0.' || array_to_string(breadcrumb, '.')) AS ltree_path FROM q ORDER BY breadcrumb; -- Remove indexes and foreign key constraints, and disable triggers for faster updates ALTER TABLE comment DISABLE TRIGGER USER; ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_creator_id_fkey; ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_parent_id_fkey; ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_post_id_fkey; ALTER TABLE comment DROP CONSTRAINT IF EXISTS idx_comment_ap_id; DROP INDEX IF EXISTS idx_comment_creator; DROP INDEX IF EXISTS idx_comment_parent; DROP INDEX IF EXISTS idx_comment_post; DROP INDEX IF EXISTS idx_comment_published; -- Add the ltree column UPDATE comment c SET path = ct.ltree_path FROM comment_temp ct WHERE c.id = ct.id; -- Without this, `DROP EXTENSION` in down.sql throws an object dependency error if up.sql and down.sql -- are run in the same database connection DROP TABLE comment_temp; -- Update the child counts UPDATE comment_aggregates ca SET child_count = c2.child_count FROM ( SELECT c.id, c.path, count(c2.id) AS child_count FROM comment c LEFT JOIN comment c2 ON c2.path <@ c.path AND c2.path != c.path GROUP BY c.id) AS c2 WHERE ca.comment_id = c2.id; -- Delete comments at a depth of > 150, otherwise the index creation below will fail DELETE FROM comment WHERE nlevel (path) > 150; -- Delete from comment where there is a missing post DELETE FROM comment c WHERE NOT EXISTS ( SELECT FROM post p WHERE p.id = c.post_id); -- Delete from comment where there is a missing creator_id DELETE FROM comment c WHERE NOT EXISTS ( SELECT FROM person p WHERE p.id = c.creator_id); -- Re-enable old constraints and indexes ALTER TABLE comment ADD CONSTRAINT "comment_creator_id_fkey" FOREIGN KEY (creator_id) REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE comment ADD CONSTRAINT "comment_post_id_fkey" FOREIGN KEY (post_id) REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE comment ADD CONSTRAINT "idx_comment_ap_id" UNIQUE (ap_id); CREATE INDEX idx_comment_creator ON comment (creator_id); CREATE INDEX idx_comment_post ON comment (post_id); CREATE INDEX idx_comment_published ON comment (published DESC); -- Create the index CREATE INDEX idx_path_gist ON comment USING gist (path); -- Drop the parent_id column ALTER TABLE comment DROP COLUMN parent_id CASCADE; ALTER TABLE comment ENABLE TRIGGER USER; ================================================ FILE: migrations/2022-08-04-150644_add_application_email_admins/down.sql ================================================ ALTER TABLE site DROP COLUMN application_email_admins; ================================================ FILE: migrations/2022-08-04-150644_add_application_email_admins/up.sql ================================================ -- Adding a field to email admins for new applications ALTER TABLE site ADD COLUMN application_email_admins boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2022-08-04-214722_add_distinguished_comment/down.sql ================================================ ALTER TABLE comment DROP COLUMN distinguished; ================================================ FILE: migrations/2022-08-04-214722_add_distinguished_comment/up.sql ================================================ ALTER TABLE comment ADD COLUMN distinguished boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2022-08-05-203502_add_person_post_aggregates/down.sql ================================================ DROP TABLE person_post_aggregates; ================================================ FILE: migrations/2022-08-05-203502_add_person_post_aggregates/up.sql ================================================ -- This table stores the # of read comments for a person, on a post -- It can then be joined to post_aggregates to get an unread count: -- unread = post_aggregates.comments - person_post_aggregates.read_comments CREATE TABLE person_post_aggregates ( id serial PRIMARY KEY, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read_comments bigint NOT NULL DEFAULT 0, published timestamp NOT NULL DEFAULT now(), UNIQUE (person_id, post_id) ); ================================================ FILE: migrations/2022-08-22-193848_comment-language-tags/down.sql ================================================ ALTER TABLE comment DROP COLUMN language_id; ================================================ FILE: migrations/2022-08-22-193848_comment-language-tags/up.sql ================================================ ALTER TABLE comment ADD COLUMN language_id integer REFERENCES LANGUAGE NOT NULL DEFAULT 0; ================================================ FILE: migrations/2022-09-07-113813_drop_ccnew_indexes_function/down.sql ================================================ DROP FUNCTION drop_ccnew_indexes; ================================================ FILE: migrations/2022-09-07-113813_drop_ccnew_indexes_function/up.sql ================================================ CREATE OR REPLACE FUNCTION drop_ccnew_indexes () RETURNS integer AS $$ DECLARE i RECORD; BEGIN FOR i IN ( SELECT relname FROM pg_class WHERE relname LIKE '%ccnew%') LOOP EXECUTE 'DROP INDEX ' || i.relname; END LOOP; RETURN 1; END; $$ LANGUAGE plpgsql; ================================================ FILE: migrations/2022-09-07-114618_pm-reports/down.sql ================================================ DROP TABLE private_message_report; ================================================ FILE: migrations/2022-09-07-114618_pm-reports/up.sql ================================================ CREATE TABLE private_message_report ( id serial PRIMARY KEY, creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- user reporting comment private_message_id int REFERENCES private_message ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, -- comment being reported original_pm_text text NOT NULL, reason text NOT NULL, resolved bool NOT NULL DEFAULT FALSE, resolver_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, -- user resolving report published timestamp NOT NULL DEFAULT now(), updated timestamp NULL, UNIQUE (private_message_id, creator_id) -- users should only be able to report a pm once ); ================================================ FILE: migrations/2022-09-08-102358_site-and-community-languages/down.sql ================================================ DROP TABLE site_language; DROP TABLE community_language; DELETE FROM local_user_language; ================================================ FILE: migrations/2022-09-08-102358_site-and-community-languages/up.sql ================================================ CREATE TABLE site_language ( id serial PRIMARY KEY, site_id int REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, language_id int REFERENCES LANGUAGE ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, UNIQUE (site_id, language_id) ); CREATE TABLE community_language ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, language_id int REFERENCES LANGUAGE ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, UNIQUE (community_id, language_id) ); -- update existing users, sites and communities to have all languages enabled DO $$ DECLARE xid integer; BEGIN FOR xid IN SELECT id FROM local_user LOOP INSERT INTO local_user_language (local_user_id, language_id) ( SELECT xid, language.id AS lid FROM LANGUAGE); END LOOP; FOR xid IN SELECT id FROM site LOOP INSERT INTO site_language (site_id, language_id) ( SELECT xid, language.id AS lid FROM LANGUAGE); END LOOP; FOR xid IN SELECT id FROM community LOOP INSERT INTO community_language (community_id, language_id) ( SELECT xid, language.id AS lid FROM LANGUAGE); END LOOP; END; $$; ================================================ FILE: migrations/2022-09-24-161829_remove_table_aliases/down.sql ================================================ CREATE VIEW person_alias_1 AS SELECT * FROM person; CREATE VIEW person_alias_2 AS SELECT * FROM person; ================================================ FILE: migrations/2022-09-24-161829_remove_table_aliases/up.sql ================================================ -- Drop the alias views DROP VIEW person_alias_1, person_alias_2; ================================================ FILE: migrations/2022-10-06-183632_move_blocklist_to_db/down.sql ================================================ -- Add back site columns ALTER TABLE site ADD COLUMN enable_downvotes boolean DEFAULT TRUE NOT NULL, ADD COLUMN open_registration boolean DEFAULT TRUE NOT NULL, ADD COLUMN enable_nsfw boolean DEFAULT TRUE NOT NULL, ADD COLUMN community_creation_admin_only boolean DEFAULT FALSE NOT NULL, ADD COLUMN require_email_verification boolean DEFAULT FALSE NOT NULL, ADD COLUMN require_application boolean DEFAULT TRUE NOT NULL, ADD COLUMN application_question text DEFAULT 'To verify that you are human, please explain why you want to create an account on this site'::text, ADD COLUMN private_instance boolean DEFAULT FALSE NOT NULL, ADD COLUMN default_theme text DEFAULT 'browser'::text NOT NULL, ADD COLUMN default_post_listing_type text DEFAULT 'Local'::text NOT NULL, ADD COLUMN legal_information text, ADD COLUMN hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL, ADD COLUMN application_email_admins boolean DEFAULT FALSE NOT NULL; -- Insert the data back from local_site UPDATE site SET enable_downvotes = ls.enable_downvotes, open_registration = ls.open_registration, enable_nsfw = ls.enable_nsfw, community_creation_admin_only = ls.community_creation_admin_only, require_email_verification = ls.require_email_verification, require_application = ls.require_application, application_question = ls.application_question, private_instance = ls.private_instance, default_theme = ls.default_theme, default_post_listing_type = ls.default_post_listing_type, legal_information = ls.legal_information, hide_modlog_mod_names = ls.hide_modlog_mod_names, application_email_admins = ls.application_email_admins, published = ls.published, updated = ls.updated FROM ( SELECT site_id, enable_downvotes, open_registration, enable_nsfw, community_creation_admin_only, require_email_verification, require_application, application_question, private_instance, default_theme, default_post_listing_type, legal_information, hide_modlog_mod_names, application_email_admins, published, updated FROM local_site) AS ls WHERE site.id = ls.site_id; -- drop instance columns ALTER TABLE site DROP COLUMN instance_id; ALTER TABLE person DROP COLUMN instance_id; ALTER TABLE community DROP COLUMN instance_id; DROP TABLE local_site_rate_limit; DROP TABLE local_site; DROP TABLE federation_allowlist; DROP TABLE federation_blocklist; DROP TABLE instance; ================================================ FILE: migrations/2022-10-06-183632_move_blocklist_to_db/up.sql ================================================ -- Create an instance table -- Holds any connected or unconnected domain CREATE TABLE instance ( id serial PRIMARY KEY, domain varchar(255) NOT NULL UNIQUE, published timestamp NOT NULL DEFAULT now(), updated timestamp NULL ); -- Insert all the domains to the instance table INSERT INTO instance (DOMAIN) SELECT DISTINCT substring(p.actor_id FROM '(?:.*://)?(?:www\.)?([^/?]*)') FROM ( SELECT actor_id FROM site UNION SELECT actor_id FROM person UNION SELECT actor_id FROM community) AS p; -- Alter site, person, and community tables to reference the instance table. ALTER TABLE site ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE person ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE community ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE; -- Add those columns UPDATE site SET instance_id = i.id FROM instance i WHERE substring(actor_id FROM '(?:.*://)?(?:www\.)?([^/?]*)') = i.domain; UPDATE person SET instance_id = i.id FROM instance i WHERE substring(actor_id FROM '(?:.*://)?(?:www\.)?([^/?]*)') = i.domain; UPDATE community SET instance_id = i.id FROM instance i WHERE substring(actor_id FROM '(?:.*://)?(?:www\.)?([^/?]*)') = i.domain; -- Make those columns unique not null now ALTER TABLE site ALTER COLUMN instance_id SET NOT NULL; ALTER TABLE site ADD CONSTRAINT idx_site_instance_unique UNIQUE (instance_id); ALTER TABLE person ALTER COLUMN instance_id SET NOT NULL; ALTER TABLE community ALTER COLUMN instance_id SET NOT NULL; -- Create allowlist and blocklist tables CREATE TABLE federation_allowlist ( id serial PRIMARY KEY, instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE, published timestamp NOT NULL DEFAULT now(), updated timestamp NULL ); CREATE TABLE federation_blocklist ( id serial PRIMARY KEY, instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE, published timestamp NOT NULL DEFAULT now(), updated timestamp NULL ); -- Move all the extra site settings-type columns to a local_site table -- Add a lot of other fields currently in the lemmy.hjson CREATE TABLE local_site ( id serial PRIMARY KEY, site_id int REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE, -- Site table fields site_setup boolean DEFAULT FALSE NOT NULL, enable_downvotes boolean DEFAULT TRUE NOT NULL, open_registration boolean DEFAULT TRUE NOT NULL, enable_nsfw boolean DEFAULT TRUE NOT NULL, community_creation_admin_only boolean DEFAULT FALSE NOT NULL, require_email_verification boolean DEFAULT FALSE NOT NULL, require_application boolean DEFAULT TRUE NOT NULL, application_question text DEFAULT 'to verify that you are human, please explain why you want to create an account on this site'::text, private_instance boolean DEFAULT FALSE NOT NULL, default_theme text DEFAULT 'browser'::text NOT NULL, default_post_listing_type text DEFAULT 'Local'::text NOT NULL, legal_information text, hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL, application_email_admins boolean DEFAULT FALSE NOT NULL, -- Fields from lemmy.hjson slur_filter_regex text, actor_name_max_length int DEFAULT 20 NOT NULL, federation_enabled boolean DEFAULT TRUE NOT NULL, federation_debug boolean DEFAULT FALSE NOT NULL, federation_strict_allowlist boolean DEFAULT TRUE NOT NULL, federation_http_fetch_retry_limit int DEFAULT 25 NOT NULL, federation_worker_count int DEFAULT 64 NOT NULL, captcha_enabled boolean DEFAULT FALSE NOT NULL, captcha_difficulty varchar(255) DEFAULT 'medium' NOT NULL, -- Time fields published timestamp without time zone DEFAULT now() NOT NULL, updated timestamp without time zone ); -- local_site_rate_limit is its own table, so as to not go over 32 columns, and force diesel to use the 64-column-tables feature CREATE TABLE local_site_rate_limit ( id serial PRIMARY KEY, local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL UNIQUE, message int DEFAULT 180 NOT NULL, message_per_second int DEFAULT 60 NOT NULL, post int DEFAULT 6 NOT NULL, post_per_second int DEFAULT 600 NOT NULL, register int DEFAULT 3 NOT NULL, register_per_second int DEFAULT 3600 NOT NULL, image int DEFAULT 6 NOT NULL, image_per_second int DEFAULT 3600 NOT NULL, comment int DEFAULT 6 NOT NULL, comment_per_second int DEFAULT 600 NOT NULL, search int DEFAULT 60 NOT NULL, search_per_second int DEFAULT 600 NOT NULL, published timestamp without time zone DEFAULT now() NOT NULL, updated timestamp without time zone ); -- Insert the data into local_site INSERT INTO local_site (site_id, site_setup, enable_downvotes, open_registration, enable_nsfw, community_creation_admin_only, require_email_verification, require_application, application_question, private_instance, default_theme, default_post_listing_type, legal_information, hide_modlog_mod_names, application_email_admins, published, updated) SELECT id, TRUE, -- Assume site if setup if there's already a site row enable_downvotes, open_registration, enable_nsfw, community_creation_admin_only, require_email_verification, require_application, application_question, private_instance, default_theme, default_post_listing_type, legal_information, hide_modlog_mod_names, application_email_admins, published, updated FROM site ORDER BY id LIMIT 1; -- Default here INSERT INTO local_site_rate_limit (local_site_id) SELECT id FROM local_site ORDER BY id LIMIT 1; -- Drop all those columns from site ALTER TABLE site DROP COLUMN enable_downvotes, DROP COLUMN open_registration, DROP COLUMN enable_nsfw, DROP COLUMN community_creation_admin_only, DROP COLUMN require_email_verification, DROP COLUMN require_application, DROP COLUMN application_question, DROP COLUMN private_instance, DROP COLUMN default_theme, DROP COLUMN default_post_listing_type, DROP COLUMN legal_information, DROP COLUMN hide_modlog_mod_names, DROP COLUMN application_email_admins; ================================================ FILE: migrations/2022-11-13-181529_create_taglines/down.sql ================================================ DROP TABLE tagline; ================================================ FILE: migrations/2022-11-13-181529_create_taglines/up.sql ================================================ CREATE TABLE tagline ( id serial PRIMARY KEY, local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, content text NOT NULL, published timestamp without time zone DEFAULT now() NOT NULL, updated timestamp without time zone ); ================================================ FILE: migrations/2022-11-20-032430_sticky_local/down.sql ================================================ DROP TRIGGER IF EXISTS post_aggregates_featured_local ON post; DROP TRIGGER IF EXISTS post_aggregates_featured_community ON post; DROP FUNCTION post_aggregates_featured_community; DROP FUNCTION post_aggregates_featured_local; ALTER TABLE post ADD stickied boolean NOT NULL DEFAULT FALSE; UPDATE post SET stickied = featured_community; ALTER TABLE post DROP COLUMN featured_community; ALTER TABLE post DROP COLUMN featured_local; ALTER TABLE post_aggregates ADD stickied boolean NOT NULL DEFAULT FALSE; UPDATE post_aggregates SET stickied = featured_community; ALTER TABLE post_aggregates DROP COLUMN featured_community; ALTER TABLE post_aggregates DROP COLUMN featured_local; ALTER TABLE mod_feature_post RENAME COLUMN featured TO stickied; ALTER TABLE mod_feature_post DROP COLUMN is_featured_community; ALTER TABLE mod_feature_post ALTER COLUMN stickied DROP NOT NULL; ALTER TABLE mod_feature_post RENAME TO mod_sticky_post; CREATE FUNCTION post_aggregates_stickied () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_aggregates pa SET stickied = NEW.stickied WHERE pa.post_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_stickied AFTER UPDATE ON post FOR EACH ROW WHEN (OLD.stickied IS DISTINCT FROM NEW.stickied) EXECUTE PROCEDURE post_aggregates_stickied (); CREATE INDEX idx_post_aggregates_stickied_newest_comment_time ON post_aggregates (stickied DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_stickied_comments ON post_aggregates (stickied DESC, comments DESC); CREATE INDEX idx_post_aggregates_stickied_hot ON post_aggregates (stickied DESC, hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_stickied_active ON post_aggregates (stickied DESC, hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_stickied_score ON post_aggregates (stickied DESC, score DESC); CREATE INDEX idx_post_aggregates_stickied_published ON post_aggregates (stickied DESC, published DESC); ================================================ FILE: migrations/2022-11-20-032430_sticky_local/up.sql ================================================ DROP TRIGGER IF EXISTS post_aggregates_stickied ON post; DROP FUNCTION post_aggregates_stickied; ALTER TABLE post ADD featured_community boolean NOT NULL DEFAULT FALSE; ALTER TABLE post ADD featured_local boolean NOT NULL DEFAULT FALSE; UPDATE post SET featured_community = stickied; ALTER TABLE post DROP COLUMN stickied; ALTER TABLE post_aggregates ADD featured_community boolean NOT NULL DEFAULT FALSE; ALTER TABLE post_aggregates ADD featured_local boolean NOT NULL DEFAULT FALSE; UPDATE post_aggregates SET featured_community = stickied; ALTER TABLE post_aggregates DROP COLUMN stickied; ALTER TABLE mod_sticky_post RENAME COLUMN stickied TO featured; ALTER TABLE mod_sticky_post ALTER COLUMN featured SET NOT NULL; ALTER TABLE mod_sticky_post ADD is_featured_community boolean NOT NULL DEFAULT TRUE; ALTER TABLE mod_sticky_post RENAME TO mod_feature_post; CREATE FUNCTION post_aggregates_featured_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_aggregates pa SET featured_community = NEW.featured_community WHERE pa.post_id = NEW.id; RETURN NULL; END $$; CREATE FUNCTION post_aggregates_featured_local () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_aggregates pa SET featured_local = NEW.featured_local WHERE pa.post_id = NEW.id; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_featured_community AFTER UPDATE ON public.post FOR EACH ROW WHEN (old.featured_community IS DISTINCT FROM new.featured_community) EXECUTE FUNCTION public.post_aggregates_featured_community (); CREATE TRIGGER post_aggregates_featured_local AFTER UPDATE ON public.post FOR EACH ROW WHEN (old.featured_local IS DISTINCT FROM new.featured_local) EXECUTE FUNCTION public.post_aggregates_featured_local (); ================================================ FILE: migrations/2022-11-21-143249_remove-federation-settings/down.sql ================================================ ALTER TABLE local_site ADD COLUMN federation_strict_allowlist bool DEFAULT TRUE NOT NULL; ALTER TABLE local_site ADD COLUMN federation_http_fetch_retry_limit int NOT NULL DEFAULT 25; ================================================ FILE: migrations/2022-11-21-143249_remove-federation-settings/up.sql ================================================ ALTER TABLE local_site DROP COLUMN federation_strict_allowlist; ALTER TABLE local_site DROP COLUMN federation_http_fetch_retry_limit; ================================================ FILE: migrations/2022-11-21-204256_user-following/down.sql ================================================ DROP TABLE person_follower; ALTER TABLE community_follower ALTER COLUMN pending DROP NOT NULL; ================================================ FILE: migrations/2022-11-21-204256_user-following/up.sql ================================================ -- create user follower table with two references to persons CREATE TABLE person_follower ( id serial PRIMARY KEY, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, follower_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp NOT NULL DEFAULT now(), pending boolean NOT NULL, UNIQUE (follower_id, person_id) ); UPDATE community_follower SET pending = FALSE WHERE pending IS NULL; ALTER TABLE community_follower ALTER COLUMN pending SET NOT NULL; ================================================ FILE: migrations/2022-12-05-110642_registration_mode/down.sql ================================================ -- add back old registration columns ALTER TABLE local_site ADD COLUMN open_registration boolean NOT NULL DEFAULT TRUE; ALTER TABLE local_site ADD COLUMN require_application boolean NOT NULL DEFAULT TRUE; -- regenerate their values WITH subquery AS ( SELECT registration_mode, CASE WHEN registration_mode = 'closed' THEN FALSE ELSE TRUE END FROM local_site) UPDATE local_site SET open_registration = subquery.case FROM subquery; WITH subquery AS ( SELECT registration_mode, CASE WHEN registration_mode = 'open' THEN FALSE ELSE TRUE END FROM local_site) UPDATE local_site SET require_application = subquery.case FROM subquery; -- drop new column and type ALTER TABLE local_site DROP COLUMN registration_mode; DROP TYPE registration_mode_enum; ================================================ FILE: migrations/2022-12-05-110642_registration_mode/up.sql ================================================ -- create enum for registration modes CREATE TYPE registration_mode_enum AS enum ( 'closed', 'require_application', 'open' ); -- use this enum for registration mode setting ALTER TABLE local_site ADD COLUMN registration_mode registration_mode_enum NOT NULL DEFAULT 'require_application'; -- generate registration mode value from previous settings WITH subquery AS ( SELECT open_registration, require_application, CASE WHEN open_registration = FALSE THEN 'closed'::registration_mode_enum WHEN open_registration = TRUE AND require_application = TRUE THEN 'require_application' ELSE 'open' END FROM local_site) UPDATE local_site SET registration_mode = subquery.case FROM subquery; -- drop old registration settings ALTER TABLE local_site DROP COLUMN open_registration; ALTER TABLE local_site DROP COLUMN require_application; ================================================ FILE: migrations/2023-01-17-165819_cleanup_post_aggregates_indexes/down.sql ================================================ -- Drop the new indexes DROP INDEX idx_post_aggregates_featured_local_newest_comment_time, idx_post_aggregates_featured_community_newest_comment_time, idx_post_aggregates_featured_local_comments, idx_post_aggregates_featured_community_comments, idx_post_aggregates_featured_local_hot, idx_post_aggregates_featured_community_hot, idx_post_aggregates_featured_local_active, idx_post_aggregates_featured_community_active, idx_post_aggregates_featured_local_score, idx_post_aggregates_featured_community_score, idx_post_aggregates_featured_local_published, idx_post_aggregates_featured_community_published; -- Create the old indexes CREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC); CREATE INDEX idx_post_aggregates_comments ON post_aggregates (comments DESC); CREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC); CREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC); ================================================ FILE: migrations/2023-01-17-165819_cleanup_post_aggregates_indexes/up.sql ================================================ -- Drop the old indexes DROP INDEX idx_post_aggregates_newest_comment_time, idx_post_aggregates_comments, idx_post_aggregates_hot, idx_post_aggregates_active, idx_post_aggregates_score, idx_post_aggregates_published; -- All of the post fetching queries now start with either -- featured_local desc, or featured_community desc, then the other sorts. -- So you now need to double these indexes CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON post_aggregates (featured_local DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (featured_community DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_local_comments ON post_aggregates (featured_local DESC, comments DESC); CREATE INDEX idx_post_aggregates_featured_community_comments ON post_aggregates (featured_community DESC, comments DESC); CREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank (score, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC); CREATE INDEX idx_post_aggregates_featured_local_published ON post_aggregates (featured_local DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (featured_community DESC, published DESC); ================================================ FILE: migrations/2023-02-01-012747_fix_active_index/down.sql ================================================ DROP INDEX idx_post_aggregates_featured_local_active, idx_post_aggregates_featured_community_active; CREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank (score, newest_comment_time) DESC, newest_comment_time DESC); ================================================ FILE: migrations/2023-02-01-012747_fix_active_index/up.sql ================================================ -- This should use the newest_comment_time_necro, not the newest_comment_time for the hot_rank DROP INDEX idx_post_aggregates_featured_local_active, idx_post_aggregates_featured_community_active; CREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank (score, newest_comment_time_necro) DESC, newest_comment_time_necro DESC); ================================================ FILE: migrations/2023-02-05-102549_drop-site-federation-debug/down.sql ================================================ ALTER TABLE local_site ADD COLUMN federation_debug boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2023-02-05-102549_drop-site-federation-debug/up.sql ================================================ ALTER TABLE local_site DROP COLUMN federation_debug; ================================================ FILE: migrations/2023-02-07-030958_community-collections/down.sql ================================================ ALTER TABLE community DROP COLUMN moderators_url; ALTER TABLE community DROP COLUMN featured_url; ================================================ FILE: migrations/2023-02-07-030958_community-collections/up.sql ================================================ ALTER TABLE community ADD COLUMN moderators_url varchar(255) UNIQUE; ALTER TABLE community ADD COLUMN featured_url varchar(255) UNIQUE; ================================================ FILE: migrations/2023-02-11-173347_custom_emojis/down.sql ================================================ DROP TABLE custom_emoji_keyword; DROP TABLE custom_emoji; ================================================ FILE: migrations/2023-02-11-173347_custom_emojis/up.sql ================================================ CREATE TABLE custom_emoji ( id serial PRIMARY KEY, local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, shortcode varchar(128) NOT NULL UNIQUE, image_url text NOT NULL UNIQUE, alt_text text NOT NULL, category text NOT NULL, published timestamp without time zone DEFAULT now() NOT NULL, updated timestamp without time zone ); CREATE TABLE custom_emoji_keyword ( id serial PRIMARY KEY, custom_emoji_id int REFERENCES custom_emoji ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, keyword varchar(128) NOT NULL, UNIQUE (custom_emoji_id, keyword) ); CREATE INDEX idx_custom_emoji_category ON custom_emoji (id, category); ================================================ FILE: migrations/2023-02-13-172528_add_report_email_admins/down.sql ================================================ ALTER TABLE local_site DROP COLUMN reports_email_admins; ================================================ FILE: migrations/2023-02-13-172528_add_report_email_admins/up.sql ================================================ -- Adding a field to email admins for new reports ALTER TABLE local_site ADD COLUMN reports_email_admins boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2023-02-13-221303_add_instance_software_and_version/down.sql ================================================ ALTER TABLE instance DROP COLUMN software; ALTER TABLE instance DROP COLUMN version; ================================================ FILE: migrations/2023-02-13-221303_add_instance_software_and_version/up.sql ================================================ -- Add Software and Version columns from nodeinfo to the instance table ALTER TABLE instance ADD COLUMN software varchar(255); ALTER TABLE instance ADD COLUMN version varchar(255); ================================================ FILE: migrations/2023-02-15-212546_add_post_comment_saved_indexes/down.sql ================================================ DROP INDEX idx_post_saved_person_id, idx_comment_saved_person_id; ================================================ FILE: migrations/2023-02-15-212546_add_post_comment_saved_indexes/up.sql ================================================ CREATE INDEX idx_post_saved_person_id ON post_saved (person_id); CREATE INDEX idx_comment_saved_person_id ON comment_saved (person_id); ================================================ FILE: migrations/2023-02-16-194139_add_totp_secret/down.sql ================================================ ALTER TABLE local_user DROP COLUMN totp_2fa_secret; ALTER TABLE local_user DROP COLUMN totp_2fa_url; ================================================ FILE: migrations/2023-02-16-194139_add_totp_secret/up.sql ================================================ ALTER TABLE local_user ADD COLUMN totp_2fa_secret text; ALTER TABLE local_user ADD COLUMN totp_2fa_url text; ================================================ FILE: migrations/2023-04-14-175955_add_listingtype_sorttype_enums/down.sql ================================================ -- Some fixes ALTER TABLE community ALTER COLUMN hidden DROP NOT NULL; ALTER TABLE community ALTER COLUMN posting_restricted_to_mods DROP NOT NULL; ALTER TABLE activity ALTER COLUMN sensitive DROP NOT NULL; ALTER TABLE mod_add ALTER COLUMN removed DROP NOT NULL; ALTER TABLE mod_add_community ALTER COLUMN removed DROP NOT NULL; ALTER TABLE mod_ban ALTER COLUMN banned DROP NOT NULL; ALTER TABLE mod_ban_from_community ALTER COLUMN banned DROP NOT NULL; ALTER TABLE mod_hide_community ALTER COLUMN hidden DROP NOT NULL; ALTER TABLE mod_lock_post ALTER COLUMN LOCKED DROP NOT NULL; ALTER TABLE mod_remove_comment ALTER COLUMN removed DROP NOT NULL; ALTER TABLE mod_remove_community ALTER COLUMN removed DROP NOT NULL; ALTER TABLE mod_remove_post ALTER COLUMN removed DROP NOT NULL; ALTER TABLE mod_transfer_community ADD COLUMN removed boolean DEFAULT FALSE; ALTER TABLE LANGUAGE ALTER COLUMN code DROP NOT NULL; ALTER TABLE LANGUAGE ALTER COLUMN name DROP NOT NULL; -- Fix the registration mode enums ALTER TYPE registration_mode_enum RENAME VALUE 'Closed' TO 'closed'; ALTER TYPE registration_mode_enum RENAME VALUE 'RequireApplication' TO 'require_application'; ALTER TYPE registration_mode_enum RENAME VALUE 'Open' TO 'open'; -- add back old columns -- Alter the local_user table ALTER TABLE local_user ALTER COLUMN default_sort_type DROP DEFAULT; ALTER TABLE local_user ALTER COLUMN default_sort_type TYPE smallint USING CASE default_sort_type WHEN 'Active' THEN 0 WHEN 'Hot' THEN 1 WHEN 'New' THEN 2 WHEN 'Old' THEN 3 WHEN 'TopDay' THEN 4 WHEN 'TopWeek' THEN 5 WHEN 'TopMonth' THEN 6 WHEN 'TopYear' THEN 7 WHEN 'TopAll' THEN 8 WHEN 'MostComments' THEN 9 WHEN 'NewComments' THEN 10 ELSE 0 END; ALTER TABLE local_user ALTER COLUMN default_sort_type SET DEFAULT 0; ALTER TABLE local_user ALTER COLUMN default_listing_type DROP DEFAULT; ALTER TABLE local_user ALTER COLUMN default_listing_type TYPE smallint USING CASE default_listing_type WHEN 'All' THEN 0 WHEN 'Local' THEN 1 WHEN 'Subscribed' THEN 2 ELSE 1 END; ALTER TABLE local_user ALTER COLUMN default_listing_type SET DEFAULT 1; -- Alter the local site column ALTER TABLE local_site ALTER COLUMN default_post_listing_type DROP DEFAULT; ALTER TABLE local_site ALTER COLUMN default_post_listing_type TYPE text; ALTER TABLE local_site ALTER COLUMN default_post_listing_type SET DEFAULT 'Local'; -- Drop the types DROP TYPE listing_type_enum; DROP TYPE sort_type_enum; ================================================ FILE: migrations/2023-04-14-175955_add_listingtype_sorttype_enums/up.sql ================================================ -- A few DB fixes ALTER TABLE community ALTER COLUMN hidden SET NOT NULL; ALTER TABLE community ALTER COLUMN posting_restricted_to_mods SET NOT NULL; ALTER TABLE activity ALTER COLUMN sensitive SET NOT NULL; ALTER TABLE mod_add ALTER COLUMN removed SET NOT NULL; ALTER TABLE mod_add_community ALTER COLUMN removed SET NOT NULL; ALTER TABLE mod_ban ALTER COLUMN banned SET NOT NULL; ALTER TABLE mod_ban_from_community ALTER COLUMN banned SET NOT NULL; ALTER TABLE mod_hide_community ALTER COLUMN hidden SET NOT NULL; ALTER TABLE mod_lock_post ALTER COLUMN LOCKED SET NOT NULL; ALTER TABLE mod_remove_comment ALTER COLUMN removed SET NOT NULL; ALTER TABLE mod_remove_community ALTER COLUMN removed SET NOT NULL; ALTER TABLE mod_remove_post ALTER COLUMN removed SET NOT NULL; ALTER TABLE mod_transfer_community DROP COLUMN removed; ALTER TABLE LANGUAGE ALTER COLUMN code SET NOT NULL; ALTER TABLE LANGUAGE ALTER COLUMN name SET NOT NULL; -- Fix the registration mode enums ALTER TYPE registration_mode_enum RENAME VALUE 'closed' TO 'Closed'; ALTER TYPE registration_mode_enum RENAME VALUE 'require_application' TO 'RequireApplication'; ALTER TYPE registration_mode_enum RENAME VALUE 'open' TO 'Open'; -- Create the enums CREATE TYPE sort_type_enum AS ENUM ( 'Active', 'Hot', 'New', 'Old', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'MostComments', 'NewComments' ); CREATE TYPE listing_type_enum AS ENUM ( 'All', 'Local', 'Subscribed' ); -- Alter the local_user table ALTER TABLE local_user ALTER COLUMN default_sort_type DROP DEFAULT; ALTER TABLE local_user ALTER COLUMN default_sort_type TYPE sort_type_enum USING CASE default_sort_type WHEN 0 THEN 'Active' WHEN 1 THEN 'Hot' WHEN 2 THEN 'New' WHEN 3 THEN 'Old' WHEN 4 THEN 'TopDay' WHEN 5 THEN 'TopWeek' WHEN 6 THEN 'TopMonth' WHEN 7 THEN 'TopYear' WHEN 8 THEN 'TopAll' WHEN 9 THEN 'MostComments' WHEN 10 THEN 'NewComments' ELSE 'Active' END::sort_type_enum; ALTER TABLE local_user ALTER COLUMN default_sort_type SET DEFAULT 'Active'; ALTER TABLE local_user ALTER COLUMN default_listing_type DROP DEFAULT; ALTER TABLE local_user ALTER COLUMN default_listing_type TYPE listing_type_enum USING CASE default_listing_type WHEN 0 THEN 'All' WHEN 1 THEN 'Local' WHEN 2 THEN 'Subscribed' ELSE 'Local' END::listing_type_enum; ALTER TABLE local_user ALTER COLUMN default_listing_type SET DEFAULT 'Local'; -- Alter the local site column ALTER TABLE local_site ALTER COLUMN default_post_listing_type DROP DEFAULT; ALTER TABLE local_site ALTER COLUMN default_post_listing_type TYPE listing_type_enum USING default_post_listing_type::listing_type_enum; ALTER TABLE local_site ALTER COLUMN default_post_listing_type SET DEFAULT 'Local'; ================================================ FILE: migrations/2023-04-23-164732_add_person_details_indexes/down.sql ================================================ DROP INDEX idx_person_lower_name; DROP INDEX idx_community_lower_name; DROP INDEX idx_community_moderator_published; DROP INDEX idx_community_moderator_community; DROP INDEX idx_community_moderator_person; DROP INDEX idx_comment_saved_comment; DROP INDEX idx_comment_saved_person; DROP INDEX idx_community_block_community; DROP INDEX idx_community_block_person; DROP INDEX idx_community_follower_community; DROP INDEX idx_community_follower_person; DROP INDEX idx_person_block_person; DROP INDEX idx_person_block_target; DROP INDEX idx_post_language; DROP INDEX idx_comment_language; DROP INDEX idx_person_aggregates_person; DROP INDEX idx_person_post_aggregates_post; DROP INDEX idx_person_post_aggregates_person; DROP INDEX idx_comment_reply_comment; DROP INDEX idx_comment_reply_recipient; DROP INDEX idx_comment_reply_published; ================================================ FILE: migrations/2023-04-23-164732_add_person_details_indexes/up.sql ================================================ -- Add a few indexes to speed up person details queries CREATE INDEX idx_person_lower_name ON person (lower(name)); CREATE INDEX idx_community_lower_name ON community (lower(name)); CREATE INDEX idx_community_moderator_published ON community_moderator (published); CREATE INDEX idx_community_moderator_community ON community_moderator (community_id); CREATE INDEX idx_community_moderator_person ON community_moderator (person_id); CREATE INDEX idx_comment_saved_comment ON comment_saved (comment_id); CREATE INDEX idx_comment_saved_person ON comment_saved (person_id); CREATE INDEX idx_community_block_community ON community_block (community_id); CREATE INDEX idx_community_block_person ON community_block (person_id); CREATE INDEX idx_community_follower_community ON community_follower (community_id); CREATE INDEX idx_community_follower_person ON community_follower (person_id); CREATE INDEX idx_person_block_person ON person_block (person_id); CREATE INDEX idx_person_block_target ON person_block (target_id); CREATE INDEX idx_post_language ON post (language_id); CREATE INDEX idx_comment_language ON comment (language_id); CREATE INDEX idx_person_aggregates_person ON person_aggregates (person_id); CREATE INDEX idx_person_post_aggregates_post ON person_post_aggregates (post_id); CREATE INDEX idx_person_post_aggregates_person ON person_post_aggregates (person_id); CREATE INDEX idx_comment_reply_comment ON comment_reply (comment_id); CREATE INDEX idx_comment_reply_recipient ON comment_reply (recipient_id); CREATE INDEX idx_comment_reply_published ON comment_reply (published DESC); ================================================ FILE: migrations/2023-05-10-095739_force_enable_undetermined_language/down.sql ================================================ SELECT 1; ================================================ FILE: migrations/2023-05-10-095739_force_enable_undetermined_language/up.sql ================================================ -- force enable undetermined language for all users INSERT INTO local_user_language (local_user_id, language_id) SELECT id, 0 FROM local_user ON CONFLICT (local_user_id, language_id) DO NOTHING; ================================================ FILE: migrations/2023-06-06-104440_index_post_url/down.sql ================================================ -- Change back the column type ALTER TABLE post ALTER COLUMN url TYPE text; -- Drop the index DROP INDEX idx_post_url; ================================================ FILE: migrations/2023-06-06-104440_index_post_url/up.sql ================================================ -- Make a hard limit of 512 for the post.url column -- Truncate existing long rows. UPDATE post SET url = LEFT (url, 512) WHERE length(url) > 512; -- Enforce the limit ALTER TABLE post ALTER COLUMN url TYPE varchar(512); -- Add the index CREATE INDEX idx_post_url ON post (url); ================================================ FILE: migrations/2023-06-07-105918_add_hot_rank_columns/down.sql ================================================ -- Remove the new columns ALTER TABLE post_aggregates DROP COLUMN hot_rank; ALTER TABLE post_aggregates DROP COLUMN hot_rank_active; ALTER TABLE comment_aggregates DROP COLUMN hot_rank; ALTER TABLE community_aggregates DROP COLUMN hot_rank; -- Drop some new indexes DROP INDEX idx_post_aggregates_score; DROP INDEX idx_post_aggregates_published; DROP INDEX idx_post_aggregates_newest_comment_time; DROP INDEX idx_post_aggregates_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_community; DROP INDEX idx_post_aggregates_featured_local; -- Recreate the old indexes CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON public.post_aggregates USING btree (featured_community DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_local_comments ON public.post_aggregates USING btree (featured_local DESC, comments DESC); CREATE INDEX idx_post_aggregates_featured_community_comments ON public.post_aggregates USING btree (featured_community DESC, comments DESC); CREATE INDEX idx_post_aggregates_featured_local_hot ON public.post_aggregates USING btree (featured_local DESC, hot_rank ((score)::numeric, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON public.post_aggregates USING btree (featured_community DESC, hot_rank ((score)::numeric, published) DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_score ON public.post_aggregates USING btree (featured_local DESC, score DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON public.post_aggregates USING btree (featured_community DESC, score DESC); CREATE INDEX idx_post_aggregates_featured_local_published ON public.post_aggregates USING btree (featured_local DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON public.post_aggregates USING btree (featured_community DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON public.post_aggregates USING btree (featured_local DESC, hot_rank ((score)::numeric, newest_comment_time_necro) DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON public.post_aggregates USING btree (featured_community DESC, hot_rank ((score)::numeric, newest_comment_time_necro) DESC, newest_comment_time_necro DESC); CREATE INDEX idx_comment_aggregates_hot ON public.comment_aggregates USING btree (hot_rank ((score)::numeric, published) DESC, published DESC); CREATE INDEX idx_community_aggregates_hot ON public.community_aggregates USING btree (hot_rank ((subscribers)::numeric, published) DESC, published DESC); ================================================ FILE: migrations/2023-06-07-105918_add_hot_rank_columns/up.sql ================================================ -- This converts the old hot_rank functions, to columns -- Remove the old compound indexes DROP INDEX idx_post_aggregates_featured_local_newest_comment_time; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time; DROP INDEX idx_post_aggregates_featured_local_comments; DROP INDEX idx_post_aggregates_featured_community_comments; DROP INDEX idx_post_aggregates_featured_local_hot; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_local_score; DROP INDEX idx_post_aggregates_featured_community_score; DROP INDEX idx_post_aggregates_featured_local_published; DROP INDEX idx_post_aggregates_featured_community_published; DROP INDEX idx_post_aggregates_featured_local_active; DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_comment_aggregates_hot; DROP INDEX idx_community_aggregates_hot; -- Add the new hot rank columns for post and comment aggregates -- Note: 1728 is the result of the hot_rank function, with a score of 1, posted now -- hot_rank = 10000*log10(1 + 3)/Power(2, 1.8) ALTER TABLE post_aggregates ADD COLUMN hot_rank integer NOT NULL DEFAULT 1728; ALTER TABLE post_aggregates ADD COLUMN hot_rank_active integer NOT NULL DEFAULT 1728; ALTER TABLE comment_aggregates ADD COLUMN hot_rank integer NOT NULL DEFAULT 1728; ALTER TABLE community_aggregates ADD COLUMN hot_rank integer NOT NULL DEFAULT 1728; -- Populate them initially -- Note: After initial population, these are updated in a periodic scheduled job, -- with only the last week being updated. UPDATE post_aggregates SET hot_rank_active = hot_rank (score::numeric, newest_comment_time_necro); UPDATE post_aggregates SET hot_rank = hot_rank (score::numeric, published); UPDATE comment_aggregates SET hot_rank = hot_rank (score::numeric, published); UPDATE community_aggregates SET hot_rank = hot_rank (subscribers::numeric, published); -- Create single column indexes CREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC); CREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC); CREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC); CREATE INDEX idx_post_aggregates_newest_comment_time_necro ON post_aggregates (newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community ON post_aggregates (featured_community DESC); CREATE INDEX idx_post_aggregates_featured_local ON post_aggregates (featured_local DESC); CREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank DESC); CREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank_active DESC); CREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC); CREATE INDEX idx_community_aggregates_hot ON community_aggregates (hot_rank DESC); ================================================ FILE: migrations/2023-06-17-175955_add_listingtype_sorttype_hour_enums/down.sql ================================================ ALTER TABLE local_user ALTER default_sort_type DROP DEFAULT; -- update the default sort type UPDATE local_user SET default_sort_type = 'TopDay' WHERE default_sort_type IN ('TopHour', 'TopSixHour', 'TopTwelveHour'); -- rename the old enum ALTER TYPE sort_type_enum RENAME TO sort_type_enum__; -- create the new enum CREATE TYPE sort_type_enum AS ENUM ( 'Active', 'Hot', 'New', 'Old', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'MostComments', 'NewComments' ); -- alter all you enum columns ALTER TABLE local_user ALTER COLUMN default_sort_type TYPE sort_type_enum USING default_sort_type::text::sort_type_enum; ALTER TABLE local_user ALTER default_sort_type SET DEFAULT 'Active'; -- drop the old enum DROP TYPE sort_type_enum__; ================================================ FILE: migrations/2023-06-17-175955_add_listingtype_sorttype_hour_enums/up.sql ================================================ -- Update the enums ALTER TYPE sort_type_enum ADD VALUE 'TopHour'; ALTER TYPE sort_type_enum ADD VALUE 'TopSixHour'; ALTER TYPE sort_type_enum ADD VALUE 'TopTwelveHour'; ================================================ FILE: migrations/2023-06-19-055530_add_retry_worker_setting/down.sql ================================================ ALTER TABLE local_site ADD COLUMN federation_worker_count int DEFAULT 64 NOT NULL; ================================================ FILE: migrations/2023-06-19-055530_add_retry_worker_setting/up.sql ================================================ ALTER TABLE local_site DROP COLUMN federation_worker_count; ================================================ FILE: migrations/2023-06-19-120700_no_double_deletion/down.sql ================================================ -- This file should undo anything in `up.sql` CREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN RETURN FALSE; END IF; IF (TG_OP = 'DELETE') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND ((OLD.deleted = 'f' AND NEW.deleted = 't') OR (OLD.removed = 'f' AND NEW.removed = 't')); END $$; ================================================ FILE: migrations/2023-06-19-120700_no_double_deletion/up.sql ================================================ -- Deleting after removing should not decrement the count twice. CREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN RETURN FALSE; END IF; IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND ((OLD.deleted = 'f' AND NEW.deleted = 't') OR (OLD.removed = 'f' AND NEW.removed = 't')); END $$; -- Recalculate proper comment count. UPDATE person_aggregates SET comment_count = cnt.count FROM ( SELECT creator_id, count(*) AS count FROM comment WHERE deleted = 'f' AND removed = 'f' GROUP BY creator_id) cnt WHERE person_aggregates.person_id = cnt.creator_id; -- Recalculate proper comment score. UPDATE person_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id AS creator_id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM person u LEFT JOIN comment c ON u.id = c.creator_id AND c.deleted = 'f' AND c.removed = 'f' LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.person_id = cd.creator_id; -- Recalculate proper post count. UPDATE person_aggregates SET post_count = cnt.count FROM ( SELECT creator_id, count(*) AS count FROM post WHERE deleted = 'f' AND removed = 'f' GROUP BY creator_id) cnt WHERE person_aggregates.person_id = cnt.creator_id; -- Recalculate proper post score. UPDATE person_aggregates ua SET post_score = pd.score FROM ( SELECT u.id AS creator_id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM person u LEFT JOIN post p ON u.id = p.creator_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.person_id = pd.creator_id; ================================================ FILE: migrations/2023-06-20-191145_add_listingtype_sorttype_3_6_9_months_enums/down.sql ================================================ ALTER TABLE local_user ALTER default_sort_type DROP DEFAULT; -- update the default sort type UPDATE local_user SET default_sort_type = 'TopDay' WHERE default_sort_type IN ('TopThreeMonths', 'TopSixMonths', 'TopNineMonths'); -- rename the old enum ALTER TYPE sort_type_enum RENAME TO sort_type_enum__; -- create the new enum CREATE TYPE sort_type_enum AS ENUM ( 'Active', 'Hot', 'New', 'Old', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'MostComments', 'NewComments', 'TopHour', 'TopSixHour', 'TopTwelveHour' ); -- alter all you enum columns ALTER TABLE local_user ALTER COLUMN default_sort_type TYPE sort_type_enum USING default_sort_type::text::sort_type_enum; ALTER TABLE local_user ALTER default_sort_type SET DEFAULT 'Active'; -- drop the old enum DROP TYPE sort_type_enum__; ================================================ FILE: migrations/2023-06-20-191145_add_listingtype_sorttype_3_6_9_months_enums/up.sql ================================================ -- Update the enums ALTER TYPE sort_type_enum ADD VALUE 'TopThreeMonths'; ALTER TYPE sort_type_enum ADD VALUE 'TopSixMonths'; ALTER TYPE sort_type_enum ADD VALUE 'TopNineMonths'; ================================================ FILE: migrations/2023-06-21-153242_add_captcha/down.sql ================================================ DROP TABLE captcha_answer; ================================================ FILE: migrations/2023-06-21-153242_add_captcha/up.sql ================================================ CREATE TABLE captcha_answer ( id serial PRIMARY KEY, uuid uuid NOT NULL UNIQUE DEFAULT gen_random_uuid (), answer text NOT NULL, published timestamp NOT NULL DEFAULT now() ); ================================================ FILE: migrations/2023-06-22-051755_fix_local_communities_marked_non_local/down.sql ================================================ -- Add a no-op statement to prevent `diesel migration redo` errors SELECT 1; ================================================ FILE: migrations/2023-06-22-051755_fix_local_communities_marked_non_local/up.sql ================================================ UPDATE community c SET local = TRUE FROM local_site ls JOIN site s ON ls.site_id = s.id WHERE c.instance_id = s.instance_id AND NOT c.local; ================================================ FILE: migrations/2023-06-22-101245_increase_user_theme_column_size/down.sql ================================================ ALTER TABLE ONLY local_user ALTER COLUMN theme TYPE character varying(20); ALTER TABLE ONLY local_user ALTER COLUMN theme SET DEFAULT 'browser'::character varying; ================================================ FILE: migrations/2023-06-22-101245_increase_user_theme_column_size/up.sql ================================================ ALTER TABLE ONLY local_user ALTER COLUMN theme TYPE text; ALTER TABLE ONLY local_user ALTER COLUMN theme SET DEFAULT 'browser'::text; ================================================ FILE: migrations/2023-06-24-072904_add_open_links_in_new_tab_setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN open_links_in_new_tab; ================================================ FILE: migrations/2023-06-24-072904_add_open_links_in_new_tab_setting/up.sql ================================================ ALTER TABLE local_user ADD COLUMN open_links_in_new_tab boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2023-06-24-185942_aggegates_published_indexes/down.sql ================================================ DROP INDEX idx_comment_aggregates_published; DROP INDEX idx_community_aggregates_published; ================================================ FILE: migrations/2023-06-24-185942_aggegates_published_indexes/up.sql ================================================ -- Add indexes on published column (needed for hot_rank updates) CREATE INDEX idx_community_aggregates_published ON community_aggregates (published DESC); CREATE INDEX idx_comment_aggregates_published ON comment_aggregates (published DESC); ================================================ FILE: migrations/2023-06-27-065106_add_ui_settings/down.sql ================================================ ALTER TABLE local_user DROP COLUMN blur_nsfw; ALTER TABLE local_user DROP COLUMN auto_expand; ================================================ FILE: migrations/2023-06-27-065106_add_ui_settings/up.sql ================================================ -- Add the blur_nsfw to the local user table as a setting ALTER TABLE local_user ADD COLUMN blur_nsfw boolean NOT NULL DEFAULT TRUE; -- Add the auto_expand to the local user table as a setting ALTER TABLE local_user ADD COLUMN auto_expand boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2023-07-04-153335_add_optimized_indexes/down.sql ================================================ -- Drop the new indexes DROP INDEX idx_person_admin; DROP INDEX idx_post_aggregates_featured_local_score; DROP INDEX idx_post_aggregates_featured_local_newest_comment_time; DROP INDEX idx_post_aggregates_featured_local_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_local_hot; DROP INDEX idx_post_aggregates_featured_local_active; DROP INDEX idx_post_aggregates_featured_local_published; DROP INDEX idx_post_aggregates_published; DROP INDEX idx_post_aggregates_featured_community_score; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_published; -- Create single column indexes again CREATE INDEX idx_post_aggregates_score ON post_aggregates (score DESC); CREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC); CREATE INDEX idx_post_aggregates_newest_comment_time ON post_aggregates (newest_comment_time DESC); CREATE INDEX idx_post_aggregates_newest_comment_time_necro ON post_aggregates (newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community ON post_aggregates (featured_community DESC); CREATE INDEX idx_post_aggregates_featured_local ON post_aggregates (featured_local DESC); CREATE INDEX idx_post_aggregates_hot ON post_aggregates (hot_rank DESC); CREATE INDEX idx_post_aggregates_active ON post_aggregates (hot_rank_active DESC); ================================================ FILE: migrations/2023-07-04-153335_add_optimized_indexes/up.sql ================================================ -- Create an admin person index CREATE INDEX IF NOT EXISTS idx_person_admin ON person (admin); -- Compound indexes, using featured_, then the other sorts, proved to be much faster -- Drop the old indexes DROP INDEX idx_post_aggregates_score; DROP INDEX idx_post_aggregates_published; DROP INDEX idx_post_aggregates_newest_comment_time; DROP INDEX idx_post_aggregates_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_community; DROP INDEX idx_post_aggregates_featured_local; DROP INDEX idx_post_aggregates_hot; DROP INDEX idx_post_aggregates_active; -- featured_local CREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC); CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON post_aggregates (featured_local DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time_necro ON post_aggregates (featured_local DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank_active DESC); CREATE INDEX idx_post_aggregates_featured_local_published ON post_aggregates (featured_local DESC, published DESC); CREATE INDEX idx_post_aggregates_published ON post_aggregates (published DESC); -- featured_community CREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (featured_community DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necro ON post_aggregates (featured_community DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (featured_community DESC, published DESC); ================================================ FILE: migrations/2023-07-05-000058_person-admin/down.sql ================================================ DROP INDEX idx_person_admin; CREATE INDEX idx_person_admin ON person (admin); ================================================ FILE: migrations/2023-07-05-000058_person-admin/up.sql ================================================ DROP INDEX IF EXISTS idx_person_admin; CREATE INDEX idx_person_admin ON person (admin) WHERE admin; -- allow quickly finding all admins (PersonView::admins) ================================================ FILE: migrations/2023-07-06-151124_hot-rank-future/down.sql ================================================ CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone) RETURNS integer AS $$ BEGIN -- hours_diff:=EXTRACT(EPOCH FROM (timezone('utc',now()) - published))/3600 RETURN floor(10000 * log(greatest (1, score + 3)) / power(((EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600) + 2), 1.8))::integer; END; $$ LANGUAGE plpgsql IMMUTABLE; ================================================ FILE: migrations/2023-07-06-151124_hot-rank-future/up.sql ================================================ CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp without time zone) RETURNS integer AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600; BEGIN IF (hours_diff > 0) THEN RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer; ELSE RETURN 0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ================================================ FILE: migrations/2023-07-08-101154_fix_soft_delete_aggregates/down.sql ================================================ -- 2023-06-19-120700_no_double_deletion/up.sql CREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN RETURN FALSE; END IF; IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND ((OLD.deleted = 'f' AND NEW.deleted = 't') OR (OLD.removed = 'f' AND NEW.removed = 't')); END $$; -- 2022-04-04-183652_update_community_aggregates_on_soft_delete/up.sql CREATE OR REPLACE FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN RETURN FALSE; END IF; IF (TG_OP = 'INSERT') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND ((OLD.deleted = 't' AND NEW.deleted = 'f') OR (OLD.removed = 't' AND NEW.removed = 'f')); END $$; -- 2021-08-02-002342_comment_count_fixes/up.sql CREATE OR REPLACE FUNCTION post_aggregates_comment_deleted () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF NEW.deleted = TRUE THEN UPDATE post_aggregates pa SET comments = comments - 1 WHERE pa.post_id = NEW.post_id; ELSE UPDATE post_aggregates pa SET comments = comments + 1 WHERE pa.post_id = NEW.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER post_aggregates_comment_set_deleted AFTER UPDATE OF deleted ON comment FOR EACH ROW EXECUTE PROCEDURE post_aggregates_comment_deleted (); CREATE OR REPLACE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET comments = comments + 1, newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time_necro = NEW.published FROM post p WHERE pa.post_id = p.id AND pa.post_id = NEW.post_id -- Fix issue with being able to necro-bump your own post AND NEW.creator_id != p.creator_id AND pa.published > ('now'::timestamp - '2 days'::interval); ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; ELSIF (TG_OP = 'UPDATE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET comments = comments - 1 FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; -- 2020-12-10-152350_create_post_aggregates/up.sql CREATE OR REPLACE TRIGGER post_aggregates_comment_count AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE PROCEDURE post_aggregates_comment_count (); ================================================ FILE: migrations/2023-07-08-101154_fix_soft_delete_aggregates/up.sql ================================================ -- Fix for duplicated decrementations when both `deleted` and `removed` fields are set subsequently CREATE OR REPLACE FUNCTION was_removed_or_deleted (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN RETURN FALSE; END IF; IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND OLD.deleted = 'f' AND OLD.removed = 'f' AND (NEW.deleted = 't' OR NEW.removed = 't'); END $$; CREATE OR REPLACE FUNCTION was_restored_or_created (TG_OP text, OLD record, NEW record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN RETURN FALSE; END IF; IF (TG_OP = 'INSERT') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND NEW.deleted = 'f' AND NEW.removed = 'f' AND (OLD.deleted = 't' OR OLD.removed = 't'); END $$; -- Fix for post's comment count not updating after setting `removed` to 't' DROP TRIGGER IF EXISTS post_aggregates_comment_set_deleted ON comment; DROP FUNCTION post_aggregates_comment_deleted (); CREATE OR REPLACE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Check for post existence - it may not exist anymore IF TG_OP = 'INSERT' OR EXISTS ( SELECT 1 FROM post p WHERE p.id = OLD.post_id) THEN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE post_aggregates pa SET comments = comments + 1 WHERE pa.post_id = NEW.post_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE post_aggregates pa SET comments = comments - 1 WHERE pa.post_id = OLD.post_id; END IF; END IF; IF TG_OP = 'INSERT' THEN UPDATE post_aggregates pa SET newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time_necro = NEW.published FROM post p WHERE pa.post_id = p.id AND pa.post_id = NEW.post_id -- Fix issue with being able to necro-bump your own post AND NEW.creator_id != p.creator_id AND pa.published > ('now'::timestamp - '2 days'::interval); END IF; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER post_aggregates_comment_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW EXECUTE PROCEDURE post_aggregates_comment_count (); ================================================ FILE: migrations/2023-07-10-075550_add-infinite-scroll-setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN infinite_scroll_enabled; ================================================ FILE: migrations/2023-07-10-075550_add-infinite-scroll-setting/up.sql ================================================ ALTER TABLE local_user ADD COLUMN infinite_scroll_enabled boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2023-07-11-084714_receive_activity_table/down.sql ================================================ CREATE TABLE activity ( id serial PRIMARY KEY, data jsonb NOT NULL, local boolean NOT NULL DEFAULT TRUE, published timestamp NOT NULL DEFAULT now(), updated timestamp, ap_id text NOT NULL, sensitive boolean NOT NULL DEFAULT TRUE ); INSERT INTO activity (ap_id, data, sensitive, published) SELECT ap_id, data, sensitive, published FROM sent_activity ORDER BY id DESC LIMIT 100000; -- We cant copy received_activity entries back into activities table because we dont have data -- which is mandatory. DROP TABLE sent_activity; DROP TABLE received_activity; CREATE UNIQUE INDEX idx_activity_ap_id ON activity (ap_id); ================================================ FILE: migrations/2023-07-11-084714_receive_activity_table/up.sql ================================================ -- outgoing activities, need to be stored to be later server over http -- we change data column from jsonb to json for decreased size -- https://stackoverflow.com/a/22910602 CREATE TABLE sent_activity ( id bigserial PRIMARY KEY, ap_id text UNIQUE NOT NULL, data json NOT NULL, sensitive boolean NOT NULL, published timestamp NOT NULL DEFAULT now() ); -- incoming activities, we only need the id to avoid processing the same activity multiple times CREATE TABLE received_activity ( id bigserial PRIMARY KEY, ap_id text UNIQUE NOT NULL, published timestamp NOT NULL DEFAULT now() ); -- copy sent activities to new table. only copy last 100k for faster migration INSERT INTO sent_activity (ap_id, data, sensitive, published) SELECT ap_id, data, sensitive, published FROM activity WHERE local = TRUE ORDER BY id DESC LIMIT 100000; -- copy received activities to new table. only last 1m for faster migration INSERT INTO received_activity (ap_id, published) SELECT ap_id, published FROM activity WHERE local = FALSE ORDER BY id DESC LIMIT 1000000; DROP TABLE activity; ================================================ FILE: migrations/2023-07-14-154840_add_optimized_indexes_published/down.sql ================================================ -- Drop the new indexes DROP INDEX idx_post_aggregates_featured_local_most_comments; DROP INDEX idx_post_aggregates_featured_local_hot; DROP INDEX idx_post_aggregates_featured_local_active; DROP INDEX idx_post_aggregates_featured_local_score; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_score; DROP INDEX idx_post_aggregates_featured_community_most_comments; DROP INDEX idx_comment_aggregates_hot; DROP INDEX idx_comment_aggregates_score; -- Add the old ones back in -- featured_local CREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank_active DESC); CREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC); -- featured_community CREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC); CREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC); CREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC); ================================================ FILE: migrations/2023-07-14-154840_add_optimized_indexes_published/up.sql ================================================ -- Drop the old indexes DROP INDEX idx_post_aggregates_featured_local_hot; DROP INDEX idx_post_aggregates_featured_local_active; DROP INDEX idx_post_aggregates_featured_local_score; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_score; DROP INDEX idx_comment_aggregates_hot; DROP INDEX idx_comment_aggregates_score; -- Add a published desc, to the end of the hot and active ranks -- Add missing most comments index CREATE INDEX idx_post_aggregates_featured_local_most_comments ON post_aggregates (featured_local DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_most_comments ON post_aggregates (featured_community DESC, comments DESC, published DESC); -- featured_local CREATE INDEX idx_post_aggregates_featured_local_hot ON post_aggregates (featured_local DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON post_aggregates (featured_local DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_score ON post_aggregates (featured_local DESC, score DESC, published DESC); -- featured_community CREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC, published DESC); -- Fixing some comment aggregates ones CREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC, published DESC); CREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC, published DESC); ================================================ FILE: migrations/2023-07-14-215339_aggregates_nonzero_indexes/down.sql ================================================ -- This file should undo anything in `up.sql` DROP INDEX idx_community_aggregates_nonzero_hotrank; DROP INDEX idx_comment_aggregates_nonzero_hotrank; DROP INDEX idx_post_aggregates_nonzero_hotrank; ================================================ FILE: migrations/2023-07-14-215339_aggregates_nonzero_indexes/up.sql ================================================ -- Your SQL goes here CREATE INDEX idx_community_aggregates_nonzero_hotrank ON community_aggregates (published) WHERE hot_rank != 0; CREATE INDEX idx_comment_aggregates_nonzero_hotrank ON comment_aggregates (published) WHERE hot_rank != 0; CREATE INDEX idx_post_aggregates_nonzero_hotrank ON post_aggregates (published DESC) WHERE hot_rank != 0 OR hot_rank_active != 0; ================================================ FILE: migrations/2023-07-18-082614_post_aggregates_community_id/down.sql ================================================ -- This file should undo anything in `up.sql` CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro) VALUES (NEW.id, NEW.published, NEW.published, NEW.published); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; ALTER TABLE post_aggregates DROP COLUMN community_id, DROP COLUMN creator_id; ================================================ FILE: migrations/2023-07-18-082614_post_aggregates_community_id/up.sql ================================================ -- Your SQL goes here ALTER TABLE post_aggregates ADD COLUMN community_id integer REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN creator_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE; CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id) VALUES (NEW.id, NEW.published, NEW.published, NEW.published, NEW.community_id, NEW.creator_id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; UPDATE post_aggregates SET community_id = post.community_id, creator_id = post.creator_id FROM post WHERE post.id = post_aggregates.post_id; ALTER TABLE post_aggregates ALTER COLUMN community_id SET NOT NULL, ALTER COLUMN creator_id SET NOT NULL; ================================================ FILE: migrations/2023-07-19-163511_comment_sort_hot_rank_then_score/down.sql ================================================ DROP INDEX idx_comment_aggregates_hot, idx_comment_aggregates_score; CREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC, published DESC); CREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC, published DESC); ================================================ FILE: migrations/2023-07-19-163511_comment_sort_hot_rank_then_score/up.sql ================================================ -- Alter the comment_aggregates hot sort to sort by score after hot_rank. -- Reason being, is that hot_ranks go to zero after a few days, -- and then comments should be sorted by score, not published. DROP INDEX idx_comment_aggregates_hot, idx_comment_aggregates_score; CREATE INDEX idx_comment_aggregates_hot ON comment_aggregates (hot_rank DESC, score DESC); -- Remove published from this sort, its pointless CREATE INDEX idx_comment_aggregates_score ON comment_aggregates (score DESC); ================================================ FILE: migrations/2023-07-24-232635_trigram-index/down.sql ================================================ DROP INDEX idx_comment_content_trigram; DROP INDEX idx_post_trigram; DROP INDEX idx_person_trigram; DROP INDEX idx_community_trigram; DROP EXTENSION pg_trgm; ================================================ FILE: migrations/2023-07-24-232635_trigram-index/up.sql ================================================ CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX IF NOT EXISTS idx_comment_content_trigram ON comment USING gin (content gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_post_trigram ON post USING gin (name gin_trgm_ops, body gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_person_trigram ON person USING gin (name gin_trgm_ops, display_name gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_community_trigram ON community USING gin (name gin_trgm_ops, title gin_trgm_ops); ================================================ FILE: migrations/2023-07-26-000217_create_controversial_indexes/down.sql ================================================ -- Update comment_aggregates_score trigger function to exclude controversy_rank update CREATE OR REPLACE FUNCTION comment_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE comment_aggregates ca SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END WHERE ca.comment_id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to comment because that comment may not exist anymore UPDATE comment_aggregates ca SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END FROM comment c WHERE ca.comment_id = c.id AND ca.comment_id = OLD.comment_id; END IF; RETURN NULL; END $$; -- Update post_aggregates_score trigger function to exclude controversy_rank update CREATE OR REPLACE FUNCTION post_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END WHERE pa.post_id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; -- Drop the indexes DROP INDEX IF EXISTS idx_post_aggregates_featured_local_controversy; DROP INDEX IF EXISTS idx_post_aggregates_featured_community_controversy; DROP INDEX IF EXISTS idx_comment_aggregates_controversy; -- Remove the added columns from the tables ALTER TABLE post_aggregates DROP COLUMN controversy_rank; ALTER TABLE comment_aggregates DROP COLUMN controversy_rank; -- Remove function DROP FUNCTION controversy_rank (numeric, numeric); ================================================ FILE: migrations/2023-07-26-000217_create_controversial_indexes/up.sql ================================================ -- Need to add immutable to the controversy_rank function in order to index by it -- Controversy Rank: -- if downvotes <= 0 or upvotes <= 0: -- 0 -- else: -- (upvotes + downvotes) * min(upvotes, downvotes) / max(upvotes, downvotes) CREATE OR REPLACE FUNCTION controversy_rank (upvotes numeric, downvotes numeric) RETURNS float AS $$ BEGIN IF downvotes <= 0 OR upvotes <= 0 THEN RETURN 0; ELSE RETURN (upvotes + downvotes) * CASE WHEN upvotes > downvotes THEN downvotes::float / upvotes::float ELSE upvotes::float / downvotes::float END; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE; -- Aggregates ALTER TABLE post_aggregates ADD COLUMN controversy_rank float NOT NULL DEFAULT 0; ALTER TABLE comment_aggregates ADD COLUMN controversy_rank float NOT NULL DEFAULT 0; -- Populate them initially -- Note: After initial population, these are updated with vote triggers UPDATE post_aggregates SET controversy_rank = controversy_rank (upvotes::numeric, downvotes::numeric); UPDATE comment_aggregates SET controversy_rank = controversy_rank (upvotes::numeric, downvotes::numeric); -- Create single column indexes CREATE INDEX idx_post_aggregates_featured_local_controversy ON post_aggregates (featured_local DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_featured_community_controversy ON post_aggregates (featured_community DESC, controversy_rank DESC); CREATE INDEX idx_comment_aggregates_controversy ON comment_aggregates (controversy_rank DESC); -- Update post_aggregates_score trigger function to include controversy_rank update CREATE OR REPLACE FUNCTION post_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END, controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) WHERE pa.post_id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END, controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; -- Update comment_aggregates_score trigger function to include controversy_rank update CREATE OR REPLACE FUNCTION comment_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE comment_aggregates ca SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END, controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) WHERE ca.comment_id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to comment because that comment may not exist anymore UPDATE comment_aggregates ca SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END, controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) FROM comment c WHERE ca.comment_id = c.id AND ca.comment_id = OLD.comment_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-07-26-222023_site-aggregates-one/down.sql ================================================ CREATE OR REPLACE FUNCTION site_aggregates_site () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO site_aggregates (site_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM site_aggregates WHERE site_id = OLD.id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-07-26-222023_site-aggregates-one/up.sql ================================================ CREATE OR REPLACE FUNCTION site_aggregates_site () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table. -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests IF (TG_OP = 'INSERT') AND NOT EXISTS ( SELECT id FROM site_aggregates LIMIT 1) THEN INSERT INTO site_aggregates (site_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM site_aggregates WHERE site_id = OLD.id; END IF; RETURN NULL; END $$; DELETE FROM site_aggregates a WHERE NOT EXISTS ( SELECT id FROM local_site s WHERE s.site_id = a.site_id); ================================================ FILE: migrations/2023-07-27-134652_remove-expensive-broken-trigger/down.sql ================================================ CREATE OR REPLACE FUNCTION person_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count - 1 WHERE person_id = OLD.creator_id; -- If the comment gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET comment_score = cd.score FROM ( SELECT u.id, coalesce(0, sum(cl.score)) AS score -- User join because comments could be empty FROM person u LEFT JOIN comment c ON u.id = c.creator_id AND c.deleted = 'f' AND c.removed = 'f' LEFT JOIN comment_like cl ON c.id = cl.comment_id GROUP BY u.id) cd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION person_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count - 1 WHERE person_id = OLD.creator_id; -- If the post gets deleted, the score calculation trigger won't fire, -- so you need to re-calculate UPDATE person_aggregates ua SET post_score = pd.score FROM ( SELECT u.id, coalesce(0, sum(pl.score)) AS score -- User join because posts could be empty FROM person u LEFT JOIN post p ON u.id = p.creator_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN post_like pl ON p.id = pl.post_id GROUP BY u.id) pd WHERE ua.person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION community_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments + 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = NEW.post_id AND ca.community_id = p.community_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments - 1 FROM comment c, post p WHERE p.id = c.post_id AND p.id = OLD.post_id AND ca.community_id = p.community_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-07-27-134652_remove-expensive-broken-trigger/up.sql ================================================ CREATE OR REPLACE FUNCTION person_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count - 1 WHERE person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION person_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count - 1 WHERE person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION community_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments + 1 FROM post p WHERE p.id = NEW.post_id AND ca.community_id = p.community_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments - 1 FROM post p WHERE p.id = OLD.post_id AND ca.community_id = p.community_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-08-01-101826_admin_flag_local_user/down.sql ================================================ ALTER TABLE person ADD COLUMN admin boolean DEFAULT FALSE NOT NULL; UPDATE person SET admin = TRUE FROM local_user WHERE local_user.person_id = person.id AND local_user.admin; ALTER TABLE local_user DROP COLUMN admin; CREATE INDEX idx_person_admin ON person (admin) WHERE admin; ================================================ FILE: migrations/2023-08-01-101826_admin_flag_local_user/up.sql ================================================ ALTER TABLE local_user ADD COLUMN admin boolean DEFAULT FALSE NOT NULL; UPDATE local_user SET admin = TRUE FROM person WHERE local_user.person_id = person.id AND person.admin; ALTER TABLE person DROP COLUMN admin; ================================================ FILE: migrations/2023-08-01-115243_persistent-activity-queue/down.sql ================================================ ALTER TABLE sent_activity DROP COLUMN send_inboxes, DROP COLUMN send_community_followers_of, DROP COLUMN send_all_instances, DROP COLUMN actor_apub_id, DROP COLUMN actor_type; DROP TYPE actor_type_enum; DROP TABLE federation_queue_state; DROP INDEX idx_community_follower_published; ================================================ FILE: migrations/2023-08-01-115243_persistent-activity-queue/up.sql ================================================ CREATE TYPE actor_type_enum AS enum ( 'site', 'community', 'person' ); -- actor_apub_id only null for old entries before this migration ALTER TABLE sent_activity ADD COLUMN send_inboxes text[] NOT NULL DEFAULT '{}', -- list of specific inbox urls ADD COLUMN send_community_followers_of integer DEFAULT NULL, ADD COLUMN send_all_instances boolean NOT NULL DEFAULT FALSE, ADD COLUMN actor_type actor_type_enum NOT NULL DEFAULT 'person', ADD COLUMN actor_apub_id text DEFAULT NULL; ALTER TABLE sent_activity ALTER COLUMN send_inboxes DROP DEFAULT, ALTER COLUMN send_community_followers_of DROP DEFAULT, ALTER COLUMN send_all_instances DROP DEFAULT, ALTER COLUMN actor_type DROP DEFAULT, ALTER COLUMN actor_apub_id DROP DEFAULT; CREATE TABLE federation_queue_state ( id serial PRIMARY KEY, instance_id integer NOT NULL UNIQUE REFERENCES instance (id), last_successful_id bigint NOT NULL, fail_count integer NOT NULL, last_retry timestamptz NOT NULL ); -- for incremental fetches of followers CREATE INDEX idx_community_follower_published ON community_follower (published); ================================================ FILE: migrations/2023-08-02-144930_password-reset-token/down.sql ================================================ ALTER TABLE password_reset_request RENAME COLUMN token TO token_encrypted; ================================================ FILE: migrations/2023-08-02-144930_password-reset-token/up.sql ================================================ ALTER TABLE password_reset_request RENAME COLUMN token_encrypted TO token; ================================================ FILE: migrations/2023-08-02-174444_fix-timezones/down.sql ================================================ SET timezone TO utc; ALTER TABLE community_moderator ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community_follower ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE person_ban ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community_person_ban ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community_person_ban ALTER COLUMN expires TYPE timestamp USING expires; ALTER TABLE person ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE person ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE person ALTER COLUMN last_refreshed_at TYPE timestamp USING last_refreshed_at; ALTER TABLE person ALTER COLUMN ban_expires TYPE timestamp USING ban_expires; ALTER TABLE post_like ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE post_saved ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE post_read ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE comment_like ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE comment_saved ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE comment ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE comment ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE mod_remove_post ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_lock_post ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_remove_comment ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_remove_community ALTER COLUMN expires TYPE timestamp USING expires; ALTER TABLE mod_remove_community ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_ban_from_community ALTER COLUMN expires TYPE timestamp USING expires; ALTER TABLE mod_ban_from_community ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_ban ALTER COLUMN expires TYPE timestamp USING expires; ALTER TABLE mod_ban ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_add_community ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE mod_add ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE person_mention ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE mod_feature_post ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE password_reset_request ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE private_message ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE private_message ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE sent_activity ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE received_activity ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE community ALTER COLUMN last_refreshed_at TYPE timestamp USING last_refreshed_at; ALTER TABLE post ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE post ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE comment_report ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE comment_report ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE post_report ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE post_report ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE post_aggregates ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE post_aggregates ALTER COLUMN newest_comment_time_necro TYPE timestamp USING newest_comment_time_necro; ALTER TABLE post_aggregates ALTER COLUMN newest_comment_time TYPE timestamp USING newest_comment_time; ALTER TABLE comment_aggregates ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community_block ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE community_aggregates ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE mod_transfer_community ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE person_block ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE local_user ALTER COLUMN validator_time TYPE timestamp USING validator_time; ALTER TABLE admin_purge_person ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE email_verification ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE admin_purge_community ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE admin_purge_post ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE admin_purge_comment ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE registration_application ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE mod_hide_community ALTER COLUMN when_ TYPE timestamp USING when_; ALTER TABLE site ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE site ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE site ALTER COLUMN last_refreshed_at TYPE timestamp USING last_refreshed_at; ALTER TABLE comment_reply ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE person_post_aggregates ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE private_message_report ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE private_message_report ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE local_site ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE local_site ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE federation_allowlist ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE federation_allowlist ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE federation_blocklist ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE federation_blocklist ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE local_site_rate_limit ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE local_site_rate_limit ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE person_follower ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE tagline ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE tagline ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE custom_emoji ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE custom_emoji ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE instance ALTER COLUMN published TYPE timestamp USING published; ALTER TABLE instance ALTER COLUMN updated TYPE timestamp USING updated; ALTER TABLE captcha_answer ALTER COLUMN published TYPE timestamp USING published; DROP FUNCTION hot_rank; CREATE FUNCTION hot_rank (score numeric, published timestamp without time zone) RETURNS integer AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (timezone('utc', now()) - published)) / 3600; BEGIN IF (hours_diff > 0) THEN RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer; ELSE RETURN 0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ================================================ FILE: migrations/2023-08-02-174444_fix-timezones/up.sql ================================================ DROP FUNCTION IF EXISTS hot_rank CASCADE; SET timezone = 'UTC'; -- Allow ALTER TABLE ... SET DATA TYPE changing between timestamp and timestamptz to avoid a table rewrite when the session time zone is UTC (Noah Misch) -- In the UTC time zone, these two data types are binary compatible. ALTER TABLE community_moderator ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community_follower ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE person_ban ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community_person_ban ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community_person_ban ALTER COLUMN expires TYPE timestamptz USING expires; ALTER TABLE person ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE person ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE person ALTER COLUMN last_refreshed_at TYPE timestamptz USING last_refreshed_at; ALTER TABLE person ALTER COLUMN ban_expires TYPE timestamptz USING ban_expires; ALTER TABLE post_like ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE post_saved ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE post_read ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE comment_like ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE comment_saved ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE comment ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE comment ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE mod_remove_post ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_lock_post ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_remove_comment ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_remove_community ALTER COLUMN expires TYPE timestamptz USING expires; ALTER TABLE mod_remove_community ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_ban_from_community ALTER COLUMN expires TYPE timestamptz USING expires; ALTER TABLE mod_ban_from_community ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_ban ALTER COLUMN expires TYPE timestamptz USING expires; ALTER TABLE mod_ban ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_add_community ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE mod_add ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE person_mention ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE mod_feature_post ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE password_reset_request ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE private_message ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE private_message ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE sent_activity ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE received_activity ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE community ALTER COLUMN last_refreshed_at TYPE timestamptz USING last_refreshed_at; ALTER TABLE post ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE post ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE comment_report ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE comment_report ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE post_report ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE post_report ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE post_aggregates ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE post_aggregates ALTER COLUMN newest_comment_time_necro TYPE timestamptz USING newest_comment_time_necro; ALTER TABLE post_aggregates ALTER COLUMN newest_comment_time TYPE timestamptz USING newest_comment_time; ALTER TABLE comment_aggregates ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community_block ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE community_aggregates ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE mod_transfer_community ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE person_block ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE local_user ALTER COLUMN validator_time TYPE timestamptz USING validator_time; ALTER TABLE admin_purge_person ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE email_verification ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE admin_purge_community ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE admin_purge_post ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE admin_purge_comment ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE registration_application ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE mod_hide_community ALTER COLUMN when_ TYPE timestamptz USING when_; ALTER TABLE site ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE site ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE site ALTER COLUMN last_refreshed_at TYPE timestamptz USING last_refreshed_at; ALTER TABLE comment_reply ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE person_post_aggregates ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE private_message_report ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE private_message_report ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE local_site ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE local_site ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE federation_allowlist ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE federation_allowlist ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE federation_blocklist ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE federation_blocklist ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE local_site_rate_limit ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE local_site_rate_limit ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE person_follower ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE tagline ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE tagline ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE custom_emoji ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE custom_emoji ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE instance ALTER COLUMN published TYPE timestamptz USING published; ALTER TABLE instance ALTER COLUMN updated TYPE timestamptz USING updated; ALTER TABLE captcha_answer ALTER COLUMN published TYPE timestamptz USING published; -- same as before just with time zone argument CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone) RETURNS integer AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600; BEGIN IF (hours_diff > 0) THEN RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer; ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future RETURN 0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ================================================ FILE: migrations/2023-08-08-163911_add_post_listing_mode_setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN post_listing_mode; DROP TYPE post_listing_mode_enum; ================================================ FILE: migrations/2023-08-08-163911_add_post_listing_mode_setting/up.sql ================================================ CREATE TYPE post_listing_mode_enum AS enum ( 'List', 'Card', 'SmallCard' ); ALTER TABLE local_user ADD COLUMN post_listing_mode post_listing_mode_enum DEFAULT 'List' NOT NULL; ================================================ FILE: migrations/2023-08-09-101305_user_instance_block/down.sql ================================================ DROP TABLE instance_block; ALTER TABLE post_aggregates DROP COLUMN instance_id; CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id) VALUES (NEW.id, NEW.published, NEW.published, NEW.published, NEW.community_id, NEW.creator_id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-08-09-101305_user_instance_block/up.sql ================================================ CREATE TABLE instance_block ( id serial PRIMARY KEY, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz NOT NULL DEFAULT now(), UNIQUE (person_id, instance_id) ); ALTER TABLE post_aggregates ADD COLUMN instance_id integer REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE; CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id) SELECT NEW.id, NEW.published, NEW.published, NEW.published, NEW.community_id, NEW.creator_id, community.instance_id FROM community WHERE NEW.community_id = community.id; ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; UPDATE post_aggregates SET instance_id = community.instance_id FROM post JOIN community ON post.community_id = community.id WHERE post.id = post_aggregates.post_id; ALTER TABLE post_aggregates ALTER COLUMN instance_id SET NOT NULL; ================================================ FILE: migrations/2023-08-23-182533_scaled_rank/down.sql ================================================ DROP FUNCTION scaled_rank; ALTER TABLE community_aggregates ALTER COLUMN hot_rank TYPE integer, ALTER COLUMN hot_rank SET DEFAULT 1728; ALTER TABLE comment_aggregates ALTER COLUMN hot_rank TYPE integer, ALTER COLUMN hot_rank SET DEFAULT 1728; ALTER TABLE post_aggregates ALTER COLUMN hot_rank TYPE integer, ALTER COLUMN hot_rank SET DEFAULT 1728, ALTER COLUMN hot_rank_active TYPE integer, ALTER COLUMN hot_rank_active SET DEFAULT 1728; -- Change back to integer version DROP FUNCTION hot_rank (numeric, published timestamp with time zone); CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone) RETURNS integer AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600; BEGIN IF (hours_diff > 0) THEN RETURN floor(10000 * log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8))::integer; ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future RETURN 0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ALTER TABLE post_aggregates DROP COLUMN scaled_rank; -- The following code is necessary because postgres can't remove -- a single enum value. ALTER TABLE local_user ALTER default_sort_type DROP DEFAULT; UPDATE local_user SET default_sort_type = 'Hot' WHERE default_sort_type = 'Scaled'; -- rename the old enum ALTER TYPE sort_type_enum RENAME TO sort_type_enum__; -- create the new enum CREATE TYPE sort_type_enum AS ENUM ( 'Active', 'Hot', 'New', 'Old', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'MostComments', 'NewComments', 'TopHour', 'TopSixHour', 'TopTwelveHour', 'TopThreeMonths', 'TopSixMonths', 'TopNineMonths' ); -- alter all your enum columns ALTER TABLE local_user ALTER COLUMN default_sort_type TYPE sort_type_enum USING default_sort_type::text::sort_type_enum; ALTER TABLE local_user ALTER default_sort_type SET DEFAULT 'Active'; -- drop the old enum DROP TYPE sort_type_enum__; -- Remove int to float conversions that were automatically added to index filters DROP INDEX idx_comment_aggregates_nonzero_hotrank, idx_community_aggregates_nonzero_hotrank, idx_post_aggregates_nonzero_hotrank; CREATE INDEX idx_community_aggregates_nonzero_hotrank ON community_aggregates (published) WHERE hot_rank != 0; CREATE INDEX idx_comment_aggregates_nonzero_hotrank ON comment_aggregates (published) WHERE hot_rank != 0; CREATE INDEX idx_post_aggregates_nonzero_hotrank ON post_aggregates (published DESC) WHERE hot_rank != 0 OR hot_rank_active != 0; ================================================ FILE: migrations/2023-08-23-182533_scaled_rank/up.sql ================================================ -- Change hot ranks and functions from an int to a float ALTER TABLE community_aggregates ALTER COLUMN hot_rank TYPE float, ALTER COLUMN hot_rank SET DEFAULT 0.1728; ALTER TABLE comment_aggregates ALTER COLUMN hot_rank TYPE float, ALTER COLUMN hot_rank SET DEFAULT 0.1728; ALTER TABLE post_aggregates ALTER COLUMN hot_rank TYPE float, ALTER COLUMN hot_rank SET DEFAULT 0.1728, ALTER COLUMN hot_rank_active TYPE float, ALTER COLUMN hot_rank_active SET DEFAULT 0.1728; DROP FUNCTION hot_rank (numeric, published timestamp with time zone); CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone) RETURNS float AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600; BEGIN -- 24 * 7 = 168, so after a week, it will default to 0. IF (hours_diff > 0 AND hours_diff < 168) THEN RETURN log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8); ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future RETURN 0.0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; -- The new scaled rank function CREATE OR REPLACE FUNCTION scaled_rank (score numeric, published timestamp with time zone, users_active_month numeric) RETURNS float AS $$ BEGIN -- Add 2 to avoid divide by zero errors -- Default for score = 1, active users = 1, and now, is (0.1728 / log(2 + 1)) = 0.3621 -- There may need to be a scale factor multiplied to users_active_month, to make -- the log curve less pronounced. This can be tuned in the future. RETURN (hot_rank (score, published) / log(2 + users_active_month)); END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ALTER TABLE post_aggregates ADD COLUMN scaled_rank float NOT NULL DEFAULT 0.3621; UPDATE post_aggregates SET scaled_rank = 0 WHERE hot_rank = 0 OR hot_rank_active = 0; CREATE INDEX idx_post_aggregates_featured_community_scaled ON post_aggregates (featured_community DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_scaled ON post_aggregates (featured_local DESC, scaled_rank DESC, published DESC); -- We forgot to add the controversial sort type ALTER TYPE sort_type_enum ADD VALUE 'Controversial'; -- Add the Scaled enum ALTER TYPE sort_type_enum ADD VALUE 'Scaled'; ================================================ FILE: migrations/2023-08-29-183053_add_listing_type_moderator_view/down.sql ================================================ ALTER TABLE local_user ALTER default_listing_type DROP DEFAULT; ALTER TABLE local_site ALTER default_post_listing_type DROP DEFAULT; UPDATE local_user SET default_listing_type = 'Local' WHERE default_listing_type = 'ModeratorView'; UPDATE local_site SET default_post_listing_type = 'Local' WHERE default_post_listing_type = 'ModeratorView'; -- rename the old enum ALTER TYPE listing_type_enum RENAME TO listing_type_enum__; -- create the new enum CREATE TYPE listing_type_enum AS ENUM ( 'All', 'Local', 'Subscribed' ); -- alter all your enum columns ALTER TABLE local_user ALTER COLUMN default_listing_type TYPE listing_type_enum USING default_listing_type::text::listing_type_enum; ALTER TABLE local_site ALTER COLUMN default_post_listing_type TYPE listing_type_enum USING default_post_listing_type::text::listing_type_enum; -- Add back in the default ALTER TABLE local_user ALTER default_listing_type SET DEFAULT 'Local'; ALTER TABLE local_site ALTER default_post_listing_type SET DEFAULT 'Local'; -- drop the old enum DROP TYPE listing_type_enum__; ================================================ FILE: migrations/2023-08-29-183053_add_listing_type_moderator_view/up.sql ================================================ -- Update the listing_type_enum ALTER TYPE listing_type_enum ADD VALUE 'ModeratorView'; ================================================ FILE: migrations/2023-08-31-205559_add_image_upload/down.sql ================================================ DROP TABLE image_upload; ================================================ FILE: migrations/2023-08-31-205559_add_image_upload/up.sql ================================================ CREATE TABLE image_upload ( id serial PRIMARY KEY, local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, pictrs_alias text NOT NULL UNIQUE, pictrs_delete_token text NOT NULL, published timestamptz DEFAULT now() NOT NULL ); CREATE INDEX idx_image_upload_local_user_id ON image_upload (local_user_id); ================================================ FILE: migrations/2023-09-01-112158_auto_resolve_report/down.sql ================================================ DROP TRIGGER IF EXISTS post_removed_resolve_reports ON mod_remove_post; DROP FUNCTION IF EXISTS post_removed_resolve_reports; DROP TRIGGER IF EXISTS comment_removed_resolve_reports ON mod_remove_comment; DROP FUNCTION IF EXISTS comment_removed_resolve_reports; ================================================ FILE: migrations/2023-09-01-112158_auto_resolve_report/up.sql ================================================ -- Automatically resolve all reports for a given post once it is marked as removed CREATE OR REPLACE FUNCTION post_removed_resolve_reports () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_report SET resolved = TRUE, resolver_id = NEW.mod_person_id, updated = now() WHERE post_report.post_id = NEW.post_id; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER post_removed_resolve_reports AFTER INSERT ON mod_remove_post FOR EACH ROW WHEN (NEW.removed) EXECUTE PROCEDURE post_removed_resolve_reports (); -- Same when comment is marked as removed CREATE OR REPLACE FUNCTION comment_removed_resolve_reports () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE comment_report SET resolved = TRUE, resolver_id = NEW.mod_person_id, updated = now() WHERE comment_report.comment_id = NEW.comment_id; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER comment_removed_resolve_reports AFTER INSERT ON mod_remove_comment FOR EACH ROW WHEN (NEW.removed) EXECUTE PROCEDURE comment_removed_resolve_reports (); ================================================ FILE: migrations/2023-09-07-215546_post-queries-efficient/down.sql ================================================ DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_controversy; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_scaled; DROP INDEX idx_post_aggregates_featured_community_most_comments; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_community_published; DROP INDEX idx_post_aggregates_featured_community_score; CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (featured_community DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_controversy ON post_aggregates (featured_community DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (featured_community DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_scaled ON post_aggregates (featured_community DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_most_comments ON post_aggregates (featured_community DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (featured_community DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necro ON post_aggregates (featured_community DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (featured_community DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (featured_community DESC, score DESC, published DESC); DROP INDEX idx_post_aggregates_community_active; DROP INDEX idx_post_aggregates_community_controversy; DROP INDEX idx_post_aggregates_community_hot; DROP INDEX idx_post_aggregates_community_scaled; DROP INDEX idx_post_aggregates_community_most_comments; DROP INDEX idx_post_aggregates_community_newest_comment_time; DROP INDEX idx_post_aggregates_community_newest_comment_time_necro; DROP INDEX idx_post_aggregates_community_published; DROP INDEX idx_post_aggregates_community_score; ================================================ FILE: migrations/2023-09-07-215546_post-queries-efficient/up.sql ================================================ -- these indices are used for single-community filtering and for the followed-communities (front-page) query -- basically one index per Sort -- index name is truncated to 63 chars so abbreviate a bit CREATE INDEX idx_post_aggregates_community_active ON post_aggregates (community_id, featured_local DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_community_controversy ON post_aggregates (community_id, featured_local DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_community_hot ON post_aggregates (community_id, featured_local DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_community_scaled ON post_aggregates (community_id, featured_local DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_community_most_comments ON post_aggregates (community_id, featured_local DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_community_newest_comment_time ON post_aggregates (community_id, featured_local DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_community_newest_comment_time_necro ON post_aggregates (community_id, featured_local DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_community_published ON post_aggregates (community_id, featured_local DESC, published DESC); CREATE INDEX idx_post_aggregates_community_score ON post_aggregates (community_id, featured_local DESC, score DESC, published DESC); -- these indices are used for "per-community" filtering -- these indices weren't really useful because whenever the query filters by featured_community it also filters by community_id, so prepend that to all these indexes DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_controversy; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_scaled; DROP INDEX idx_post_aggregates_featured_community_most_comments; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_community_published; DROP INDEX idx_post_aggregates_featured_community_score; CREATE INDEX idx_post_aggregates_featured_community_active ON post_aggregates (community_id, featured_community DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_controversy ON post_aggregates (community_id, featured_community DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON post_aggregates (community_id, featured_community DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_scaled ON post_aggregates (community_id, featured_community DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_most_comments ON post_aggregates (community_id, featured_community DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates (community_id, featured_community DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necro ON post_aggregates (community_id, featured_community DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON post_aggregates (community_id, featured_community DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON post_aggregates (community_id, featured_community DESC, score DESC, published DESC); ================================================ FILE: migrations/2023-09-11-110040_rework-2fa-setup/down.sql ================================================ ALTER TABLE local_user ADD COLUMN totp_2fa_url text; ALTER TABLE local_user DROP COLUMN totp_2fa_enabled; ================================================ FILE: migrations/2023-09-11-110040_rework-2fa-setup/up.sql ================================================ ALTER TABLE local_user DROP COLUMN totp_2fa_url; ALTER TABLE local_user ADD COLUMN totp_2fa_enabled boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2023-09-12-194850_add_federation_worker_index/down.sql ================================================ DROP INDEX idx_person_local_instance; ================================================ FILE: migrations/2023-09-12-194850_add_federation_worker_index/up.sql ================================================ CREATE INDEX idx_person_local_instance ON person (local DESC, instance_id); ================================================ FILE: migrations/2023-09-18-141700_login-token/down.sql ================================================ DROP TABLE login_token; ALTER TABLE local_user ADD COLUMN validator_time timestamptz NOT NULL DEFAULT now(); ================================================ FILE: migrations/2023-09-18-141700_login-token/up.sql ================================================ CREATE TABLE login_token ( id serial PRIMARY KEY, token text NOT NULL UNIQUE, user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz NOT NULL DEFAULT now(), ip text, user_agent text ); CREATE INDEX idx_login_token_user_token ON login_token (user_id, token); -- not needed anymore as we invalidate login tokens on password change ALTER TABLE local_user DROP COLUMN validator_time; ================================================ FILE: migrations/2023-09-20-110614_drop-show-new-post-notifs/down.sql ================================================ ALTER TABLE local_user ADD COLUMN show_new_post_notifs boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2023-09-20-110614_drop-show-new-post-notifs/up.sql ================================================ -- this setting is unused with websocket gone ALTER TABLE local_user DROP COLUMN show_new_post_notifs; ================================================ FILE: migrations/2023-09-28-084231_import_user_settings_rate_limit/down.sql ================================================ ALTER TABLE local_site_rate_limit DROP COLUMN import_user_settings; ALTER TABLE local_site_rate_limit DROP COLUMN import_user_settings_per_second; ================================================ FILE: migrations/2023-09-28-084231_import_user_settings_rate_limit/up.sql ================================================ ALTER TABLE local_site_rate_limit ADD COLUMN import_user_settings int NOT NULL DEFAULT 1; ALTER TABLE local_site_rate_limit ADD COLUMN import_user_settings_per_second int NOT NULL DEFAULT 86400; ================================================ FILE: migrations/2023-10-02-145002_community_followers_count_federated/down.sql ================================================ CREATE OR REPLACE FUNCTION community_aggregates_subscriber_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates SET subscribers = subscribers + 1 WHERE community_id = NEW.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates SET subscribers = subscribers - 1 WHERE community_id = OLD.community_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-10-02-145002_community_followers_count_federated/up.sql ================================================ -- The subscriber count should only be updated for local communities. For remote -- communities it is read over federation from the origin instance. CREATE OR REPLACE FUNCTION community_aggregates_subscriber_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates SET subscribers = subscribers + 1 FROM community WHERE community.id = community_id AND community.local AND community_id = NEW.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates SET subscribers = subscribers - 1 FROM community WHERE community.id = community_id AND community.local AND community_id = OLD.community_id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-10-06-133405_add_keyboard_navigation_setting/down.sql ================================================ ALTER TABLE local_user DROP COLUMN enable_keyboard_navigation; ================================================ FILE: migrations/2023-10-06-133405_add_keyboard_navigation_setting/up.sql ================================================ ALTER TABLE local_user ADD COLUMN enable_keyboard_navigation boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2023-10-13-175712_allow_animated_avatars/down.sql ================================================ ALTER TABLE local_user DROP COLUMN enable_animated_images; ================================================ FILE: migrations/2023-10-13-175712_allow_animated_avatars/up.sql ================================================ ALTER TABLE local_user ADD COLUMN enable_animated_images boolean DEFAULT TRUE NOT NULL; ================================================ FILE: migrations/2023-10-17-181800_drop_remove_community_expires/down.sql ================================================ ALTER TABLE mod_remove_community ADD COLUMN expires timestamptz; ================================================ FILE: migrations/2023-10-17-181800_drop_remove_community_expires/up.sql ================================================ ALTER TABLE mod_remove_community DROP COLUMN expires; ================================================ FILE: migrations/2023-10-23-184941_hot_rank_greatest_fix/down.sql ================================================ CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone) RETURNS float AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600; BEGIN -- 24 * 7 = 168, so after a week, it will default to 0. IF (hours_diff > 0 AND hours_diff < 168) THEN RETURN log(greatest (1, score + 3)) / power((hours_diff + 2), 1.8); ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future RETURN 0.0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ================================================ FILE: migrations/2023-10-23-184941_hot_rank_greatest_fix/up.sql ================================================ -- The hot_rank algorithm currently uses greatest(1, score + 3) -- This greatest of 1 incorrect because log10(1) is zero, -- so it will push negative-voted comments / posts to the bottom, IE hot_rank = 0 -- The update_scheduled_ranks will never recalculate them, because it ignores content -- with hot_rank = 0 CREATE OR REPLACE FUNCTION hot_rank (score numeric, published timestamp with time zone) RETURNS float AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600; BEGIN -- 24 * 7 = 168, so after a week, it will default to 0. IF (hours_diff > 0 AND hours_diff < 168) THEN -- Use greatest(2,score), so that the hot_rank will be positive and not ignored. RETURN log(greatest (2, score + 2)) / power((hours_diff + 2), 1.8); ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future RETURN 0.0; END IF; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; ================================================ FILE: migrations/2023-10-24-030352_change_primary_keys_and_remove_some_id_columns/down.sql ================================================ ALTER TABLE captcha_answer ADD UNIQUE (uuid), DROP CONSTRAINT captcha_answer_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE comment_aggregates ADD UNIQUE (comment_id), DROP CONSTRAINT comment_aggregates_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE INDEX idx_comment_like_person ON comment_like (person_id); ALTER TABLE comment_like ADD UNIQUE (comment_id, person_id), DROP CONSTRAINT comment_like_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE INDEX idx_comment_saved_person_id ON comment_saved (person_id); ALTER TABLE comment_saved ADD UNIQUE (comment_id, person_id), DROP CONSTRAINT comment_saved_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE community_aggregates ADD UNIQUE (community_id), DROP CONSTRAINT community_aggregates_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE INDEX idx_community_block_person ON community_block (person_id); ALTER TABLE community_block ADD UNIQUE (person_id, community_id), DROP CONSTRAINT community_block_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE INDEX idx_community_follower_person ON community_follower (person_id); ALTER TABLE community_follower ADD UNIQUE (community_id, person_id), DROP CONSTRAINT community_follower_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE community_language ADD UNIQUE (community_id, language_id), DROP CONSTRAINT community_language_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE INDEX idx_community_moderator_person ON community_moderator (person_id); ALTER TABLE community_moderator ADD UNIQUE (community_id, person_id), DROP CONSTRAINT community_moderator_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE community_person_ban ADD UNIQUE (community_id, person_id), DROP CONSTRAINT community_person_ban_pkey, ADD COLUMN id serial PRIMARY KEY CONSTRAINT community_user_ban_id_not_null NOT NULL; ALTER TABLE custom_emoji_keyword ADD UNIQUE (custom_emoji_id, keyword), DROP CONSTRAINT custom_emoji_keyword_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE federation_allowlist ADD UNIQUE (instance_id), DROP CONSTRAINT federation_allowlist_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE federation_blocklist ADD UNIQUE (instance_id), DROP CONSTRAINT federation_blocklist_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE federation_queue_state ADD UNIQUE (instance_id), DROP CONSTRAINT federation_queue_state_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE image_upload ADD UNIQUE (pictrs_alias), DROP CONSTRAINT image_upload_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE instance_block ADD UNIQUE (person_id, instance_id), DROP CONSTRAINT instance_block_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE local_site_rate_limit ADD UNIQUE (local_site_id), DROP CONSTRAINT local_site_rate_limit_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE local_user_language ADD UNIQUE (local_user_id, language_id), DROP CONSTRAINT local_user_language_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE login_token ADD UNIQUE (token), DROP CONSTRAINT login_token_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE person_aggregates ADD UNIQUE (person_id), DROP CONSTRAINT person_aggregates_pkey, ADD COLUMN id serial PRIMARY KEY CONSTRAINT user_aggregates_id_not_null NOT NULL; ALTER TABLE person_ban ADD UNIQUE (person_id), DROP CONSTRAINT person_ban_pkey, ADD COLUMN id serial PRIMARY KEY CONSTRAINT user_ban_id_not_null NOT NULL; ALTER TABLE person_block ADD UNIQUE (person_id, target_id), DROP CONSTRAINT person_block_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE person_follower ADD UNIQUE (follower_id, person_id), DROP CONSTRAINT person_follower_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE person_post_aggregates ADD UNIQUE (person_id, post_id), DROP CONSTRAINT person_post_aggregates_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE post_aggregates ADD UNIQUE (post_id), DROP CONSTRAINT post_aggregates_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE INDEX idx_post_like_person ON post_like (person_id); ALTER TABLE post_like ADD UNIQUE (post_id, person_id), DROP CONSTRAINT post_like_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE post_read ADD UNIQUE (post_id, person_id), DROP CONSTRAINT post_read_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE received_activity ADD UNIQUE (ap_id), DROP CONSTRAINT received_activity_pkey, ADD COLUMN id bigserial PRIMARY KEY; CREATE INDEX idx_post_saved_person_id ON post_saved (person_id); ALTER TABLE post_saved ADD UNIQUE (post_id, person_id), DROP CONSTRAINT post_saved_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE site_aggregates DROP CONSTRAINT site_aggregates_pkey, ADD COLUMN id serial PRIMARY KEY; ALTER TABLE site_language ADD UNIQUE (site_id, language_id), DROP CONSTRAINT site_language_pkey, ADD COLUMN id serial PRIMARY KEY; CREATE OR REPLACE FUNCTION site_aggregates_site () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table. -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests IF (TG_OP = 'INSERT') AND NOT EXISTS ( SELECT id FROM site_aggregates LIMIT 1) THEN INSERT INTO site_aggregates (site_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM site_aggregates WHERE site_id = OLD.id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-10-24-030352_change_primary_keys_and_remove_some_id_columns/up.sql ================================================ ALTER TABLE captcha_answer DROP COLUMN id, ADD PRIMARY KEY (uuid), DROP CONSTRAINT captcha_answer_uuid_key; ALTER TABLE comment_aggregates DROP COLUMN id, ADD PRIMARY KEY (comment_id), DROP CONSTRAINT comment_aggregates_comment_id_key; ALTER TABLE comment_like DROP COLUMN id, ADD PRIMARY KEY (person_id, comment_id), DROP CONSTRAINT comment_like_comment_id_person_id_key; DROP INDEX idx_comment_like_person; ALTER TABLE comment_saved DROP COLUMN id, ADD PRIMARY KEY (person_id, comment_id), DROP CONSTRAINT comment_saved_comment_id_person_id_key; DROP INDEX idx_comment_saved_person_id; ALTER TABLE community_aggregates DROP COLUMN id, ADD PRIMARY KEY (community_id), DROP CONSTRAINT community_aggregates_community_id_key; ALTER TABLE community_block DROP COLUMN id, ADD PRIMARY KEY (person_id, community_id), DROP CONSTRAINT community_block_person_id_community_id_key; DROP INDEX idx_community_block_person; ALTER TABLE community_follower DROP COLUMN id, ADD PRIMARY KEY (person_id, community_id), DROP CONSTRAINT community_follower_community_id_person_id_key; DROP INDEX idx_community_follower_person; ALTER TABLE community_language DROP COLUMN id, ADD PRIMARY KEY (community_id, language_id), DROP CONSTRAINT community_language_community_id_language_id_key; ALTER TABLE community_moderator DROP COLUMN id, ADD PRIMARY KEY (person_id, community_id), DROP CONSTRAINT community_moderator_community_id_person_id_key; DROP INDEX idx_community_moderator_person; ALTER TABLE community_person_ban DROP COLUMN id, ADD PRIMARY KEY (person_id, community_id), DROP CONSTRAINT community_person_ban_community_id_person_id_key; ALTER TABLE custom_emoji_keyword DROP COLUMN id, ADD PRIMARY KEY (custom_emoji_id, keyword), DROP CONSTRAINT custom_emoji_keyword_custom_emoji_id_keyword_key; ALTER TABLE federation_allowlist DROP COLUMN id, ADD PRIMARY KEY (instance_id), DROP CONSTRAINT federation_allowlist_instance_id_key; ALTER TABLE federation_blocklist DROP COLUMN id, ADD PRIMARY KEY (instance_id), DROP CONSTRAINT federation_blocklist_instance_id_key; ALTER TABLE federation_queue_state DROP COLUMN id, ADD PRIMARY KEY (instance_id), DROP CONSTRAINT federation_queue_state_instance_id_key; ALTER TABLE image_upload DROP COLUMN id, ADD PRIMARY KEY (pictrs_alias), DROP CONSTRAINT image_upload_pictrs_alias_key; ALTER TABLE instance_block DROP COLUMN id, ADD PRIMARY KEY (person_id, instance_id), DROP CONSTRAINT instance_block_person_id_instance_id_key; ALTER TABLE local_site_rate_limit DROP COLUMN id, ADD PRIMARY KEY (local_site_id), DROP CONSTRAINT local_site_rate_limit_local_site_id_key; ALTER TABLE local_user_language DROP COLUMN id, ADD PRIMARY KEY (local_user_id, language_id), DROP CONSTRAINT local_user_language_local_user_id_language_id_key; ALTER TABLE login_token DROP COLUMN id, ADD PRIMARY KEY (token), DROP CONSTRAINT login_token_token_key; -- Delete duplicates which can exist because of missing `UNIQUE` constraint DELETE FROM person_aggregates AS a USING ( SELECT min(id) AS id, person_id FROM person_aggregates GROUP BY person_id HAVING count(*) > 1) AS b WHERE a.person_id = b.person_id AND a.id != b.id; ALTER TABLE person_aggregates DROP CONSTRAINT IF EXISTS person_aggregates_person_id_key; ALTER TABLE person_aggregates ADD UNIQUE (person_id); ALTER TABLE person_aggregates DROP COLUMN id, ADD PRIMARY KEY (person_id), DROP CONSTRAINT person_aggregates_person_id_key; ALTER TABLE person_ban DROP COLUMN id, ADD PRIMARY KEY (person_id), DROP CONSTRAINT person_ban_person_id_key; ALTER TABLE person_block DROP COLUMN id, ADD PRIMARY KEY (person_id, target_id), DROP CONSTRAINT person_block_person_id_target_id_key; ALTER TABLE person_follower DROP COLUMN id, ADD PRIMARY KEY (follower_id, person_id), DROP CONSTRAINT person_follower_follower_id_person_id_key; ALTER TABLE person_post_aggregates DROP COLUMN id, ADD PRIMARY KEY (person_id, post_id), DROP CONSTRAINT person_post_aggregates_person_id_post_id_key; ALTER TABLE post_aggregates DROP COLUMN id, ADD PRIMARY KEY (post_id), DROP CONSTRAINT post_aggregates_post_id_key; ALTER TABLE post_like DROP COLUMN id, ADD PRIMARY KEY (person_id, post_id), DROP CONSTRAINT post_like_post_id_person_id_key; DROP INDEX idx_post_like_person; ALTER TABLE post_read DROP COLUMN id, ADD PRIMARY KEY (person_id, post_id), DROP CONSTRAINT post_read_post_id_person_id_key; ALTER TABLE post_saved DROP COLUMN id, ADD PRIMARY KEY (person_id, post_id), DROP CONSTRAINT post_saved_post_id_person_id_key; DROP INDEX idx_post_saved_person_id; ALTER TABLE received_activity DROP COLUMN id, ADD PRIMARY KEY (ap_id), DROP CONSTRAINT received_activity_ap_id_key; -- Delete duplicates which can exist because of missing `UNIQUE` constraint DELETE FROM site_aggregates AS a USING ( SELECT min(id) AS id, site_id FROM site_aggregates GROUP BY site_id HAVING count(*) > 1) AS b WHERE a.site_id = b.site_id AND a.id != b.id; ALTER TABLE site_aggregates DROP COLUMN id, ADD PRIMARY KEY (site_id); ALTER TABLE site_language DROP COLUMN id, ADD PRIMARY KEY (site_id, language_id), DROP CONSTRAINT site_language_site_id_language_id_key; -- Change functions to not use the removed columns CREATE OR REPLACE FUNCTION site_aggregates_site () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table. -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests IF (TG_OP = 'INSERT') AND NOT EXISTS ( SELECT * FROM site_aggregates LIMIT 1) THEN INSERT INTO site_aggregates (site_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM site_aggregates WHERE site_id = OLD.id; END IF; RETURN NULL; END $$; ================================================ FILE: migrations/2023-10-24-131607_proxy_links/down.sql ================================================ DROP TABLE remote_image; ALTER TABLE local_image RENAME TO image_upload; ================================================ FILE: migrations/2023-10-24-131607_proxy_links/up.sql ================================================ CREATE TABLE remote_image ( id serial PRIMARY KEY, link text NOT NULL UNIQUE, published timestamptz DEFAULT now() NOT NULL ); ALTER TABLE image_upload RENAME TO local_image; ================================================ FILE: migrations/2023-10-24-183747_autocollapse_bot_comments/down.sql ================================================ ALTER TABLE local_user DROP COLUMN collapse_bot_comments; ================================================ FILE: migrations/2023-10-24-183747_autocollapse_bot_comments/up.sql ================================================ ALTER TABLE local_user ADD COLUMN collapse_bot_comments boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2023-10-27-142514_post_url_content_type/down.sql ================================================ ALTER TABLE post DROP COLUMN url_content_type; ================================================ FILE: migrations/2023-10-27-142514_post_url_content_type/up.sql ================================================ ALTER TABLE post ADD COLUMN url_content_type text; ================================================ FILE: migrations/2023-11-01-223740_federation-published/down.sql ================================================ ALTER TABLE federation_queue_state DROP COLUMN last_successful_published_time, ALTER COLUMN last_successful_id SET NOT NULL, ALTER COLUMN last_retry SET NOT NULL; ================================================ FILE: migrations/2023-11-01-223740_federation-published/up.sql ================================================ ALTER TABLE federation_queue_state ADD COLUMN last_successful_published_time timestamptz NULL, ALTER COLUMN last_successful_id DROP NOT NULL, ALTER COLUMN last_retry DROP NOT NULL; ================================================ FILE: migrations/2023-11-02-120140_apub-signed-fetch/down.sql ================================================ ALTER TABLE local_site DROP COLUMN federation_signed_fetch; ================================================ FILE: migrations/2023-11-02-120140_apub-signed-fetch/up.sql ================================================ ALTER TABLE local_site ADD COLUMN federation_signed_fetch boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2023-11-07-135409_inbox_unique/down.sql ================================================ ALTER TABLE person ADD CONSTRAINT idx_person_inbox_url UNIQUE (inbox_url); ALTER TABLE community ADD CONSTRAINT idx_community_inbox_url UNIQUE (inbox_url); UPDATE site SET inbox_url = inbox_query.inbox FROM ( SELECT format('https://%s/site_inbox', DOMAIN) AS inbox FROM instance, site, local_site WHERE instance.id = site.instance_id AND local_site.id = site.id) AS inbox_query, instance, local_site WHERE instance.id = site.instance_id AND local_site.id = site.id; ================================================ FILE: migrations/2023-11-07-135409_inbox_unique/up.sql ================================================ -- drop unique constraints for inbox columns ALTER TABLE person DROP CONSTRAINT idx_person_inbox_url; ALTER TABLE community DROP CONSTRAINT idx_community_inbox_url; -- change site inbox path from /inbox to /site_inbox -- we dont have any way here to set the correct protocol (http or https) according to tls_enabled, or set -- the correct port in case of debugging UPDATE site SET inbox_url = inbox_query.inbox FROM ( SELECT format('https://%s/inbox', DOMAIN) AS inbox FROM instance, site, local_site WHERE instance.id = site.instance_id AND local_site.id = site.id) AS inbox_query, instance, local_site WHERE instance.id = site.instance_id AND local_site.id = site.id; ================================================ FILE: migrations/2023-11-22-194806_low_rank_defaults/down.sql ================================================ ALTER TABLE community_aggregates ALTER COLUMN hot_rank SET DEFAULT 0.1728; ALTER TABLE comment_aggregates ALTER COLUMN hot_rank SET DEFAULT 0.1728; ALTER TABLE post_aggregates ALTER COLUMN hot_rank SET DEFAULT 0.1728, ALTER COLUMN hot_rank_active SET DEFAULT 0.1728, ALTER COLUMN scaled_rank SET DEFAULT 0.3621; ================================================ FILE: migrations/2023-11-22-194806_low_rank_defaults/up.sql ================================================ -- Change the hot_ranks to a miniscule number, so that new / fetched content -- won't crowd out existing content. -- -- They must be non-zero, in order for them to be picked up by the hot_ranks updater. -- See https://github.com/LemmyNet/lemmy/issues/4178 ALTER TABLE community_aggregates ALTER COLUMN hot_rank SET DEFAULT 0.0001; ALTER TABLE comment_aggregates ALTER COLUMN hot_rank SET DEFAULT 0.0001; ALTER TABLE post_aggregates ALTER COLUMN hot_rank SET DEFAULT 0.0001, ALTER COLUMN hot_rank_active SET DEFAULT 0.0001, ALTER COLUMN scaled_rank SET DEFAULT 0.0001; ================================================ FILE: migrations/2023-12-06-180359_edit_active_users/down.sql ================================================ CREATE OR REPLACE FUNCTION community_aggregates_activity (i text) RETURNS TABLE ( count_ bigint, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*), community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT p.creator_id, p.community_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE) a GROUP BY community_id; END; $$; CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person u ON c.creator_id = u.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND u.local = TRUE AND pe.bot_account = FALSE UNION SELECT p.creator_id FROM post p INNER JOIN person u ON p.creator_id = u.id INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND u.local = TRUE AND pe.bot_account = FALSE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2023-12-06-180359_edit_active_users/up.sql ================================================ -- Edit community aggregates to include voters as active users CREATE OR REPLACE FUNCTION community_aggregates_activity (i text) RETURNS TABLE ( count_ bigint, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*), community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT p.creator_id, p.community_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT pl.person_id, p.community_id FROM post_like pl INNER JOIN post p ON pl.post_id = p.id INNER JOIN person pe ON pl.person_id = pe.id WHERE pl.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT cl.person_id, p.community_id FROM comment_like cl INNER JOIN post p ON cl.post_id = p.id INNER JOIN person pe ON cl.person_id = pe.id WHERE cl.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE) a GROUP BY community_id; END; $$; -- Edit site aggregates to include voters and people who have read posts as active users CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT p.creator_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT pl.person_id FROM post_like pl INNER JOIN person pe ON pl.person_id = pe.id WHERE pl.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT cl.person_id FROM comment_like cl INNER JOIN person pe ON cl.person_id = pe.id WHERE cl.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2023-12-19-210053_tolerable-batch-insert-speed/down.sql ================================================ CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id) SELECT NEW.id, NEW.published, NEW.published, NEW.published, NEW.community_id, NEW.creator_id, community.instance_id FROM community WHERE NEW.community_id = community.id; ELSIF (TG_OP = 'DELETE') THEN DELETE FROM post_aggregates WHERE post_id = OLD.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER post_aggregates_post AFTER INSERT OR DELETE ON post FOR EACH ROW EXECUTE PROCEDURE post_aggregates_post (); CREATE OR REPLACE TRIGGER community_aggregates_post_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE PROCEDURE community_aggregates_post_count (); DROP FUNCTION IF EXISTS community_aggregates_post_count_insert CASCADE; DROP FUNCTION IF EXISTS community_aggregates_post_update CASCADE; DROP FUNCTION IF EXISTS site_aggregates_post_update CASCADE; DROP FUNCTION IF EXISTS person_aggregates_post_insert CASCADE; CREATE OR REPLACE FUNCTION site_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET posts = posts + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER site_aggregates_post_insert AFTER INSERT OR UPDATE OF removed, deleted ON post FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_insert (); CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'http://changeme.invalid/' || substr(md5(random()::text), 0, 25); $$; CREATE OR REPLACE TRIGGER person_aggregates_post_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE PROCEDURE person_aggregates_post_count (); DROP SEQUENCE IF EXISTS changeme_seq; ================================================ FILE: migrations/2023-12-19-210053_tolerable-batch-insert-speed/up.sql ================================================ -- Change triggers to run once per statement instead of once per row -- post_aggregates_post trigger doesn't need to handle deletion because the post_id column has ON DELETE CASCADE CREATE OR REPLACE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id) SELECT id, published, published, published, community_id, creator_id, ( SELECT community.instance_id FROM community WHERE community.id = community_id LIMIT 1) FROM new_post; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION community_aggregates_post_count_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE community_aggregates SET posts = posts + post_group.count FROM ( SELECT community_id, count(*) FROM new_post GROUP BY community_id) post_group WHERE community_aggregates.community_id = post_group.community_id; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION person_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE person_aggregates SET post_count = post_count + post_group.count FROM ( SELECT creator_id, count(*) FROM new_post GROUP BY creator_id) post_group WHERE person_aggregates.person_id = post_group.creator_id; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER post_aggregates_post AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE PROCEDURE post_aggregates_post (); -- Don't run old trigger for insert CREATE OR REPLACE TRIGGER community_aggregates_post_count AFTER DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE PROCEDURE community_aggregates_post_count (); CREATE OR REPLACE TRIGGER community_aggregates_post_count_insert AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE PROCEDURE community_aggregates_post_count_insert (); CREATE OR REPLACE FUNCTION site_aggregates_post_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET posts = posts + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE OR REPLACE FUNCTION site_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET posts = posts + ( SELECT count(*) FROM new_post) FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER site_aggregates_post_update AFTER UPDATE OF removed, deleted ON post FOR EACH ROW WHEN (NEW.local = TRUE) EXECUTE PROCEDURE site_aggregates_post_update (); CREATE OR REPLACE TRIGGER site_aggregates_post_insert AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE PROCEDURE site_aggregates_post_insert (); CREATE OR REPLACE TRIGGER person_aggregates_post_count AFTER DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE PROCEDURE person_aggregates_post_count (); CREATE OR REPLACE TRIGGER person_aggregates_post_insert AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE PROCEDURE person_aggregates_post_insert (); -- Avoid running hash function and random number generation for default ap_id CREATE SEQUENCE IF NOT EXISTS changeme_seq AS bigint CYCLE; CREATE OR REPLACE FUNCTION generate_unique_changeme () RETURNS text LANGUAGE sql AS $$ SELECT 'http://changeme.invalid/seq/' || nextval('changeme_seq')::text; $$; ================================================ FILE: migrations/2023-12-22-040137_make-mixed-sorting-directions-work-with-tuple-comparison/down.sql ================================================ DROP INDEX idx_post_aggregates_community_published_asc, idx_post_aggregates_featured_community_published_asc, idx_post_aggregates_featured_local_published_asc, idx_post_aggregates_published_asc; DROP FUNCTION reverse_timestamp_sort (t timestamp with time zone); ================================================ FILE: migrations/2023-12-22-040137_make-mixed-sorting-directions-work-with-tuple-comparison/up.sql ================================================ CREATE FUNCTION reverse_timestamp_sort (t timestamp with time zone) RETURNS bigint AS $$ BEGIN RETURN (-1000000 * EXTRACT(EPOCH FROM t))::bigint; END; $$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE; CREATE INDEX idx_post_aggregates_community_published_asc ON public.post_aggregates USING btree (community_id, featured_local DESC, reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_aggregates_featured_community_published_asc ON public.post_aggregates USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_aggregates_featured_local_published_asc ON public.post_aggregates USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_aggregates_published_asc ON public.post_aggregates USING btree (reverse_timestamp_sort (published) DESC); ================================================ FILE: migrations/2024-01-02-094916_site-name-not-unique/down.sql ================================================ ALTER TABLE site ADD CONSTRAINT site_name_key UNIQUE (name); ================================================ FILE: migrations/2024-01-02-094916_site-name-not-unique/up.sql ================================================ ALTER TABLE site DROP CONSTRAINT site_name_key; ================================================ FILE: migrations/2024-01-05-213000_community_aggregates_add_local_subscribers/down.sql ================================================ ALTER TABLE community_aggregates DROP COLUMN subscribers_local; -- old function from migrations/2023-10-02-145002_community_followers_count_federated/up.sql -- The subscriber count should only be updated for local communities. For remote -- communities it is read over federation from the origin instance. CREATE OR REPLACE FUNCTION community_aggregates_subscriber_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates SET subscribers = subscribers + 1 FROM community WHERE community.id = community_id AND community.local AND community_id = NEW.community_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates SET subscribers = subscribers - 1 FROM community WHERE community.id = community_id AND community.local AND community_id = OLD.community_id; END IF; RETURN NULL; END $$; DROP TRIGGER IF EXISTS delete_follow_before_person ON person; DROP FUNCTION IF EXISTS delete_follow_before_person; ================================================ FILE: migrations/2024-01-05-213000_community_aggregates_add_local_subscribers/up.sql ================================================ -- Couldn't find a way to put subscribers_local right after subscribers except recreating the table. ALTER TABLE community_aggregates ADD COLUMN subscribers_local bigint NOT NULL DEFAULT 0; -- update initial value -- update by counting local persons who follow communities. WITH follower_counts AS ( SELECT community_id, count(*) AS local_sub_count FROM community_follower cf JOIN person p ON p.id = cf.person_id WHERE p.local = TRUE GROUP BY community_id) UPDATE community_aggregates ca SET subscribers_local = local_sub_count FROM follower_counts WHERE ca.community_id = follower_counts.community_id; -- subscribers should be updated only when a local community is followed by a local or remote person -- subscribers_local should be updated only when a local person follows a local or remote community CREATE OR REPLACE FUNCTION community_aggregates_subscriber_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates ca SET subscribers = subscribers + community.local::int, subscribers_local = subscribers_local + person.local::int FROM community LEFT JOIN person ON person.id = NEW.person_id WHERE community.id = NEW.community_id AND community.id = ca.community_id AND person.local IS NOT NULL; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates ca SET subscribers = subscribers - community.local::int, subscribers_local = subscribers_local - person.local::int FROM community LEFT JOIN person ON person.id = OLD.person_id WHERE community.id = OLD.community_id AND community.id = ca.community_id AND person.local IS NOT NULL; END IF; RETURN NULL; END $$; -- to be able to join person on the trigger above, we need to run it before the person is deleted: https://github.com/LemmyNet/lemmy/pull/4166#issuecomment-1874095856 CREATE FUNCTION delete_follow_before_person () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN DELETE FROM community_follower AS c WHERE c.person_id = OLD.id; RETURN OLD; END; $$; CREATE TRIGGER delete_follow_before_person BEFORE DELETE ON person FOR EACH ROW EXECUTE FUNCTION delete_follow_before_person (); ================================================ FILE: migrations/2024-01-15-100133_local-only-community/down.sql ================================================ ALTER TABLE community DROP COLUMN visibility; DROP TYPE community_visibility; ================================================ FILE: migrations/2024-01-15-100133_local-only-community/up.sql ================================================ CREATE TYPE community_visibility AS enum ( 'Public', 'LocalOnly' ); ALTER TABLE community ADD COLUMN visibility community_visibility NOT NULL DEFAULT 'Public'; ================================================ FILE: migrations/2024-01-22-105746_lemmynsfw-changes/down.sql ================================================ ALTER TABLE site DROP COLUMN content_warning; ALTER TABLE local_site DROP COLUMN default_post_listing_mode; ================================================ FILE: migrations/2024-01-22-105746_lemmynsfw-changes/up.sql ================================================ ALTER TABLE site ADD COLUMN content_warning text; ALTER TABLE local_site ADD COLUMN default_post_listing_mode post_listing_mode_enum NOT NULL DEFAULT 'List'; ================================================ FILE: migrations/2024-01-25-151400_remove_auto_resolve_report_trigger/down.sql ================================================ -- Automatically resolve all reports for a given post once it is marked as removed CREATE OR REPLACE FUNCTION post_removed_resolve_reports () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_report SET resolved = TRUE, resolver_id = NEW.mod_person_id, updated = now() WHERE post_report.post_id = NEW.post_id; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER post_removed_resolve_reports AFTER INSERT ON mod_remove_post FOR EACH ROW WHEN (NEW.removed) EXECUTE PROCEDURE post_removed_resolve_reports (); -- Same when comment is marked as removed CREATE OR REPLACE FUNCTION comment_removed_resolve_reports () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE comment_report SET resolved = TRUE, resolver_id = NEW.mod_person_id, updated = now() WHERE comment_report.comment_id = NEW.comment_id; RETURN NULL; END $$; CREATE OR REPLACE TRIGGER comment_removed_resolve_reports AFTER INSERT ON mod_remove_comment FOR EACH ROW WHEN (NEW.removed) EXECUTE PROCEDURE comment_removed_resolve_reports (); ================================================ FILE: migrations/2024-01-25-151400_remove_auto_resolve_report_trigger/up.sql ================================================ DROP TRIGGER IF EXISTS post_removed_resolve_reports ON mod_remove_post; DROP FUNCTION IF EXISTS post_removed_resolve_reports; DROP TRIGGER IF EXISTS comment_removed_resolve_reports ON mod_remove_comment; DROP FUNCTION IF EXISTS comment_removed_resolve_reports; ================================================ FILE: migrations/2024-02-12-211114_add_vote_display_mode_setting/down.sql ================================================ DROP TABLE local_user_vote_display_mode; ================================================ FILE: migrations/2024-02-12-211114_add_vote_display_mode_setting/up.sql ================================================ -- Create an extra table to hold local user vote display settings -- Score and Upvote percentage are turned on by default. CREATE TABLE local_user_vote_display_mode ( local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, score boolean DEFAULT TRUE NOT NULL, upvotes boolean DEFAULT FALSE NOT NULL, downvotes boolean DEFAULT FALSE NOT NULL, upvote_percentage boolean DEFAULT TRUE NOT NULL, PRIMARY KEY (local_user_id) ); -- Insert rows for every local user INSERT INTO local_user_vote_display_mode (local_user_id) SELECT id FROM local_user; ================================================ FILE: migrations/2024-02-15-171358_default_instance_sort_type/down.sql ================================================ ALTER TABLE local_site DROP COLUMN default_sort_type; ================================================ FILE: migrations/2024-02-15-171358_default_instance_sort_type/up.sql ================================================ ALTER TABLE local_site ADD COLUMN default_sort_type sort_type_enum DEFAULT 'Active' NOT NULL; ================================================ FILE: migrations/2024-02-24-034523_replaceable-schema/down.sql ================================================ DROP SCHEMA IF EXISTS r CASCADE; DROP INDEX idx_site_aggregates_1_row_only; CREATE FUNCTION comment_aggregates_comment () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO comment_aggregates (comment_id, published) VALUES (NEW.id, NEW.published); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM comment_aggregates WHERE comment_id = OLD.id; END IF; RETURN NULL; END $$; CREATE FUNCTION comment_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE comment_aggregates ca SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END, controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) WHERE ca.comment_id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to comment because that comment may not exist anymore UPDATE comment_aggregates ca SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END, controversy_rank = controversy_rank (ca.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, ca.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) FROM comment c WHERE ca.comment_id = c.id AND ca.comment_id = OLD.comment_id; END IF; RETURN NULL; END $$; CREATE FUNCTION community_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments + 1 FROM post p WHERE p.id = NEW.post_id AND ca.community_id = p.community_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates ca SET comments = comments - 1 FROM post p WHERE p.id = OLD.post_id AND ca.community_id = p.community_id; END IF; RETURN NULL; END $$; CREATE FUNCTION community_aggregates_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO community_aggregates (community_id, published) VALUES (NEW.id, NEW.published); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM community_aggregates WHERE community_id = OLD.id; END IF; RETURN NULL; END $$; CREATE FUNCTION community_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates SET posts = posts + 1 WHERE community_id = NEW.community_id; IF (TG_OP = 'UPDATE') THEN -- Post was restored, so restore comment counts as well UPDATE community_aggregates ca SET posts = coalesce(cd.posts, 0), comments = coalesce(cd.comments, 0) FROM ( SELECT c.id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM community c LEFT JOIN post p ON c.id = p.community_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN comment ct ON p.id = ct.post_id AND ct.deleted = 'f' AND ct.removed = 'f' WHERE c.id = NEW.community_id GROUP BY c.id) cd WHERE ca.community_id = NEW.community_id; END IF; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE community_aggregates SET posts = posts - 1 WHERE community_id = OLD.community_id; -- Update the counts if the post got deleted UPDATE community_aggregates ca SET posts = coalesce(cd.posts, 0), comments = coalesce(cd.comments, 0) FROM ( SELECT c.id, count(DISTINCT p.id) AS posts, count(DISTINCT ct.id) AS comments FROM community c LEFT JOIN post p ON c.id = p.community_id AND p.deleted = 'f' AND p.removed = 'f' LEFT JOIN comment ct ON p.id = ct.post_id AND ct.deleted = 'f' AND ct.removed = 'f' WHERE c.id = OLD.community_id GROUP BY c.id) cd WHERE ca.community_id = OLD.community_id; END IF; RETURN NULL; END $$; CREATE FUNCTION community_aggregates_post_count_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE community_aggregates SET posts = posts + post_group.count FROM ( SELECT community_id, count(*) FROM new_post GROUP BY community_id) post_group WHERE community_aggregates.community_id = post_group.community_id; RETURN NULL; END $$; CREATE FUNCTION community_aggregates_subscriber_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE community_aggregates ca SET subscribers = subscribers + community.local::int, subscribers_local = subscribers_local + person.local::int FROM community LEFT JOIN person ON person.id = NEW.person_id WHERE community.id = NEW.community_id AND community.id = ca.community_id AND person.local IS NOT NULL; ELSIF (TG_OP = 'DELETE') THEN UPDATE community_aggregates ca SET subscribers = subscribers - community.local::int, subscribers_local = subscribers_local - person.local::int FROM community LEFT JOIN person ON person.id = OLD.person_id WHERE community.id = OLD.community_id AND community.id = ca.community_id AND person.local IS NOT NULL; END IF; RETURN NULL; END $$; CREATE FUNCTION delete_follow_before_person () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN DELETE FROM community_follower AS c WHERE c.person_id = OLD.id; RETURN OLD; END; $$; CREATE FUNCTION person_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET comment_count = comment_count - 1 WHERE person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE FUNCTION person_aggregates_comment_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE person_aggregates ua SET comment_score = comment_score + NEW.score FROM comment c WHERE ua.person_id = c.creator_id AND c.id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates ua SET comment_score = comment_score - OLD.score FROM comment c WHERE ua.person_id = c.creator_id AND c.id = OLD.comment_id; END IF; RETURN NULL; END $$; CREATE FUNCTION person_aggregates_person () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN INSERT INTO person_aggregates (person_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM person_aggregates WHERE person_id = OLD.id; END IF; RETURN NULL; END $$; CREATE FUNCTION person_aggregates_post_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count + 1 WHERE person_id = NEW.creator_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE person_aggregates SET post_count = post_count - 1 WHERE person_id = OLD.creator_id; END IF; RETURN NULL; END $$; CREATE FUNCTION person_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE person_aggregates SET post_count = post_count + post_group.count FROM ( SELECT creator_id, count(*) FROM new_post GROUP BY creator_id) post_group WHERE person_aggregates.person_id = post_group.creator_id; RETURN NULL; END $$; CREATE FUNCTION person_aggregates_post_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter UPDATE person_aggregates ua SET post_score = post_score + NEW.score FROM post p WHERE ua.person_id = p.creator_id AND p.id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN UPDATE person_aggregates ua SET post_score = post_score - OLD.score FROM post p WHERE ua.person_id = p.creator_id AND p.id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE FUNCTION post_aggregates_comment_count () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Check for post existence - it may not exist anymore IF TG_OP = 'INSERT' OR EXISTS ( SELECT 1 FROM post p WHERE p.id = OLD.post_id) THEN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE post_aggregates pa SET comments = comments + 1 WHERE pa.post_id = NEW.post_id; ELSIF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE post_aggregates pa SET comments = comments - 1 WHERE pa.post_id = OLD.post_id; END IF; END IF; IF TG_OP = 'INSERT' THEN UPDATE post_aggregates pa SET newest_comment_time = NEW.published WHERE pa.post_id = NEW.post_id; -- A 2 day necro-bump limit UPDATE post_aggregates pa SET newest_comment_time_necro = NEW.published FROM post p WHERE pa.post_id = p.id AND pa.post_id = NEW.post_id -- Fix issue with being able to necro-bump your own post AND NEW.creator_id != p.creator_id AND pa.published > ('now'::timestamp - '2 days'::interval); END IF; RETURN NULL; END $$; CREATE FUNCTION post_aggregates_featured_community () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_aggregates pa SET featured_community = NEW.featured_community WHERE pa.post_id = NEW.id; RETURN NULL; END $$; CREATE FUNCTION post_aggregates_featured_local () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE post_aggregates pa SET featured_local = NEW.featured_local WHERE pa.post_id = NEW.id; RETURN NULL; END $$; CREATE FUNCTION post_aggregates_post () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN INSERT INTO post_aggregates (post_id, published, newest_comment_time, newest_comment_time_necro, community_id, creator_id, instance_id) SELECT id, published, published, published, community_id, creator_id, ( SELECT community.instance_id FROM community WHERE community.id = community_id LIMIT 1) FROM new_post; RETURN NULL; END $$; CREATE FUNCTION post_aggregates_score () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN UPDATE post_aggregates pa SET score = score + NEW.score, upvotes = CASE WHEN NEW.score = 1 THEN upvotes + 1 ELSE upvotes END, downvotes = CASE WHEN NEW.score = -1 THEN downvotes + 1 ELSE downvotes END, controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) WHERE pa.post_id = NEW.post_id; ELSIF (TG_OP = 'DELETE') THEN -- Join to post because that post may not exist anymore UPDATE post_aggregates pa SET score = score - OLD.score, upvotes = CASE WHEN OLD.score = 1 THEN upvotes - 1 ELSE upvotes END, downvotes = CASE WHEN OLD.score = -1 THEN downvotes - 1 ELSE downvotes END, controversy_rank = controversy_rank (pa.upvotes + CASE WHEN NEW.score = 1 THEN 1 ELSE 0 END::numeric, pa.downvotes + CASE WHEN NEW.score = -1 THEN 1 ELSE 0 END::numeric) FROM post p WHERE pa.post_id = p.id AND pa.post_id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_comment_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET comments = comments - 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_comment_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET comments = comments + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_community_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET communities = communities - 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_community_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET communities = communities + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_person_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- Join to site since the creator might not be there anymore UPDATE site_aggregates sa SET users = users - 1 FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_person_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates SET users = users + 1; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_post_delete () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_removed_or_deleted (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET posts = posts - 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_post_insert () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN UPDATE site_aggregates sa SET posts = posts + ( SELECT count(*) FROM new_post) FROM site s WHERE sa.site_id = s.id; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_post_update () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF (was_restored_or_created (TG_OP, OLD, NEW)) THEN UPDATE site_aggregates sa SET posts = posts + 1 FROM site s WHERE sa.site_id = s.id; END IF; RETURN NULL; END $$; CREATE FUNCTION site_aggregates_site () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN -- we only ever want to have a single value in site_aggregate because the site_aggregate triggers update all rows in that table. -- a cleaner check would be to insert it for the local_site but that would break assumptions at least in the tests IF (TG_OP = 'INSERT') AND NOT EXISTS ( SELECT * FROM site_aggregates LIMIT 1) THEN INSERT INTO site_aggregates (site_id) VALUES (NEW.id); ELSIF (TG_OP = 'DELETE') THEN DELETE FROM site_aggregates WHERE site_id = OLD.id; END IF; RETURN NULL; END $$; CREATE FUNCTION was_removed_or_deleted (tg_op text, old record, new record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'INSERT') THEN RETURN FALSE; END IF; IF (TG_OP = 'DELETE' AND OLD.deleted = 'f' AND OLD.removed = 'f') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND OLD.deleted = 'f' AND OLD.removed = 'f' AND (NEW.deleted = 't' OR NEW.removed = 't'); END $$; CREATE FUNCTION was_restored_or_created (tg_op text, old record, new record) RETURNS boolean LANGUAGE plpgsql AS $$ BEGIN IF (TG_OP = 'DELETE') THEN RETURN FALSE; END IF; IF (TG_OP = 'INSERT') THEN RETURN TRUE; END IF; RETURN TG_OP = 'UPDATE' AND NEW.deleted = 'f' AND NEW.removed = 'f' AND (OLD.deleted = 't' OR OLD.removed = 't'); END $$; CREATE TRIGGER comment_aggregates_comment AFTER INSERT OR DELETE ON comment FOR EACH ROW EXECUTE FUNCTION comment_aggregates_comment (); CREATE TRIGGER comment_aggregates_score AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE FUNCTION comment_aggregates_score (); CREATE TRIGGER community_aggregates_comment_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW EXECUTE FUNCTION community_aggregates_comment_count (); CREATE TRIGGER community_aggregates_community AFTER INSERT OR DELETE ON community FOR EACH ROW EXECUTE FUNCTION community_aggregates_community (); CREATE TRIGGER community_aggregates_post_count AFTER DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE FUNCTION community_aggregates_post_count (); CREATE TRIGGER community_aggregates_post_count_insert AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE FUNCTION community_aggregates_post_count_insert (); CREATE TRIGGER community_aggregates_subscriber_count AFTER INSERT OR DELETE ON community_follower FOR EACH ROW EXECUTE FUNCTION community_aggregates_subscriber_count (); CREATE TRIGGER delete_follow_before_person BEFORE DELETE ON person FOR EACH ROW EXECUTE FUNCTION delete_follow_before_person (); CREATE TRIGGER person_aggregates_comment_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW EXECUTE FUNCTION person_aggregates_comment_count (); CREATE TRIGGER person_aggregates_comment_score AFTER INSERT OR DELETE ON comment_like FOR EACH ROW EXECUTE FUNCTION person_aggregates_comment_score (); CREATE TRIGGER person_aggregates_person AFTER INSERT OR DELETE ON person FOR EACH ROW EXECUTE FUNCTION person_aggregates_person (); CREATE TRIGGER person_aggregates_post_count AFTER DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW EXECUTE FUNCTION person_aggregates_post_count (); CREATE TRIGGER person_aggregates_post_insert AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE FUNCTION person_aggregates_post_insert (); CREATE TRIGGER person_aggregates_post_score AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE FUNCTION person_aggregates_post_score (); CREATE TRIGGER post_aggregates_comment_count AFTER INSERT OR DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW EXECUTE FUNCTION post_aggregates_comment_count (); CREATE TRIGGER post_aggregates_featured_community AFTER UPDATE ON post FOR EACH ROW WHEN ((old.featured_community IS DISTINCT FROM new.featured_community)) EXECUTE FUNCTION post_aggregates_featured_community (); CREATE TRIGGER post_aggregates_featured_local AFTER UPDATE ON post FOR EACH ROW WHEN ((old.featured_local IS DISTINCT FROM new.featured_local)) EXECUTE FUNCTION post_aggregates_featured_local (); CREATE TRIGGER post_aggregates_post AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE FUNCTION post_aggregates_post (); CREATE TRIGGER post_aggregates_score AFTER INSERT OR DELETE ON post_like FOR EACH ROW EXECUTE FUNCTION post_aggregates_score (); CREATE TRIGGER site_aggregates_comment_delete AFTER DELETE OR UPDATE OF removed, deleted ON comment FOR EACH ROW WHEN ((old.local = TRUE)) EXECUTE FUNCTION site_aggregates_comment_delete (); CREATE TRIGGER site_aggregates_comment_insert AFTER INSERT OR UPDATE OF removed, deleted ON comment FOR EACH ROW WHEN ((new.local = TRUE)) EXECUTE FUNCTION site_aggregates_comment_insert (); CREATE TRIGGER site_aggregates_community_delete AFTER DELETE OR UPDATE OF removed, deleted ON community FOR EACH ROW WHEN (OLD.local = TRUE) EXECUTE PROCEDURE site_aggregates_community_delete (); CREATE TRIGGER site_aggregates_community_insert AFTER INSERT OR UPDATE OF removed, deleted ON community FOR EACH ROW WHEN ((new.local = TRUE)) EXECUTE FUNCTION site_aggregates_community_insert (); CREATE TRIGGER site_aggregates_person_delete AFTER DELETE ON person FOR EACH ROW WHEN ((old.local = TRUE)) EXECUTE FUNCTION site_aggregates_person_delete (); CREATE TRIGGER site_aggregates_person_insert AFTER INSERT ON person FOR EACH ROW WHEN ((new.local = TRUE)) EXECUTE FUNCTION site_aggregates_person_insert (); CREATE TRIGGER site_aggregates_post_delete AFTER DELETE OR UPDATE OF removed, deleted ON post FOR EACH ROW WHEN ((old.local = TRUE)) EXECUTE FUNCTION site_aggregates_post_delete (); CREATE TRIGGER site_aggregates_post_insert AFTER INSERT ON post REFERENCING NEW TABLE AS new_post FOR EACH STATEMENT EXECUTE FUNCTION site_aggregates_post_insert (); CREATE TRIGGER site_aggregates_post_update AFTER UPDATE OF removed, deleted ON post FOR EACH ROW WHEN ((new.local = TRUE)) EXECUTE FUNCTION site_aggregates_post_update (); CREATE TRIGGER site_aggregates_site AFTER INSERT OR DELETE ON site FOR EACH ROW EXECUTE FUNCTION site_aggregates_site (); -- Rank functions CREATE FUNCTION controversy_rank (upvotes numeric, downvotes numeric) RETURNS double precision LANGUAGE plpgsql IMMUTABLE AS $$ BEGIN IF downvotes <= 0 OR upvotes <= 0 THEN RETURN 0; ELSE RETURN (upvotes + downvotes) * CASE WHEN upvotes > downvotes THEN downvotes::float / upvotes::float ELSE upvotes::float / downvotes::float END; END IF; END; $$; CREATE FUNCTION hot_rank (score numeric, published timestamp with time zone) RETURNS double precision LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $$ DECLARE hours_diff numeric := EXTRACT(EPOCH FROM (now() - published)) / 3600; BEGIN -- 24 * 7 = 168, so after a week, it will default to 0. IF (hours_diff > 0 AND hours_diff < 168) THEN -- Use greatest(2,score), so that the hot_rank will be positive and not ignored. RETURN log(greatest (2, score + 2)) / power((hours_diff + 2), 1.8); ELSE -- if the post is from the future, set hot score to 0. otherwise you can game the post to -- always be on top even with only 1 vote by setting it to the future RETURN 0.0; END IF; END; $$; CREATE FUNCTION scaled_rank (score numeric, published timestamp with time zone, users_active_month numeric) RETURNS double precision LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $$ BEGIN -- Add 2 to avoid divide by zero errors -- Default for score = 1, active users = 1, and now, is (0.1728 / log(2 + 1)) = 0.3621 -- There may need to be a scale factor multiplied to users_active_month, to make -- the log curve less pronounced. This can be tuned in the future. RETURN (hot_rank (score, published) / log(2 + users_active_month)); END; $$; -- Don't defer constraints ALTER TABLE comment_aggregates ALTER CONSTRAINT comment_aggregates_comment_id_fkey NOT DEFERRABLE; ALTER TABLE community_aggregates ALTER CONSTRAINT community_aggregates_community_id_fkey NOT DEFERRABLE; ALTER TABLE person_aggregates ALTER CONSTRAINT person_aggregates_person_id_fkey NOT DEFERRABLE; ALTER TABLE post_aggregates ALTER CONSTRAINT post_aggregates_community_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT post_aggregates_creator_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT post_aggregates_instance_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT post_aggregates_post_id_fkey NOT DEFERRABLE; ALTER TABLE site_aggregates ALTER CONSTRAINT site_aggregates_site_id_fkey NOT DEFERRABLE; ================================================ FILE: migrations/2024-02-24-034523_replaceable-schema/up.sql ================================================ CREATE UNIQUE INDEX idx_site_aggregates_1_row_only ON site_aggregates ((TRUE)); -- Drop functions and use `CASCADE` to drop the triggers that use them DROP FUNCTION comment_aggregates_comment, comment_aggregates_score, community_aggregates_comment_count, community_aggregates_community, community_aggregates_post_count, community_aggregates_post_count_insert, community_aggregates_subscriber_count, delete_follow_before_person, person_aggregates_comment_count, person_aggregates_comment_score, person_aggregates_person, person_aggregates_post_count, person_aggregates_post_insert, person_aggregates_post_score, post_aggregates_comment_count, post_aggregates_featured_community, post_aggregates_featured_local, post_aggregates_post, post_aggregates_score, site_aggregates_comment_delete, site_aggregates_comment_insert, site_aggregates_community_delete, site_aggregates_community_insert, site_aggregates_person_delete, site_aggregates_person_insert, site_aggregates_post_delete, site_aggregates_post_insert, site_aggregates_post_update, site_aggregates_site, was_removed_or_deleted, was_restored_or_created CASCADE; -- Drop rank functions DROP FUNCTION controversy_rank, scaled_rank, hot_rank; -- Defer constraints ALTER TABLE comment_aggregates ALTER CONSTRAINT comment_aggregates_comment_id_fkey INITIALLY DEFERRED; ALTER TABLE community_aggregates ALTER CONSTRAINT community_aggregates_community_id_fkey INITIALLY DEFERRED; ALTER TABLE person_aggregates ALTER CONSTRAINT person_aggregates_person_id_fkey INITIALLY DEFERRED; ALTER TABLE post_aggregates ALTER CONSTRAINT post_aggregates_community_id_fkey INITIALLY DEFERRED, ALTER CONSTRAINT post_aggregates_creator_id_fkey INITIALLY DEFERRED, ALTER CONSTRAINT post_aggregates_instance_id_fkey INITIALLY DEFERRED, ALTER CONSTRAINT post_aggregates_post_id_fkey INITIALLY DEFERRED; ALTER TABLE site_aggregates ALTER CONSTRAINT site_aggregates_site_id_fkey INITIALLY DEFERRED; -- Fix values that might be incorrect because of the old triggers UPDATE post_aggregates SET featured_local = post.featured_local, featured_community = post.featured_community FROM post WHERE post_aggregates.post_id = post.id AND (post_aggregates.featured_local, post_aggregates.featured_community) != (post.featured_local, post.featured_community); UPDATE community_aggregates SET comments = counted.comments FROM ( SELECT community_id, count(*) AS comments FROM comment, LATERAL ( SELECT * FROM post WHERE post.id = comment.post_id LIMIT 1) AS post WHERE NOT (comment.deleted OR comment.removed OR post.deleted OR post.removed) GROUP BY community_id) AS counted WHERE community_aggregates.community_id = counted.community_id AND community_aggregates.comments != counted.comments; UPDATE site_aggregates SET communities = ( SELECT count(*) FROM community WHERE local); ================================================ FILE: migrations/2024-02-27-204628_add_post_alt_text/down.sql ================================================ ALTER TABLE post DROP COLUMN alt_text; ================================================ FILE: migrations/2024-02-27-204628_add_post_alt_text/up.sql ================================================ ALTER TABLE post ADD COLUMN alt_text text; ================================================ FILE: migrations/2024-02-28-144211_hide_posts/down.sql ================================================ DROP TABLE post_hide; ================================================ FILE: migrations/2024-02-28-144211_hide_posts/up.sql ================================================ CREATE TABLE post_hide ( post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamp with time zone NOT NULL DEFAULT now(), PRIMARY KEY (person_id, post_id) ); ================================================ FILE: migrations/2024-03-06-104706_local_image_user_opt/down.sql ================================================ ALTER TABLE local_image ADD CONSTRAINT image_upload_local_user_id_not_null NOT NULL local_user_id; ================================================ FILE: migrations/2024-03-06-104706_local_image_user_opt/up.sql ================================================ ALTER TABLE local_image ALTER COLUMN local_user_id DROP NOT NULL; ================================================ FILE: migrations/2024-03-06-201637_url_blocklist/down.sql ================================================ -- This file should undo anything in `up.sql` DROP TABLE local_site_url_blocklist; ================================================ FILE: migrations/2024-03-06-201637_url_blocklist/up.sql ================================================ CREATE TABLE local_site_url_blocklist ( id serial NOT NULL PRIMARY KEY, url text NOT NULL UNIQUE, published timestamp with time zone NOT NULL DEFAULT now(), updated timestamp with time zone ); ================================================ FILE: migrations/2024-04-05-153647_alter_vote_display_mode_defaults/down.sql ================================================ ALTER TABLE local_user_vote_display_mode DROP COLUMN score, ADD COLUMN score boolean DEFAULT TRUE NOT NULL, DROP COLUMN upvotes, ADD COLUMN upvotes boolean DEFAULT FALSE NOT NULL, DROP COLUMN downvotes, ADD COLUMN downvotes boolean DEFAULT FALSE NOT NULL, DROP COLUMN upvote_percentage, ADD COLUMN upvote_percentage boolean DEFAULT TRUE NOT NULL; ================================================ FILE: migrations/2024-04-05-153647_alter_vote_display_mode_defaults/up.sql ================================================ -- Based on a poll, update the local_user_vote_display_mode defaults to: -- Upvotes + Downvotes -- Rather than -- Score + upvote_percentage ALTER TABLE local_user_vote_display_mode DROP COLUMN score, ADD COLUMN score boolean DEFAULT FALSE NOT NULL, DROP COLUMN upvotes, ADD COLUMN upvotes boolean DEFAULT TRUE NOT NULL, DROP COLUMN downvotes, ADD COLUMN downvotes boolean DEFAULT TRUE NOT NULL, DROP COLUMN upvote_percentage, ADD COLUMN upvote_percentage boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2024-04-15-105932_community_followers_url_optional/down.sql ================================================ ALTER TABLE community ALTER COLUMN followers_url SET NOT NULL; ================================================ FILE: migrations/2024-04-15-105932_community_followers_url_optional/up.sql ================================================ ALTER TABLE community ALTER COLUMN followers_url DROP NOT NULL; ================================================ FILE: migrations/2024-04-23-020604_add_post_id_index/down.sql ================================================ DROP INDEX idx_post_aggregates_community_active; DROP INDEX idx_post_aggregates_community_controversy; DROP INDEX idx_post_aggregates_community_hot; DROP INDEX idx_post_aggregates_community_most_comments; DROP INDEX idx_post_aggregates_community_newest_comment_time; DROP INDEX idx_post_aggregates_community_newest_comment_time_necro; DROP INDEX idx_post_aggregates_community_published; DROP INDEX idx_post_aggregates_community_published_asc; DROP INDEX idx_post_aggregates_community_scaled; DROP INDEX idx_post_aggregates_community_score; DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_controversy; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_most_comments; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necr; DROP INDEX idx_post_aggregates_featured_community_published; DROP INDEX idx_post_aggregates_featured_community_published_asc; DROP INDEX idx_post_aggregates_featured_community_scaled; DROP INDEX idx_post_aggregates_featured_community_score; DROP INDEX idx_post_aggregates_featured_local_active; DROP INDEX idx_post_aggregates_featured_local_controversy; DROP INDEX idx_post_aggregates_featured_local_hot; DROP INDEX idx_post_aggregates_featured_local_most_comments; DROP INDEX idx_post_aggregates_featured_local_newest_comment_time; DROP INDEX idx_post_aggregates_featured_local_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_local_published; DROP INDEX idx_post_aggregates_featured_local_published_asc; DROP INDEX idx_post_aggregates_featured_local_scaled; DROP INDEX idx_post_aggregates_featured_local_score; CREATE INDEX idx_post_aggregates_community_active ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_community_controversy ON public.post_aggregates USING btree (community_id, featured_local DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_community_hot ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_community_most_comments ON public.post_aggregates USING btree (community_id, featured_local DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_community_newest_comment_time_necro ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_community_published ON public.post_aggregates USING btree (community_id, featured_local DESC, published DESC); CREATE INDEX idx_post_aggregates_community_published_asc ON public.post_aggregates USING btree (community_id, featured_local DESC, public.reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_aggregates_community_scaled ON public.post_aggregates USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_community_score ON public.post_aggregates USING btree (community_id, featured_local DESC, score DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_controversy ON public.post_aggregates USING btree (community_id, featured_community DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_most_comments ON public.post_aggregates USING btree (community_id, featured_community DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necr ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON public.post_aggregates USING btree (community_id, featured_community DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_published_asc ON public.post_aggregates USING btree (community_id, featured_community DESC, public.reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_aggregates_featured_community_scaled ON public.post_aggregates USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON public.post_aggregates USING btree (community_id, featured_community DESC, score DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON public.post_aggregates USING btree (featured_local DESC, hot_rank_active DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_controversy ON public.post_aggregates USING btree (featured_local DESC, controversy_rank DESC); CREATE INDEX idx_post_aggregates_featured_local_hot ON public.post_aggregates USING btree (featured_local DESC, hot_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_most_comments ON public.post_aggregates USING btree (featured_local DESC, comments DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time DESC); CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time_necro ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time_necro DESC); CREATE INDEX idx_post_aggregates_featured_local_published ON public.post_aggregates USING btree (featured_local DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_published_asc ON public.post_aggregates USING btree (featured_local DESC, public.reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_aggregates_featured_local_scaled ON public.post_aggregates USING btree (featured_local DESC, scaled_rank DESC, published DESC); CREATE INDEX idx_post_aggregates_featured_local_score ON public.post_aggregates USING btree (featured_local DESC, score DESC, published DESC); ================================================ FILE: migrations/2024-04-23-020604_add_post_id_index/up.sql ================================================ -- Add , post_id DESC to all these DROP INDEX idx_post_aggregates_community_active; DROP INDEX idx_post_aggregates_community_controversy; DROP INDEX idx_post_aggregates_community_hot; DROP INDEX idx_post_aggregates_community_most_comments; DROP INDEX idx_post_aggregates_community_newest_comment_time; DROP INDEX idx_post_aggregates_community_newest_comment_time_necro; DROP INDEX idx_post_aggregates_community_published; DROP INDEX idx_post_aggregates_community_published_asc; DROP INDEX idx_post_aggregates_community_scaled; DROP INDEX idx_post_aggregates_community_score; DROP INDEX idx_post_aggregates_featured_community_active; DROP INDEX idx_post_aggregates_featured_community_controversy; DROP INDEX idx_post_aggregates_featured_community_hot; DROP INDEX idx_post_aggregates_featured_community_most_comments; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time; DROP INDEX idx_post_aggregates_featured_community_newest_comment_time_necr; DROP INDEX idx_post_aggregates_featured_community_published; DROP INDEX idx_post_aggregates_featured_community_published_asc; DROP INDEX idx_post_aggregates_featured_community_scaled; DROP INDEX idx_post_aggregates_featured_community_score; DROP INDEX idx_post_aggregates_featured_local_active; DROP INDEX idx_post_aggregates_featured_local_controversy; DROP INDEX idx_post_aggregates_featured_local_hot; DROP INDEX idx_post_aggregates_featured_local_most_comments; DROP INDEX idx_post_aggregates_featured_local_newest_comment_time; DROP INDEX idx_post_aggregates_featured_local_newest_comment_time_necro; DROP INDEX idx_post_aggregates_featured_local_published; DROP INDEX idx_post_aggregates_featured_local_published_asc; DROP INDEX idx_post_aggregates_featured_local_scaled; DROP INDEX idx_post_aggregates_featured_local_score; CREATE INDEX idx_post_aggregates_community_active ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_controversy ON public.post_aggregates USING btree (community_id, featured_local DESC, controversy_rank DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_hot ON public.post_aggregates USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_most_comments ON public.post_aggregates USING btree (community_id, featured_local DESC, comments DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_newest_comment_time_necro ON public.post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time_necro DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_published ON public.post_aggregates USING btree (community_id, featured_local DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_published_asc ON public.post_aggregates USING btree (community_id, featured_local DESC, public.reverse_timestamp_sort (published) DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_scaled ON public.post_aggregates USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_community_score ON public.post_aggregates USING btree (community_id, featured_local DESC, score DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_active ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_controversy ON public.post_aggregates USING btree (community_id, featured_community DESC, controversy_rank DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_hot ON public.post_aggregates USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_most_comments ON public.post_aggregates USING btree (community_id, featured_community DESC, comments DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_newest_comment_time_necr ON public.post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time_necro DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_published ON public.post_aggregates USING btree (community_id, featured_community DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_published_asc ON public.post_aggregates USING btree (community_id, featured_community DESC, public.reverse_timestamp_sort (published) DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_scaled ON public.post_aggregates USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_community_score ON public.post_aggregates USING btree (community_id, featured_community DESC, score DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_active ON public.post_aggregates USING btree (featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_controversy ON public.post_aggregates USING btree (featured_local DESC, controversy_rank DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_hot ON public.post_aggregates USING btree (featured_local DESC, hot_rank DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_most_comments ON public.post_aggregates USING btree (featured_local DESC, comments DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_newest_comment_time_necro ON public.post_aggregates USING btree (featured_local DESC, newest_comment_time_necro DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_published ON public.post_aggregates USING btree (featured_local DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_published_asc ON public.post_aggregates USING btree (featured_local DESC, public.reverse_timestamp_sort (published) DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_scaled ON public.post_aggregates USING btree (featured_local DESC, scaled_rank DESC, published DESC, post_id DESC); CREATE INDEX idx_post_aggregates_featured_local_score ON public.post_aggregates USING btree (featured_local DESC, score DESC, published DESC, post_id DESC); ================================================ FILE: migrations/2024-05-04-140749_separate_triggers/down.sql ================================================ SELECT 1; ================================================ FILE: migrations/2024-05-04-140749_separate_triggers/up.sql ================================================ -- This migration exists to trigger re-execution of replaceable_schema SELECT 1; ================================================ FILE: migrations/2024-05-05-162540_add_image_detail_table/down.sql ================================================ ALTER TABLE remote_image ADD UNIQUE (link), DROP CONSTRAINT remote_image_pkey, ADD COLUMN id serial PRIMARY KEY; DROP TABLE image_details; ================================================ FILE: migrations/2024-05-05-162540_add_image_detail_table/up.sql ================================================ -- Drop the id column from the remote_image table, just use link ALTER TABLE remote_image DROP COLUMN id, ADD PRIMARY KEY (link), DROP CONSTRAINT remote_image_link_key; -- No good way to do references here unfortunately, unless we combine the images tables -- The link should be the URL, not the pictrs_alias, to allow joining from post.thumbnail_url CREATE TABLE image_details ( link text PRIMARY KEY, width integer NOT NULL, height integer NOT NULL, content_type text NOT NULL ); ================================================ FILE: migrations/2024-06-17-160323_fix_post_aggregates_featured_local/down.sql ================================================ SELECT ; ================================================ FILE: migrations/2024-06-17-160323_fix_post_aggregates_featured_local/up.sql ================================================ -- Fix rows that were not updated because of the old incorrect trigger UPDATE post_aggregates SET featured_local = post.featured_local FROM post WHERE post.id = post_aggregates.post_id AND post.featured_local != post_aggregates.featured_local; ================================================ FILE: migrations/2024-06-24-000000_ap_id_triggers/down.sql ================================================ ALTER TABLE comment ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme (); ALTER TABLE post ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme (); ALTER TABLE private_message ALTER COLUMN ap_id SET DEFAULT generate_unique_changeme (); ================================================ FILE: migrations/2024-06-24-000000_ap_id_triggers/up.sql ================================================ ALTER TABLE comment ALTER COLUMN ap_id DROP DEFAULT; ALTER TABLE post ALTER COLUMN ap_id DROP DEFAULT; ALTER TABLE private_message ALTER COLUMN ap_id DROP DEFAULT; ================================================ FILE: migrations/2024-07-01-014711_exponential_controversy/down.sql ================================================ UPDATE post_aggregates SET controversy_rank = CASE WHEN downvotes <= 0 OR upvotes <= 0 THEN 0 ELSE (upvotes + downvotes) * CASE WHEN upvotes > downvotes THEN downvotes::float / upvotes::float ELSE upvotes::float / downvotes::float END END WHERE upvotes > 0 AND downvotes > 0; ================================================ FILE: migrations/2024-07-01-014711_exponential_controversy/up.sql ================================================ UPDATE post_aggregates SET controversy_rank = (upvotes + downvotes) ^ CASE WHEN upvotes > downvotes THEN downvotes::float / upvotes::float ELSE upvotes::float / downvotes::float END WHERE upvotes > 0 AND downvotes > 0 -- a number divided by itself is 1, and `* 1` does the same thing as `^ 1` AND upvotes != downvotes; ================================================ FILE: migrations/2024-08-03-155932_increase_post_url_max_length/down.sql ================================================ ALTER TABLE post ALTER COLUMN url TYPE varchar(512); ANALYZE post (url); ================================================ FILE: migrations/2024-08-03-155932_increase_post_url_max_length/up.sql ================================================ -- Change the post url max limit to 2000 -- From here: https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-in-different-browsers#417184 ALTER TABLE post ALTER COLUMN url TYPE varchar(2000); ANALYZE post (url); ================================================ FILE: migrations/2024-11-12-090437_move-triggers/down.sql ================================================ -- Edit community aggregates to include voters as active users CREATE OR REPLACE FUNCTION community_aggregates_activity (i text) RETURNS TABLE ( count_ bigint, community_id_ integer) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT count(*), community_id FROM ( SELECT c.creator_id, p.community_id FROM comment c INNER JOIN post p ON c.post_id = p.id INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT p.creator_id, p.community_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT pl.person_id, p.community_id FROM post_like pl INNER JOIN post p ON pl.post_id = p.id INNER JOIN person pe ON pl.person_id = pe.id WHERE pl.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT cl.person_id, p.community_id FROM comment_like cl INNER JOIN post p ON cl.post_id = p.id INNER JOIN person pe ON cl.person_id = pe.id WHERE cl.published > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE) a GROUP BY community_id; END; $$; -- Edit site aggregates to include voters and people who have read posts as active users CREATE OR REPLACE FUNCTION site_aggregates_activity (i text) RETURNS integer LANGUAGE plpgsql AS $$ DECLARE count_ integer; BEGIN SELECT count(*) INTO count_ FROM ( SELECT c.creator_id FROM comment c INNER JOIN person pe ON c.creator_id = pe.id WHERE c.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT p.creator_id FROM post p INNER JOIN person pe ON p.creator_id = pe.id WHERE p.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT pl.person_id FROM post_like pl INNER JOIN person pe ON pl.person_id = pe.id WHERE pl.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT cl.person_id FROM comment_like cl INNER JOIN person pe ON cl.person_id = pe.id WHERE cl.published > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE) a; RETURN count_; END; $$; ================================================ FILE: migrations/2024-11-12-090437_move-triggers/up.sql ================================================ DROP FUNCTION community_aggregates_activity, site_aggregates_activity CASCADE; ================================================ FILE: migrations/2025-01-10-135505_donation-dialog/down.sql ================================================ ALTER TABLE local_user DROP COLUMN last_donation_notification; ================================================ FILE: migrations/2025-01-10-135505_donation-dialog/up.sql ================================================ -- Generate new column last_donation_notification with default value at random time in the -- past year (so that users dont see it all at the same time after instance upgrade). ALTER TABLE local_user ADD COLUMN last_donation_notification timestamptz NOT NULL DEFAULT (now() - (random() * (interval '12 months'))); ================================================ FILE: migrations/2025-02-11-131045_ban-remove-content-pm/down.sql ================================================ ALTER TABLE private_message DROP COLUMN removed; ================================================ FILE: migrations/2025-02-11-131045_ban-remove-content-pm/up.sql ================================================ ALTER TABLE private_message ADD COLUMN removed bool NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-02-24-173152_search-alt-text-of-posts/down.sql ================================================ DROP INDEX idx_post_trigram; CREATE INDEX IF NOT EXISTS idx_post_trigram ON post USING gin (name gin_trgm_ops, body gin_trgm_ops); ================================================ FILE: migrations/2025-02-24-173152_search-alt-text-of-posts/up.sql ================================================ DROP INDEX idx_post_trigram; CREATE INDEX IF NOT EXISTS idx_post_trigram ON post USING gin (name gin_trgm_ops, body gin_trgm_ops, alt_text gin_trgm_ops); ================================================ FILE: migrations/2025-03-07-094522_enable_english_for_all/down.sql ================================================ SELECT 1; ================================================ FILE: migrations/2025-03-07-094522_enable_english_for_all/up.sql ================================================ -- enable english for all users on instances with all languages enabled. -- Fix for https://github.com/LemmyNet/lemmy/pull/5485 DO $$ BEGIN IF ( SELECT count(*) FROM site_language INNER JOIN local_site ON site_language.site_id = local_site.site_id) = 184 THEN INSERT INTO local_user_language (local_user_id, language_id) SELECT local_user_id, 37 FROM local_user_language GROUP BY local_user_id HAVING NOT (37 = ANY (array_agg(language_id))); END IF; END $$ ================================================ FILE: migrations/2025-04-07-100344_registration-rate-limit/down.sql ================================================ ALTER TABLE local_site_rate_limit ALTER register SET DEFAULT 3; UPDATE local_site_rate_limit SET register = 3 WHERE register = 10; ================================================ FILE: migrations/2025-04-07-100344_registration-rate-limit/up.sql ================================================ ALTER TABLE local_site_rate_limit ALTER register SET DEFAULT 10; UPDATE local_site_rate_limit SET register = 10 WHERE register = 3; ================================================ FILE: migrations/2025-05-15-154113_missing_post_indexes/down.sql ================================================ DROP INDEX idx_post_read_post; DROP INDEX idx_post_hide_post; DROP INDEX idx_post_saved_post; ================================================ FILE: migrations/2025-05-15-154113_missing_post_indexes/up.sql ================================================ CREATE INDEX idx_post_read_post ON post_read (post_id); CREATE INDEX idx_post_hide_post ON post_hide (post_id); CREATE INDEX idx_post_saved_post ON post_saved (post_id); ================================================ FILE: migrations/2025-07-29-152742_add_indexes_for_aggregates_activity/down.sql ================================================ DROP INDEX idx_post_published, idx_post_like_published, idx_comment_like_published; ================================================ FILE: migrations/2025-07-29-152742_add_indexes_for_aggregates_activity/up.sql ================================================ -- These actually increased query time, but they prevent more postgres workers from being launched, and so should free up locks. CREATE INDEX idx_post_published ON post (published); CREATE INDEX idx_post_like_published ON post_like (published); CREATE INDEX idx_comment_like_published ON comment_like (published); ================================================ FILE: migrations/2025-07-29-152743_post-aggregates-creator-community-indexes/down.sql ================================================ DROP INDEX idx_post_aggregates_creator, idx_post_aggregates_community; ================================================ FILE: migrations/2025-07-29-152743_post-aggregates-creator-community-indexes/up.sql ================================================ CREATE INDEX idx_post_aggregates_creator ON post_aggregates (creator_id); CREATE INDEX idx_post_aggregates_community ON post_aggregates (community_id); ================================================ FILE: migrations/2025-08-01-000000_enable_private_messages/down.sql ================================================ ALTER TABLE local_user DROP COLUMN enable_private_messages; ================================================ FILE: migrations/2025-08-01-000000_enable_private_messages/up.sql ================================================ ALTER TABLE local_user ADD COLUMN enable_private_messages boolean DEFAULT TRUE NOT NULL; ================================================ FILE: migrations/2025-08-01-000002_error_if_code_migrations_needed/down.sql ================================================ SELECT ; ================================================ FILE: migrations/2025-08-01-000002_error_if_code_migrations_needed/up.sql ================================================ -- https://github.com/LemmyNet/lemmy/pull/5710 -- Uncomment to test: -- ALTER TABLE site DROP COLUMN instance_id; INSERT INTO site (name, public_key) VALUES ('', ''); DO $$ BEGIN IF EXISTS ( SELECT FROM (( SELECT id FROM person WHERE actor_id LIKE 'http://changeme%' OR (local AND public_key = '')) UNION ALL ( SELECT id FROM community WHERE actor_id LIKE 'http://changeme%' OR (local AND public_key = '')) UNION ALL ( SELECT id FROM post WHERE thumbnail_url NOT LIKE 'http%' OR (local AND ap_id LIKE 'http://changeme%')) UNION ALL ( SELECT id FROM comment WHERE ap_id LIKE 'http://changeme%' AND local) UNION ALL ( SELECT id FROM private_message WHERE ap_id LIKE 'http://changeme%' AND local) UNION ALL ( SELECT id FROM site WHERE public_key = '')) AS broken_rows) THEN RAISE 'Unstable upgrade: Youre on too old a version of lemmy. Upgrade to 0.19.0 first.'; END IF; END $$; ================================================ FILE: migrations/2025-08-01-000003_remove_show_scores_column/down.sql ================================================ ALTER TABLE local_user ADD COLUMN show_scores boolean NOT NULL DEFAULT TRUE; ================================================ FILE: migrations/2025-08-01-000003_remove_show_scores_column/up.sql ================================================ ALTER TABLE local_user DROP COLUMN show_scores; ================================================ FILE: migrations/2025-08-01-000004_custom_emoji_tagline_changes/down.sql ================================================ ALTER TABLE custom_emoji ADD COLUMN local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE; UPDATE custom_emoji SET local_site_id = ( SELECT site_id FROM local_site LIMIT 1); ALTER TABLE custom_emoji ALTER COLUMN local_site_id SET NOT NULL; ALTER TABLE tagline ADD COLUMN local_site_id int REFERENCES local_site ON UPDATE CASCADE ON DELETE CASCADE; UPDATE tagline SET local_site_id = ( SELECT site_id FROM local_site LIMIT 1); ALTER TABLE tagline ALTER COLUMN local_site_id SET NOT NULL; ================================================ FILE: migrations/2025-08-01-000004_custom_emoji_tagline_changes/up.sql ================================================ ALTER TABLE custom_emoji DROP COLUMN local_site_id; ALTER TABLE tagline DROP COLUMN local_site_id; ================================================ FILE: migrations/2025-08-01-000005_drop-enable-nsfw/down.sql ================================================ ALTER TABLE local_site ADD COLUMN enable_nsfw boolean NOT NULL DEFAULT TRUE; UPDATE local_site SET enable_nsfw = CASE WHEN site.content_warning IS NULL THEN FALSE ELSE TRUE END FROM site WHERE -- only local site has private key site.private_key IS NOT NULL; ================================================ FILE: migrations/2025-08-01-000005_drop-enable-nsfw/up.sql ================================================ -- if site has enable_nsfw, set a default content warning UPDATE site SET content_warning = CASE WHEN local_site.enable_nsfw THEN 'NSFW' ELSE NULL END FROM local_site -- only local site has private key WHERE private_key IS NOT NULL -- dont overwrite existing content warning AND content_warning IS NOT NULL; ALTER TABLE local_site DROP enable_nsfw; ================================================ FILE: migrations/2025-08-01-000006_default_comment_sort_type/down.sql ================================================ -- This file should undo anything in `up.sql` -- Rename the post sort enum ALTER TYPE post_sort_type_enum RENAME TO sort_type_enum; -- Rename the default post sort columns ALTER TABLE local_user RENAME COLUMN default_post_sort_type TO default_sort_type; ALTER TABLE local_site RENAME COLUMN default_post_sort_type TO default_sort_type; -- Create the comment sort type enum ALTER TABLE local_user DROP COLUMN default_comment_sort_type; ALTER TABLE local_site DROP COLUMN default_comment_sort_type; -- Drop the comment enum DROP TYPE comment_sort_type_enum; ================================================ FILE: migrations/2025-08-01-000006_default_comment_sort_type/up.sql ================================================ -- Rename the post sort enum ALTER TYPE sort_type_enum RENAME TO post_sort_type_enum; -- Rename the default post sort columns ALTER TABLE local_user RENAME COLUMN default_sort_type TO default_post_sort_type; ALTER TABLE local_site RENAME COLUMN default_sort_type TO default_post_sort_type; -- Create the comment sort type enum CREATE TYPE comment_sort_type_enum AS ENUM ( 'Hot', 'Top', 'New', 'Old', 'Controversial' ); -- Add the new default comment sort columns to local_user and local_site ALTER TABLE local_user ADD COLUMN default_comment_sort_type comment_sort_type_enum NOT NULL DEFAULT 'Hot'; ALTER TABLE local_site ADD COLUMN default_comment_sort_type comment_sort_type_enum NOT NULL DEFAULT 'Hot'; ================================================ FILE: migrations/2025-08-01-000007_schedule-post/down.sql ================================================ ALTER TABLE post DROP COLUMN scheduled_publish_time; ================================================ FILE: migrations/2025-08-01-000007_schedule-post/up.sql ================================================ ALTER TABLE post ADD COLUMN scheduled_publish_time timestamptz; CREATE INDEX idx_post_scheduled_publish_time ON post (scheduled_publish_time); ================================================ FILE: migrations/2025-08-01-000008_create_oauth_provider/down.sql ================================================ DROP TABLE oauth_account; DROP TABLE oauth_provider; ALTER TABLE local_site DROP COLUMN oauth_registration; ALTER TABLE local_user ALTER COLUMN password_encrypted SET NOT NULL; ================================================ FILE: migrations/2025-08-01-000008_create_oauth_provider/up.sql ================================================ ALTER TABLE local_user ALTER COLUMN password_encrypted DROP NOT NULL; CREATE TABLE oauth_provider ( id serial PRIMARY KEY, display_name text NOT NULL, issuer text NOT NULL, authorization_endpoint text NOT NULL, token_endpoint text NOT NULL, userinfo_endpoint text NOT NULL, id_claim text NOT NULL, client_id text NOT NULL UNIQUE, client_secret text NOT NULL, scopes text NOT NULL, auto_verify_email boolean DEFAULT TRUE NOT NULL, account_linking_enabled boolean DEFAULT FALSE NOT NULL, enabled boolean DEFAULT TRUE NOT NULL, published timestamp with time zone DEFAULT now() NOT NULL, updated timestamp with time zone ); ALTER TABLE local_site ADD COLUMN oauth_registration boolean DEFAULT TRUE NOT NULL; CREATE TABLE oauth_account ( local_user_id int REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, oauth_provider_id int REFERENCES oauth_provider ON UPDATE CASCADE ON DELETE RESTRICT NOT NULL, oauth_user_id text NOT NULL, published timestamp with time zone DEFAULT now() NOT NULL, updated timestamp with time zone, UNIQUE (oauth_provider_id, oauth_user_id), PRIMARY KEY (oauth_provider_id, local_user_id) ); ================================================ FILE: migrations/2025-08-01-000009_add_federation_vote_rejection/down.sql ================================================ -- Add back the enable_downvotes column ALTER TABLE local_site ADD COLUMN enable_downvotes boolean DEFAULT TRUE NOT NULL; -- regenerate their values (from post_downvotes alone) WITH subquery AS ( SELECT post_downvotes, CASE WHEN post_downvotes = 'Disable'::federation_mode_enum THEN FALSE ELSE TRUE END FROM local_site) UPDATE local_site SET enable_downvotes = subquery.case FROM subquery; -- Drop the new columns ALTER TABLE local_site DROP COLUMN post_upvotes, DROP COLUMN post_downvotes, DROP COLUMN comment_upvotes, DROP COLUMN comment_downvotes; DROP TYPE federation_mode_enum; ================================================ FILE: migrations/2025-08-01-000009_add_federation_vote_rejection/up.sql ================================================ -- This removes the simple enable_downvotes setting, in favor of an -- expanded federation mode type for post/comment up/downvotes. -- Create the federation mode enum CREATE TYPE federation_mode_enum AS ENUM ( 'All', 'Local', 'Disable' ); -- Add the new columns ALTER TABLE local_site ADD COLUMN post_upvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL, ADD COLUMN post_downvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL, ADD COLUMN comment_upvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL, ADD COLUMN comment_downvotes federation_mode_enum DEFAULT 'All'::federation_mode_enum NOT NULL; -- Copy over the enable_downvotes into the post and comment downvote settings WITH subquery AS ( SELECT enable_downvotes, CASE WHEN enable_downvotes = TRUE THEN 'All'::federation_mode_enum ELSE 'Disable'::federation_mode_enum END FROM local_site) UPDATE local_site SET post_downvotes = subquery.case, comment_downvotes = subquery.case FROM subquery; -- Drop the enable_downvotes column ALTER TABLE local_site DROP COLUMN enable_downvotes; ================================================ FILE: migrations/2025-08-01-000010_remove_auto_expand/down.sql ================================================ ALTER TABLE local_user ADD COLUMN auto_expand boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-08-01-000010_remove_auto_expand/up.sql ================================================ ALTER TABLE local_user DROP COLUMN auto_expand; ================================================ FILE: migrations/2025-08-01-000011_add_short_community_description/down.sql ================================================ ALTER TABLE community DROP COLUMN description; ALTER TABLE community RENAME COLUMN sidebar TO description; ================================================ FILE: migrations/2025-08-01-000011_add_short_community_description/up.sql ================================================ -- Renaming description to sidebar ALTER TABLE community RENAME COLUMN description TO sidebar; -- Adding a short description column ALTER TABLE community ADD COLUMN description varchar(150); ================================================ FILE: migrations/2025-08-01-000012_no-individual-inboxes/down.sql ================================================ ALTER TABLE person ADD COLUMN shared_inbox_url varchar(255); ALTER TABLE person RENAME CONSTRAINT person_shared_inbox_url_not_null TO user__inbox_url_not_null; ALTER TABLE community DROP CONSTRAINT community_shared_inbox_url_not_null; ALTER TABLE community ADD COLUMN shared_inbox_url varchar(255), ALTER COLUMN inbox_url SET NOT NULL; ================================================ FILE: migrations/2025-08-01-000012_no-individual-inboxes/up.sql ================================================ -- replace value of inbox_url with shared_inbox_url and the drop shared inbox UPDATE person SET shared_inbox_url = inbox_url WHERE shared_inbox_url IS NULL; ALTER TABLE person DROP COLUMN inbox_url, ALTER COLUMN shared_inbox_url SET NOT NULL, ALTER COLUMN shared_inbox_url SET DEFAULT generate_unique_changeme (); ALTER TABLE person RENAME COLUMN shared_inbox_url TO inbox_url; UPDATE community SET shared_inbox_url = inbox_url WHERE shared_inbox_url IS NULL; ALTER TABLE community DROP COLUMN inbox_url, ALTER COLUMN shared_inbox_url SET NOT NULL, ALTER COLUMN shared_inbox_url SET DEFAULT generate_unique_changeme (); ALTER TABLE community RENAME COLUMN shared_inbox_url TO inbox_url; ================================================ FILE: migrations/2025-08-01-000013_comment-vote-remote-postid/down.sql ================================================ ALTER TABLE comment_like ADD COLUMN post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE; UPDATE comment_like SET post_id = comment.post_id FROM comment WHERE comment_id = comment.id; ALTER TABLE comment_like ALTER COLUMN post_id SET NOT NULL; CREATE INDEX idx_comment_like_post ON comment_like (post_id); ================================================ FILE: migrations/2025-08-01-000013_comment-vote-remote-postid/up.sql ================================================ ALTER TABLE comment_like DROP post_id; ================================================ FILE: migrations/2025-08-01-000014_private-community/down.sql ================================================ -- Remove private visibility ALTER TYPE community_visibility RENAME TO community_visibility__; CREATE TYPE community_visibility AS enum ( 'Public', 'LocalOnly' ); ALTER TABLE community ALTER COLUMN visibility DROP DEFAULT; ALTER TABLE community ALTER COLUMN visibility TYPE community_visibility USING visibility::text::community_visibility; ALTER TABLE community ALTER COLUMN visibility SET DEFAULT 'Public'; DROP TYPE community_visibility__; -- Revert community follower changes CREATE OR REPLACE FUNCTION convert_follower_state (s community_follower_state) RETURNS bool LANGUAGE sql AS $$ SELECT CASE WHEN s = 'Pending' THEN TRUE ELSE FALSE END $$; ALTER TABLE community_follower ALTER COLUMN state TYPE bool USING convert_follower_state (state); DROP FUNCTION convert_follower_state; ALTER TABLE community_follower ALTER COLUMN state SET DEFAULT FALSE; ALTER TABLE community_follower RENAME COLUMN state TO pending; DROP TYPE community_follower_state; ALTER TABLE community_follower DROP COLUMN approver_id; ALTER TABLE ONLY local_site ALTER COLUMN federation_signed_fetch SET DEFAULT FALSE; ================================================ FILE: migrations/2025-08-01-000014_private-community/up.sql ================================================ ALTER TYPE community_visibility ADD value 'Private'; -- Change `community_follower.pending` to `state` enum CREATE TYPE community_follower_state AS enum ( 'Accepted', 'Pending', 'ApprovalRequired' ); ALTER TABLE community_follower ALTER COLUMN pending DROP DEFAULT; CREATE OR REPLACE FUNCTION convert_follower_state (b bool) RETURNS community_follower_state LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT CASE WHEN b = TRUE THEN 'Pending'::community_follower_state ELSE 'Accepted'::community_follower_state END $$; ALTER TABLE community_follower ALTER COLUMN pending TYPE community_follower_state USING convert_follower_state (pending); DROP FUNCTION convert_follower_state; ALTER TABLE community_follower RENAME COLUMN pending TO state; -- Add column for mod who approved the private community follower -- Dont use foreign key here, otherwise joining to person table doesnt work easily ALTER TABLE community_follower ADD COLUMN approver_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE; -- Enable signed fetch, necessary to fetch content in private communities ALTER TABLE ONLY local_site ALTER COLUMN federation_signed_fetch SET DEFAULT TRUE; UPDATE local_site SET federation_signed_fetch = TRUE; ================================================ FILE: migrations/2025-08-01-000015_add_mark_fetched_posts_as_read/down.sql ================================================ ALTER TABLE local_user DROP COLUMN auto_mark_fetched_posts_as_read; ================================================ FILE: migrations/2025-08-01-000015_add_mark_fetched_posts_as_read/up.sql ================================================ ALTER TABLE local_user ADD COLUMN auto_mark_fetched_posts_as_read boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2025-08-01-000016_smoosh-tables-together/down.sql ================================================ -- For each new actions table, create tables that are dropped in up.sql, and insert into them CREATE TABLE comment_saved ( person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz DEFAULT now() NOT NULL, CONSTRAINT comment_saved_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, comment_id) ); INSERT INTO comment_saved (person_id, comment_id, published) SELECT person_id, comment_id, saved FROM comment_actions WHERE saved IS NOT NULL; CREATE TABLE community_block ( person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz DEFAULT now() NOT NULL, PRIMARY KEY (person_id, community_id) ); INSERT INTO community_block (person_id, community_id, published) SELECT person_id, community_id, blocked FROM community_actions WHERE blocked IS NOT NULL; CREATE TABLE community_person_ban ( community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz DEFAULT now(), expires timestamptz, CONSTRAINT community_user_ban_published_not_null NOT NULL published, CONSTRAINT community_user_ban_community_id_not_null NOT NULL community_id, CONSTRAINT community_user_ban_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, community_id) ); INSERT INTO community_person_ban (community_id, person_id, published, expires) SELECT community_id, person_id, received_ban, ban_expires FROM community_actions WHERE received_ban IS NOT NULL; CREATE TABLE community_moderator ( community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz DEFAULT now() NOT NULL, CONSTRAINT community_moderator_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, community_id) ); INSERT INTO community_moderator (community_id, person_id, published) SELECT community_id, person_id, became_moderator FROM community_actions WHERE became_moderator IS NOT NULL; CREATE TABLE person_block ( person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, target_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz DEFAULT now() NOT NULL, PRIMARY KEY (person_id, target_id) ); INSERT INTO person_block (person_id, target_id, published) SELECT person_id, target_id, blocked FROM person_actions WHERE blocked IS NOT NULL; CREATE TABLE IF NOT EXISTS person_post_aggregates ( person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read_comments bigint DEFAULT 0 NOT NULL, published timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (person_id, post_id) ); INSERT INTO person_post_aggregates (person_id, post_id, read_comments, published) SELECT person_id, post_id, read_comments_amount, read_comments FROM post_actions WHERE read_comments IS NOT NULL; CREATE TABLE post_hide ( post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz DEFAULT now() NOT NULL, PRIMARY KEY (person_id, post_id) ); INSERT INTO post_hide (post_id, person_id, published) SELECT post_id, person_id, hidden FROM post_actions WHERE hidden IS NOT NULL; CREATE TABLE IF NOT EXISTS post_like ( post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, score smallint NOT NULL, published timestamptz DEFAULT now() NOT NULL, CONSTRAINT post_like_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, post_id) ); INSERT INTO post_like (post_id, person_id, score, published) SELECT post_id, person_id, CASE WHEN vote_is_upvote THEN 1 ELSE -1 END, liked FROM post_actions WHERE liked IS NOT NULL; CREATE TABLE post_saved ( post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz DEFAULT now() NOT NULL, CONSTRAINT post_saved_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, post_id) ); INSERT INTO post_saved (post_id, person_id, published) SELECT post_id, person_id, saved FROM post_actions WHERE saved IS NOT NULL; -- Do the opposite of the `ALTER TABLE` commands in up.sql DELETE FROM comment_actions WHERE liked IS NULL; DELETE FROM community_actions WHERE followed IS NULL; DELETE FROM instance_actions WHERE blocked IS NULL; DELETE FROM person_actions WHERE followed IS NULL; DELETE FROM post_actions WHERE read IS NULL; CREATE TABLE IF NOT EXISTS comment_like ( comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, score smallint NOT NULL, published timestamptz DEFAULT now() NOT NULL, CONSTRAINT comment_like_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, comment_id) ); INSERT INTO comment_like (comment_id, person_id, score, published) SELECT comment_id, person_id, CASE WHEN vote_is_upvote THEN 1 ELSE -1 END, liked FROM comment_actions WHERE liked IS NOT NULL; ALTER TABLE community_actions RENAME TO community_follower; ALTER TABLE instance_actions RENAME TO instance_block; ALTER TABLE person_actions RENAME TO person_follower; CREATE TABLE IF NOT EXISTS post_read ( post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz DEFAULT now() NOT NULL, CONSTRAINT post_read_user_id_not_null NOT NULL person_id, PRIMARY KEY (person_id, post_id) ); INSERT INTO post_read (post_id, person_id, published) SELECT post_id, person_id, read FROM post_actions WHERE read IS NOT NULL; ALTER TABLE community_follower RENAME COLUMN followed TO published; ALTER TABLE community_follower RENAME COLUMN follow_state TO state; ALTER TABLE community_follower RENAME COLUMN follow_approver_id TO approver_id; ALTER TABLE instance_block RENAME COLUMN blocked TO published; ALTER TABLE person_follower RENAME COLUMN person_id TO follower_id; ALTER TABLE person_follower RENAME COLUMN target_id TO person_id; ALTER TABLE person_follower RENAME COLUMN followed TO published; ALTER TABLE person_follower RENAME COLUMN follow_pending TO pending; ALTER TABLE community_follower DROP CONSTRAINT community_actions_pkey, DROP CONSTRAINT community_actions_check_followed, DROP CONSTRAINT community_actions_check_received_ban, DROP CONSTRAINT community_actions_community_id_not_null, ADD CONSTRAINT community_actions_pkey PRIMARY KEY (person_id, community_id), ALTER COLUMN community_id SET NOT NULL, ALTER COLUMN published SET NOT NULL, ALTER COLUMN published SET DEFAULT now(), ADD CONSTRAINT community_follower_pending_not_null NOT NULL state, DROP COLUMN blocked, DROP COLUMN became_moderator, DROP COLUMN received_ban, DROP COLUMN ban_expires; ALTER TABLE community_follower RENAME CONSTRAINT community_actions_person_id_not_null TO community_follower_user_id_not_null; ALTER TABLE instance_block DROP CONSTRAINT instance_actions_pkey, DROP CONSTRAINT instance_actions_instance_id_not_null, DROP CONSTRAINT instance_actions_person_id_not_null, ADD CONSTRAINT instance_actions_pkey PRIMARY KEY (person_id, instance_id), ALTER COLUMN published SET NOT NULL, ALTER COLUMN instance_id SET NOT NULL, ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN published SET DEFAULT now(); ALTER TABLE person_follower DROP CONSTRAINT person_actions_pkey, DROP CONSTRAINT person_actions_check_followed, DROP CONSTRAINT person_actions_person_id_not_null, DROP CONSTRAINT person_actions_target_id_not_null, ADD CONSTRAINT person_actions_pkey PRIMARY KEY (follower_id, person_id), ALTER COLUMN follower_id SET NOT NULL, ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN published SET NOT NULL, ALTER COLUMN published SET DEFAULT now(), ALTER COLUMN pending SET NOT NULL, DROP COLUMN blocked; -- Rename associated stuff ALTER INDEX community_actions_pkey RENAME TO community_follower_pkey; ALTER INDEX idx_community_actions_community RENAME TO idx_community_follower_community; ALTER TABLE community_follower RENAME CONSTRAINT community_actions_community_id_fkey TO community_follower_community_id_fkey; ALTER TABLE community_follower RENAME CONSTRAINT community_actions_person_id_fkey TO community_follower_person_id_fkey; ALTER TABLE community_follower RENAME CONSTRAINT community_actions_follow_approver_id_fkey TO community_follower_approver_id_fkey; ALTER INDEX instance_actions_pkey RENAME TO instance_block_pkey; ALTER TABLE instance_block RENAME CONSTRAINT instance_actions_instance_id_fkey TO instance_block_instance_id_fkey; ALTER TABLE instance_block RENAME CONSTRAINT instance_actions_person_id_fkey TO instance_block_person_id_fkey; ALTER INDEX person_actions_pkey RENAME TO person_follower_pkey; ALTER TABLE person_follower RENAME CONSTRAINT person_actions_target_id_fkey TO person_follower_person_id_fkey; ALTER TABLE person_follower RENAME CONSTRAINT person_actions_person_id_fkey TO person_follower_follower_id_fkey; -- Rename idx_community_actions_followed and remove filter CREATE INDEX idx_community_follower_published ON community_follower (published); DROP INDEX idx_community_actions_followed; -- Move indexes back to their original tables CREATE INDEX idx_comment_saved_comment ON comment_saved (comment_id); CREATE INDEX idx_comment_saved_person ON comment_saved (person_id); CREATE INDEX idx_community_block_community ON community_block (community_id); CREATE INDEX idx_community_moderator_community ON community_moderator (community_id); CREATE INDEX idx_community_moderator_published ON community_moderator (published); CREATE INDEX idx_person_block_person ON person_block (person_id); CREATE INDEX idx_person_block_target ON person_block (target_id); CREATE INDEX IF NOT EXISTS idx_person_post_aggregates_person ON person_post_aggregates (person_id); CREATE INDEX IF NOT EXISTS idx_person_post_aggregates_post ON person_post_aggregates (post_id); CREATE INDEX IF NOT EXISTS idx_post_like_post ON post_like (post_id); CREATE INDEX idx_comment_like_comment ON comment_like (comment_id); CREATE INDEX idx_post_hide_post ON post_hide (post_id); CREATE INDEX idx_post_read_post ON post_read (post_id); CREATE INDEX idx_post_saved_post ON post_saved (post_id); CREATE INDEX idx_post_like_published ON post_like (published); CREATE INDEX idx_comment_like_published ON comment_like (published); DROP INDEX idx_person_actions_person, idx_person_actions_target, idx_post_actions_person, idx_post_actions_post; -- Drop `NOT NULL` indexes of columns that still exist DROP INDEX idx_comment_actions_liked_not_null, idx_community_actions_followed_not_null, idx_person_actions_followed_not_null, idx_post_actions_read_not_null, idx_instance_actions_blocked_not_null, idx_comment_actions_person, idx_community_actions_person, idx_instance_actions_instance, idx_instance_actions_person; -- Drop statistics of columns that still exist DROP statistics comment_actions_liked_stat, community_actions_followed_stat, person_actions_followed_stat; DROP TABLE comment_actions, post_actions; ================================================ FILE: migrations/2025-08-01-000016_smoosh-tables-together/up.sql ================================================ -- Consolidates all the old tables like post_read, post_like, into post_actions, to reduce joins and increase performance. -- This creates the tables: -- post_actions, comment_actions, community_actions, instance_actions, and person_actions. -- -- comment_actions CREATE TABLE comment_actions AS SELECT max(liked) AS liked, max(saved) AS saved, person_id, comment_id, max(like_score) = 1 AS vote_is_upvote -- `null = 1` returns null FROM ( SELECT person_id, comment_id, score AS like_score, published AS liked, NULL::timestamptz AS saved FROM comment_like UNION ALL SELECT person_id, comment_id, NULL::int, NULL::timestamptz, published FROM comment_saved) GROUP BY person_id, comment_id; -- Drop the tables DROP TABLE comment_saved, comment_like; -- Add the constraints ALTER TABLE comment_actions ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN comment_id SET NOT NULL, ADD PRIMARY KEY (person_id, comment_id), ADD CONSTRAINT comment_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT comment_actions_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT comment_actions_check_liked CHECK (((liked IS NULL) = (vote_is_upvote IS NULL))); -- Create new indexes, with `OR` being used to allow `IS NOT NULL` filters in queries to use either column in -- a group (e.g. `liked IS NOT NULL` and `vote_is_upvote IS NOT NULL` both work) CREATE INDEX idx_comment_actions_person ON comment_actions (person_id); CREATE INDEX idx_comment_actions_comment ON comment_actions (comment_id); CREATE INDEX idx_comment_actions_liked_not_null ON comment_actions (person_id, comment_id) WHERE liked IS NOT NULL OR vote_is_upvote IS NOT NULL; CREATE INDEX idx_comment_actions_saved_not_null ON comment_actions (person_id, comment_id) WHERE saved IS NOT NULL; -- Here's an SO link on merges, but this turned out to be slower than a -- disabled triggers + disabled primary key + full union select + insert with group by -- SO link on merges: https://stackoverflow.com/a/74066614/1655478 CREATE TABLE post_actions AS SELECT max(read) AS read, max(read_comments) AS read_comments, max(saved) AS saved, max(liked) AS liked, max(hidden) AS hidden, person_id, post_id, cast(max(read_comments_amount) AS int) AS read_comments_amount, max(like_score) = 1 AS vote_is_upvote -- `null = 1` returns null FROM ( SELECT person_id, post_id, published AS read, NULL::timestamptz AS read_comments, NULL::int AS read_comments_amount, NULL::timestamptz AS saved, NULL::timestamptz AS liked, NULL::int AS like_score, NULL::timestamptz AS hidden FROM post_read UNION ALL SELECT person_id, post_id, NULL::timestamptz, published, read_comments, NULL::timestamptz, NULL::timestamptz, NULL::int, NULL::timestamptz FROM person_post_aggregates UNION ALL SELECT person_id, post_id, NULL::timestamptz, NULL::timestamptz, NULL::int, published, NULL::timestamptz, NULL::int, NULL::timestamptz FROM post_saved UNION ALL SELECT person_id, post_id, NULL::timestamptz, NULL::timestamptz, NULL::int, NULL::timestamptz, published, score, NULL::timestamptz FROM post_like UNION ALL SELECT person_id, post_id, NULL::timestamptz, NULL::timestamptz, NULL::int, NULL::timestamptz, NULL::timestamptz, NULL::int, published FROM post_hide) GROUP BY person_id, post_id; -- Drop the tables DROP TABLE post_read, person_post_aggregates, post_like, post_saved, post_hide; -- Add the constraints ALTER TABLE post_actions ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN post_id SET NOT NULL, ADD PRIMARY KEY (person_id, post_id), ADD CONSTRAINT post_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT post_actions_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT post_actions_check_liked CHECK (((liked IS NULL) = (vote_is_upvote IS NULL))), ADD CONSTRAINT post_actions_check_read_comments CHECK (((read_comments IS NULL) = (read_comments_amount IS NULL))); -- Create indexes CREATE INDEX idx_post_actions_person ON post_actions (person_id); CREATE INDEX idx_post_actions_post ON post_actions (post_id); CREATE INDEX idx_post_actions_read_not_null ON post_actions (person_id, post_id) WHERE read IS NOT NULL; CREATE INDEX idx_post_actions_read_comments_not_null ON post_actions (person_id, post_id) WHERE read_comments IS NOT NULL OR read_comments_amount IS NOT NULL; CREATE INDEX idx_post_actions_saved_not_null ON post_actions (person_id, post_id) WHERE saved IS NOT NULL; CREATE INDEX idx_post_actions_liked_not_null ON post_actions (person_id, post_id) WHERE liked IS NOT NULL OR vote_is_upvote IS NOT NULL; CREATE INDEX idx_post_actions_hidden_not_null ON post_actions (person_id, post_id) WHERE hidden IS NOT NULL; -- community_actions CREATE TABLE community_actions AS SELECT max(followed) AS followed, max(blocked) AS blocked, max(became_moderator) AS became_moderator, max(received_ban) AS received_ban, max(ban_expires) AS ban_expires, person_id, community_id, max(follow_state) AS follow_state, max(follow_approver_id) AS follow_approver_id FROM ( SELECT person_id, community_id, published AS followed, state AS follow_state, approver_id AS follow_approver_id, NULL::timestamptz AS blocked, NULL::timestamptz AS became_moderator, NULL::timestamptz AS received_ban, NULL::timestamptz AS ban_expires FROM community_follower UNION ALL SELECT person_id, community_id, NULL::timestamptz, NULL::community_follower_state, NULL::int, published, NULL::timestamptz, NULL::timestamptz, NULL::timestamptz FROM community_block UNION ALL SELECT person_id, community_id, NULL::timestamptz, NULL::community_follower_state, NULL::int, NULL::timestamptz, published, NULL::timestamptz, NULL::timestamptz FROM community_moderator UNION ALL SELECT person_id, community_id, NULL::timestamptz, NULL::community_follower_state, NULL::int, NULL::timestamptz, NULL::timestamptz, published, expires FROM community_person_ban) GROUP BY person_id, community_id; -- Drop the old tables DROP TABLE community_follower, community_block, community_moderator, community_person_ban; -- Add the constraints ALTER TABLE community_actions ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN community_id SET NOT NULL, ADD PRIMARY KEY (person_id, community_id), ADD CONSTRAINT community_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT community_actions_follow_approver_id_fkey FOREIGN KEY (follow_approver_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT community_actions_community_id_fkey FOREIGN KEY (community_id) REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT community_actions_check_followed CHECK ((((followed IS NULL) = (follow_state IS NULL)) AND (NOT ((followed IS NULL) AND (follow_approver_id IS NOT NULL))))), ADD CONSTRAINT community_actions_check_received_ban CHECK ((NOT ((received_ban IS NULL) AND (ban_expires IS NOT NULL)))); -- Create indexes CREATE INDEX idx_community_actions_person ON community_actions (person_id); CREATE INDEX idx_community_actions_community ON community_actions (community_id); CREATE INDEX idx_community_actions_followed ON community_actions (followed) WHERE followed IS NOT NULL; CREATE INDEX idx_community_actions_followed_not_null ON community_actions (person_id, community_id) WHERE followed IS NOT NULL OR follow_state IS NOT NULL; CREATE INDEX idx_community_actions_became_moderator ON community_actions (became_moderator) WHERE became_moderator IS NOT NULL; CREATE INDEX idx_community_actions_became_moderator_not_null ON community_actions (person_id, community_id) WHERE became_moderator IS NOT NULL; CREATE INDEX idx_community_actions_blocked_not_null ON community_actions (person_id, community_id) WHERE blocked IS NOT NULL; CREATE INDEX idx_community_actions_received_ban_not_null ON community_actions (person_id, community_id) WHERE received_ban IS NOT NULL; -- instance_actions CREATE TABLE instance_actions AS SELECT published AS blocked, person_id, instance_id FROM instance_block; DROP TABLE instance_block; -- Add the constraints ALTER TABLE instance_actions ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN instance_id SET NOT NULL, ADD PRIMARY KEY (person_id, instance_id), ADD CONSTRAINT instance_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT instance_actions_instance_id_fkey FOREIGN KEY (instance_id) REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE; -- This index is currently redundant because instance_actions only has 1 action type, but inconsistency -- with other tables would make it harder to do everything correctly when adding another action type CREATE INDEX idx_instance_actions_person ON instance_actions (person_id); CREATE INDEX idx_instance_actions_instance ON instance_actions (instance_id); CREATE INDEX idx_instance_actions_blocked_not_null ON instance_actions (person_id, instance_id) WHERE blocked IS NOT NULL; -- person_actions CREATE TABLE person_actions AS SELECT max(followed) AS followed, max(blocked) AS blocked, person_id, target_id, cast(max(follow_pending) AS boolean) AS follow_pending FROM ( SELECT follower_id AS person_id, person_id AS target_id, published AS followed, pending::int AS follow_pending, NULL::timestamptz AS blocked FROM person_follower UNION ALL SELECT person_id, target_id, NULL::timestamptz, NULL::int, published FROM person_block) GROUP BY person_id, target_id; -- add primary key, foreign keys, and not nulls ALTER TABLE person_actions ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN target_id SET NOT NULL, ADD PRIMARY KEY (person_id, target_id), ADD CONSTRAINT person_actions_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_actions_target_id_fkey FOREIGN KEY (target_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_actions_check_followed CHECK (((followed IS NULL) = (follow_pending IS NULL))); DROP TABLE person_block, person_follower; CREATE INDEX idx_person_actions_person ON person_actions (person_id); CREATE INDEX idx_person_actions_target ON person_actions (target_id); CREATE INDEX idx_person_actions_followed_not_null ON person_actions (person_id, target_id) WHERE followed IS NOT NULL OR follow_pending IS NOT NULL; CREATE INDEX idx_person_actions_blocked_not_null ON person_actions (person_id, target_id) WHERE blocked IS NOT NULL; -- Create new statistics for more accurate estimations of how much of an index will be read (e.g. for -- `(liked, like_score)`, the query planner might othewise assume that `(TRUE, FALSE)` and `(TRUE, TRUE)` -- are equally likely when only `(TRUE, TRUE)` is possible, which would make it severely underestimate -- the efficiency of using the index) CREATE statistics comment_actions_liked_stat ON (liked IS NULL), (vote_is_upvote IS NULL) FROM comment_actions; CREATE statistics community_actions_followed_stat ON (followed IS NULL), (follow_state IS NULL) FROM community_actions; CREATE statistics person_actions_followed_stat ON (followed IS NULL), (follow_pending IS NULL) FROM person_actions; CREATE statistics post_actions_read_comments_stat ON (read_comments IS NULL), (read_comments_amount IS NULL) FROM post_actions; CREATE statistics post_actions_liked_stat ON (liked IS NULL), (vote_is_upvote IS NULL), (post_id IS NULL) FROM post_actions; ================================================ FILE: migrations/2025-08-01-000017_forbid_diesel_cli/down.sql ================================================ DROP FUNCTION forbid_diesel_cli CASCADE; ================================================ FILE: migrations/2025-08-01-000017_forbid_diesel_cli/up.sql ================================================ -- This trigger prevents using the Diesel CLI to run or revert migrations, so the custom migration runner -- can drop and recreate the `r` schema for new migrations. -- -- This migration being seperate from the next migration (created in the same PR) guarantees that the -- Diesel CLI will fail to bring the number of pending migrations to 0, which is one of the conditions -- required to skip running replaceable_schema. -- -- If the Diesel CLI could run or revert migrations, this scenario would be possible: -- -- Run `diesel migration redo` when the newest migration has a new table with triggers. End up with triggers -- being dropped and not replaced because triggers are created outside of up.sql. The custom migration runner -- sees that there are no pending migrations and the value in the `previously_run_sql` trigger is correct, so -- it doesn't rebuild the `r` schema. There is now incorrect behavior but no error messages. CREATE FUNCTION forbid_diesel_cli () RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF NOT EXISTS ( SELECT FROM pg_locks WHERE (locktype, pid, objid) = ('advisory', pg_backend_pid(), 0)) THEN RAISE 'migrations must be managed using lemmy_server instead of diesel CLI'; END IF; RETURN NULL; END; $$; CREATE TRIGGER forbid_diesel_cli BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON __diesel_schema_migrations EXECUTE FUNCTION forbid_diesel_cli (); ================================================ FILE: migrations/2025-08-01-000018_custom_migration_runner/down.sql ================================================ DROP TABLE previously_run_sql; ================================================ FILE: migrations/2025-08-01-000018_custom_migration_runner/up.sql ================================================ DROP SCHEMA IF EXISTS r CASCADE; CREATE TABLE previously_run_sql ( -- For compatibility with Diesel id boolean PRIMARY KEY, -- Too big to be used as primary key content text NOT NULL ); INSERT INTO previously_run_sql (id, content) VALUES (TRUE, ''); ================================================ FILE: migrations/2025-08-01-000019_add_report_count/down.sql ================================================ ALTER TABLE post_aggregates DROP COLUMN report_count, DROP COLUMN unresolved_report_count; ALTER TABLE comment_aggregates DROP COLUMN report_count, DROP COLUMN unresolved_report_count; ================================================ FILE: migrations/2025-08-01-000019_add_report_count/up.sql ================================================ -- Adding report_count and unresolved_report_count -- to the post and comment aggregate tables ALTER TABLE post_aggregates ADD COLUMN report_count smallint NOT NULL DEFAULT 0, ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; -- Disable the triggers temporarily ALTER TABLE post_aggregates DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'post_aggregates'); -- Update the historical counts -- Posts UPDATE post_aggregates AS a SET report_count = cnt.count FROM ( SELECT post_id, count(*) AS count FROM post_report GROUP BY post_id) cnt WHERE a.post_id = cnt.post_id; -- The unresolved UPDATE post_aggregates AS a SET unresolved_report_count = cnt.count FROM ( SELECT post_id, count(*) AS count FROM post_report WHERE resolved = 'f' GROUP BY post_id) cnt WHERE a.post_id = cnt.post_id; -- Re-enable triggers after upserts ALTER TABLE post_aggregates ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'post_aggregates'); -- reindex REINDEX TABLE post_aggregates; ALTER TABLE comment_aggregates ADD COLUMN report_count smallint NOT NULL DEFAULT 0, ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; -- Disable the triggers temporarily ALTER TABLE comment_aggregates DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'comment_aggregates'); -- Comments UPDATE comment_aggregates AS a SET report_count = cnt.count FROM ( SELECT comment_id, count(*) AS count FROM comment_report GROUP BY comment_id) cnt WHERE a.comment_id = cnt.comment_id; -- The unresolved UPDATE comment_aggregates AS a SET unresolved_report_count = cnt.count FROM ( SELECT comment_id, count(*) AS count FROM comment_report WHERE resolved = 'f' GROUP BY comment_id) cnt WHERE a.comment_id = cnt.comment_id; -- Re-enable triggers after upserts ALTER TABLE comment_aggregates ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'comment_aggregates'); -- reindex REINDEX TABLE comment_aggregates; ================================================ FILE: migrations/2025-08-01-000020_oauth_pkce/down.sql ================================================ ALTER TABLE oauth_provider DROP COLUMN use_pkce; ================================================ FILE: migrations/2025-08-01-000020_oauth_pkce/up.sql ================================================ ALTER TABLE oauth_provider ADD COLUMN use_pkce boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2025-08-01-000021_add_blurhash_to_image_details/down.sql ================================================ ALTER TABLE image_details DROP COLUMN blurhash; ================================================ FILE: migrations/2025-08-01-000021_add_blurhash_to_image_details/up.sql ================================================ -- Add a blurhash column for image_details ALTER TABLE image_details -- Supposed to be 20-30 chars, use 50 to be safe ADD COLUMN blurhash varchar(50); ================================================ FILE: migrations/2025-08-01-000022_instance-block-mod-log/down.sql ================================================ ALTER TABLE federation_blocklist DROP expires; DROP TABLE admin_block_instance; DROP TABLE admin_allow_instance; ================================================ FILE: migrations/2025-08-01-000022_instance-block-mod-log/up.sql ================================================ ALTER TABLE federation_blocklist ADD COLUMN expires timestamptz; CREATE TABLE admin_block_instance ( id serial PRIMARY KEY, instance_id int NOT NULL REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE, admin_person_id int NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE, blocked bool NOT NULL, reason text, expires timestamptz, when_ timestamptz NOT NULL DEFAULT now() ); CREATE TABLE admin_allow_instance ( id serial PRIMARY KEY, instance_id int NOT NULL REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE, admin_person_id int NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE, allowed bool NOT NULL, reason text, when_ timestamptz NOT NULL DEFAULT now() ); ================================================ FILE: migrations/2025-08-01-000023_add_report_combined_table/down.sql ================================================ DROP TABLE report_combined; ================================================ FILE: migrations/2025-08-01-000023_add_report_combined_table/up.sql ================================================ -- Creates combined tables for -- Reports: (comment, post, and private_message) CREATE TABLE report_combined ( id serial PRIMARY KEY, published timestamptz NOT NULL, post_report_id int UNIQUE REFERENCES post_report ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, comment_report_id int UNIQUE REFERENCES comment_report ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, private_message_report_id int UNIQUE REFERENCES private_message_report ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, -- Make sure only one of the columns is not null CHECK (num_nonnulls (post_report_id, comment_report_id, private_message_report_id) = 1) ); CREATE INDEX idx_report_combined_published ON report_combined (published DESC, id DESC); CREATE INDEX idx_report_combined_published_asc ON report_combined (reverse_timestamp_sort (published) DESC, id DESC); -- Updating the history INSERT INTO report_combined (published, post_report_id, comment_report_id, private_message_report_id) SELECT published, id, NULL::int, NULL::int FROM post_report UNION ALL SELECT published, NULL::int, id, NULL::int FROM comment_report UNION ALL SELECT published, NULL::int, NULL::int, id FROM private_message_report; ALTER TABLE report_combined ALTER CONSTRAINT report_combined_post_report_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT report_combined_comment_report_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT report_combined_private_message_report_id_fkey NOT DEFERRABLE; ================================================ FILE: migrations/2025-08-01-000024_add_person_content_combined_table/down.sql ================================================ DROP TABLE person_content_combined, person_saved_combined; ================================================ FILE: migrations/2025-08-01-000024_add_person_content_combined_table/up.sql ================================================ -- Creates combined tables for -- person_content: (comment, post) -- person_saved: (comment, post) CREATE TABLE person_content_combined AS SELECT published, creator_id, id AS post_id, NULL::int AS comment_id FROM post UNION ALL SELECT published, creator_id, NULL::int, id FROM comment; -- Add the constraints ALTER TABLE person_content_combined ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ALTER COLUMN published SET NOT NULL, ALTER COLUMN creator_id SET NOT NULL, ADD CONSTRAINT person_content_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_content_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_content_combined_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD UNIQUE (post_id), ADD UNIQUE (comment_id), ADD CONSTRAINT person_content_combined_check CHECK (num_nonnulls (post_id, comment_id) = 1); CREATE INDEX idx_person_content_combined_creator_published ON person_content_combined (creator_id, published DESC, id DESC); -- This is for local_users only CREATE TABLE person_saved_combined AS SELECT pa.saved AS saved, pa.person_id AS person_id, p.creator_id AS creator_id, pa.post_id AS post_id, NULL::int AS comment_id FROM post_actions pa, local_user lu, post p WHERE pa.person_id = lu.person_id AND pa.saved IS NOT NULL AND pa.post_id = p.id UNION ALL SELECT ca.saved, ca.person_id, c.creator_id AS creator_id, NULL::int, ca.comment_id FROM comment_actions ca, local_user lu, comment c WHERE ca.person_id = lu.person_id AND ca.saved IS NOT NULL AND ca.comment_id = c.id; -- Add the constraints ALTER TABLE person_saved_combined ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ALTER COLUMN saved SET NOT NULL, ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN creator_id SET NOT NULL, ADD CONSTRAINT person_saved_combined_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_saved_combined_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_saved_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_saved_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_saved_combined_check CHECK (num_nonnulls (post_id, comment_id) = 1), ADD UNIQUE (person_id, post_id), ADD UNIQUE (person_id, comment_id); CREATE INDEX idx_person_saved_combined_person_saved ON person_saved_combined (person_id, saved DESC, id DESC); CREATE INDEX idx_person_saved_combined_person ON person_saved_combined (person_id); CREATE INDEX idx_person_saved_combined_creator ON person_saved_combined (creator_id); ================================================ FILE: migrations/2025-08-01-000025_add_modlog_combined_table/down.sql ================================================ DROP TABLE modlog_combined; -- Rename the columns back to when_ ALTER TABLE admin_allow_instance RENAME COLUMN published TO when_; ALTER TABLE admin_block_instance RENAME COLUMN published TO when_; ALTER TABLE admin_purge_comment RENAME COLUMN published TO when_; ALTER TABLE admin_purge_community RENAME COLUMN published TO when_; ALTER TABLE admin_purge_person RENAME COLUMN published TO when_; ALTER TABLE admin_purge_post RENAME COLUMN published TO when_; ALTER TABLE mod_add RENAME COLUMN published TO when_; ALTER TABLE mod_add_community RENAME COLUMN published TO when_; ALTER TABLE mod_ban RENAME COLUMN published TO when_; ALTER TABLE mod_ban_from_community RENAME COLUMN published TO when_; ALTER TABLE mod_feature_post RENAME COLUMN published TO when_; ALTER TABLE mod_hide_community RENAME COLUMN published TO when_; ALTER TABLE mod_lock_post RENAME COLUMN published TO when_; ALTER TABLE mod_remove_comment RENAME COLUMN published TO when_; ALTER TABLE mod_remove_community RENAME COLUMN published TO when_; ALTER TABLE mod_remove_post RENAME COLUMN published TO when_; ALTER TABLE mod_transfer_community RENAME COLUMN published TO when_; ================================================ FILE: migrations/2025-08-01-000025_add_modlog_combined_table/up.sql ================================================ -- First, rename all the when_ columns on the modlog to published ALTER TABLE admin_allow_instance RENAME COLUMN when_ TO published; ALTER TABLE admin_block_instance RENAME COLUMN when_ TO published; ALTER TABLE admin_purge_comment RENAME COLUMN when_ TO published; ALTER TABLE admin_purge_community RENAME COLUMN when_ TO published; ALTER TABLE admin_purge_person RENAME COLUMN when_ TO published; ALTER TABLE admin_purge_post RENAME COLUMN when_ TO published; ALTER TABLE mod_add RENAME COLUMN when_ TO published; ALTER TABLE mod_add_community RENAME COLUMN when_ TO published; ALTER TABLE mod_ban RENAME COLUMN when_ TO published; ALTER TABLE mod_ban_from_community RENAME COLUMN when_ TO published; ALTER TABLE mod_feature_post RENAME COLUMN when_ TO published; ALTER TABLE mod_hide_community RENAME COLUMN when_ TO published; ALTER TABLE mod_lock_post RENAME COLUMN when_ TO published; ALTER TABLE mod_remove_comment RENAME COLUMN when_ TO published; ALTER TABLE mod_remove_community RENAME COLUMN when_ TO published; ALTER TABLE mod_remove_post RENAME COLUMN when_ TO published; ALTER TABLE mod_transfer_community RENAME COLUMN when_ TO published; -- Creates combined tables for -- modlog: (17 tables) -- admin_allow_instance -- admin_block_instance -- admin_purge_comment -- admin_purge_community -- admin_purge_person -- admin_purge_post -- mod_add -- mod_add_community -- mod_ban -- mod_ban_from_community -- mod_feature_post -- mod_hide_community -- mod_lock_post -- mod_remove_comment -- mod_remove_community -- mod_remove_post -- mod_transfer_community CREATE TABLE modlog_combined ( id serial PRIMARY KEY, published timestamptz NOT NULL, admin_allow_instance_id int UNIQUE REFERENCES admin_allow_instance ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, admin_block_instance_id int UNIQUE REFERENCES admin_block_instance ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, admin_purge_comment_id int UNIQUE REFERENCES admin_purge_comment ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, admin_purge_community_id int UNIQUE REFERENCES admin_purge_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, admin_purge_person_id int UNIQUE REFERENCES admin_purge_person ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, admin_purge_post_id int UNIQUE REFERENCES admin_purge_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_add_id int UNIQUE REFERENCES mod_add ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_add_community_id int UNIQUE REFERENCES mod_add_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_ban_id int UNIQUE REFERENCES mod_ban ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_ban_from_community_id int UNIQUE REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_feature_post_id int UNIQUE REFERENCES mod_feature_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_hide_community_id int UNIQUE REFERENCES mod_hide_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_lock_post_id int UNIQUE REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_remove_comment_id int UNIQUE REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_remove_community_id int UNIQUE REFERENCES mod_remove_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_remove_post_id int UNIQUE REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, mod_transfer_community_id int UNIQUE REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE ); -- Updating the history -- Not doing a union all here, because there's way too many null columns INSERT INTO modlog_combined (published, admin_allow_instance_id) SELECT published, id FROM admin_allow_instance; INSERT INTO modlog_combined (published, admin_block_instance_id) SELECT published, id FROM admin_block_instance; INSERT INTO modlog_combined (published, admin_purge_comment_id) SELECT published, id FROM admin_purge_comment; INSERT INTO modlog_combined (published, admin_purge_community_id) SELECT published, id FROM admin_purge_community; INSERT INTO modlog_combined (published, admin_purge_person_id) SELECT published, id FROM admin_purge_person; INSERT INTO modlog_combined (published, admin_purge_post_id) SELECT published, id FROM admin_purge_post; INSERT INTO modlog_combined (published, mod_add_id) SELECT published, id FROM mod_add; INSERT INTO modlog_combined (published, mod_add_community_id) SELECT published, id FROM mod_add_community; INSERT INTO modlog_combined (published, mod_ban_id) SELECT published, id FROM mod_ban; INSERT INTO modlog_combined (published, mod_ban_from_community_id) SELECT published, id FROM mod_ban_from_community; INSERT INTO modlog_combined (published, mod_feature_post_id) SELECT published, id FROM mod_feature_post; INSERT INTO modlog_combined (published, mod_hide_community_id) SELECT published, id FROM mod_hide_community; INSERT INTO modlog_combined (published, mod_lock_post_id) SELECT published, id FROM mod_lock_post; INSERT INTO modlog_combined (published, mod_remove_comment_id) SELECT published, id FROM mod_remove_comment; INSERT INTO modlog_combined (published, mod_remove_community_id) SELECT published, id FROM mod_remove_community; INSERT INTO modlog_combined (published, mod_remove_post_id) SELECT published, id FROM mod_remove_post; INSERT INTO modlog_combined (published, mod_transfer_community_id) SELECT published, id FROM mod_transfer_community; CREATE INDEX idx_modlog_combined_published ON modlog_combined (published DESC, id DESC); -- Make sure only one of the columns is not null ALTER TABLE modlog_combined ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, mod_add_id, mod_add_community_id, mod_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_hide_community_id, mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1), ALTER CONSTRAINT modlog_combined_admin_allow_instance_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_admin_block_instance_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_admin_purge_comment_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_admin_purge_post_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_admin_purge_community_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_admin_purge_person_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_add_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_add_community_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_ban_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_ban_from_community_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_feature_post_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_hide_community_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_lock_post_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_remove_comment_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_remove_community_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_remove_post_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT modlog_combined_mod_transfer_community_id_fkey NOT DEFERRABLE; ================================================ FILE: migrations/2025-08-01-000026_add_inbox_combined_table/down.sql ================================================ -- Rename the person_mention table to person_comment_mention ALTER TABLE person_comment_mention RENAME TO person_mention; -- Drop the new tables DROP TABLE person_post_mention, inbox_combined; ================================================ FILE: migrations/2025-08-01-000026_add_inbox_combined_table/up.sql ================================================ -- Creates combined tables for -- Inbox: (replies, comment mentions, post mentions, and private_messages) -- Also add post mentions, since these didn't exist before. -- Rename the person_mention table to person_comment_mention ALTER TABLE person_mention RENAME TO person_comment_mention; -- Create the new post_mention table CREATE TABLE person_post_mention ( id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, recipient_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read boolean DEFAULT FALSE NOT NULL, published timestamptz NOT NULL DEFAULT now(), UNIQUE (recipient_id, post_id) ); -- Updating the history CREATE TABLE inbox_combined AS SELECT published, id AS comment_reply_id, NULL::int AS person_comment_mention_id, NULL::int AS person_post_mention_id, NULL::int AS private_message_id FROM comment_reply UNION ALL SELECT published, NULL::int, id, NULL::int, NULL::int FROM person_comment_mention UNION ALL SELECT published, NULL::int, NULL::int, id, NULL::int FROM person_post_mention UNION ALL SELECT published, NULL::int, NULL::int, NULL::int, id FROM private_message; ALTER TABLE inbox_combined ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ALTER COLUMN published SET NOT NULL, ADD CONSTRAINT inbox_combined_comment_reply_id_fkey FOREIGN KEY (comment_reply_id) REFERENCES comment_reply ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT inbox_combined_person_comment_mention_id_fkey FOREIGN KEY (person_comment_mention_id) REFERENCES person_comment_mention ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT inbox_combined_person_post_mention_id_fkey FOREIGN KEY (person_post_mention_id) REFERENCES person_post_mention ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT inbox_combined_private_message_id_fkey FOREIGN KEY (private_message_id) REFERENCES private_message ON UPDATE CASCADE ON DELETE CASCADE, ADD UNIQUE (comment_reply_id), ADD UNIQUE (person_comment_mention_id), ADD UNIQUE (person_post_mention_id), ADD UNIQUE (private_message_id), ADD CONSTRAINT inbox_combined_check CHECK (num_nonnulls (comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) = 1); CREATE INDEX idx_inbox_combined_published ON inbox_combined (published DESC, id DESC); CREATE INDEX idx_inbox_combined_published_asc ON inbox_combined (reverse_timestamp_sort (published) DESC, id DESC); ================================================ FILE: migrations/2025-08-01-000027_add_search_combined_table/down.sql ================================================ ALTER TABLE person_aggregates DROP COLUMN published; DROP TABLE search_combined; ================================================ FILE: migrations/2025-08-01-000027_add_search_combined_table/up.sql ================================================ -- Creates combined tables for -- Search: (post, comment, community, person) -- Add published to person_aggregates (it was missing for some reason) ALTER TABLE person_aggregates ADD COLUMN published timestamptz NOT NULL DEFAULT now(); UPDATE person_aggregates pa SET published = p.published FROM person p WHERE pa.person_id = p.id; -- score is used for the top sort -- For persons: its post score -- For comments: score, -- For posts: score, -- For community: users active monthly -- Updating the history CREATE TABLE search_combined AS SELECT published, score::int, post_id, NULL::int AS comment_id, NULL::int AS community_id, NULL::int AS person_id FROM post_aggregates UNION ALL SELECT published, score::int, NULL::int, comment_id, NULL::int, NULL::int FROM comment_aggregates UNION ALL SELECT published, users_active_month::int, NULL::int, NULL::int, community_id, NULL::int FROM community_aggregates UNION ALL SELECT published, post_score::int, NULL::int, NULL::int, NULL::int, person_id FROM person_aggregates; -- Add the constraints ALTER TABLE search_combined ADD COLUMN id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ALTER COLUMN published SET NOT NULL, ALTER COLUMN score SET NOT NULL, ALTER COLUMN score SET DEFAULT 0, ADD CONSTRAINT search_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT search_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT search_combined_community_id_fkey FOREIGN KEY (community_id) REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT search_combined_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD UNIQUE (post_id), ADD UNIQUE (comment_id), ADD UNIQUE (community_id), ADD UNIQUE (person_id), ADD CONSTRAINT search_combined_check CHECK (num_nonnulls (post_id, comment_id, community_id, person_id) = 1); CREATE INDEX idx_search_combined_published ON search_combined (published DESC, id DESC); CREATE INDEX idx_search_combined_published_asc ON search_combined (reverse_timestamp_sort (published) DESC, id DESC); CREATE INDEX idx_search_combined_score ON search_combined (score DESC, id DESC); ================================================ FILE: migrations/2025-08-01-000028_add_index_on_person_id_read_for_read_only_post_actions/down.sql ================================================ DROP INDEX idx_post_actions_on_read_read_not_null; ================================================ FILE: migrations/2025-08-01-000028_add_index_on_person_id_read_for_read_only_post_actions/up.sql ================================================ CREATE INDEX idx_post_actions_on_read_read_not_null ON post_actions (person_id, read, post_id) WHERE read IS NOT NULL; ================================================ FILE: migrations/2025-08-01-000029_community-post-tags/down.sql ================================================ DROP TABLE post_tag; DROP TABLE tag; ================================================ FILE: migrations/2025-08-01-000029_community-post-tags/up.sql ================================================ -- 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. CREATE TABLE tag ( id serial PRIMARY KEY, ap_id text NOT NULL UNIQUE, name varchar(255) NOT NULL, display_name varchar(255), description text, community_id int NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz NOT NULL DEFAULT now(), updated timestamptz, deleted boolean NOT NULL DEFAULT FALSE ); -- an association between a post and a tag. created/updated by the post author or mods of a community CREATE TABLE post_tag ( post_id int NOT NULL REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE, tag_id int NOT NULL REFERENCES tag (id) ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (post_id, tag_id) ); ================================================ FILE: migrations/2025-08-01-000030_optimize_get_random_community/down.sql ================================================ ALTER TABLE community DROP COLUMN random_number; DROP FUNCTION random_smallint; ================================================ FILE: migrations/2025-08-01-000030_optimize_get_random_community/up.sql ================================================ -- * inclusive bounds of `smallint` range from https://www.postgresql.org/docs/17/datatype-numeric.html -- * built-in `random` function has `VOLATILE` and `PARALLEL RESTRICTED` according to: -- * https://www.postgresql.org/docs/current/parallel-safety.html#PARALLEL-LABELING -- * https://www.postgresql.org/docs/17/xfunc-volatility.html CREATE FUNCTION random_smallint () RETURNS smallint LANGUAGE sql VOLATILE PARALLEL RESTRICTED RETURN -- https://stackoverflow.com/questions/1400505/generate-a-random-number-in-the-range-1-10/1400752#1400752 -- (65536 = exclusive upper bound - inclusive lower bound) trunc ((random() * (65536)) - 32768 ); ALTER TABLE community ADD COLUMN random_number smallint NOT NULL DEFAULT random_smallint (); CREATE INDEX idx_community_random_number ON community (random_number) INCLUDE (local, nsfw) WHERE NOT (deleted OR removed OR visibility = 'Private'); ================================================ FILE: migrations/2025-08-01-000031_update-replaceable-schema/down.sql ================================================ SELECT 1; ================================================ FILE: migrations/2025-08-01-000031_update-replaceable-schema/up.sql ================================================ SELECT 1; ================================================ FILE: migrations/2025-08-01-000032_community_report/down.sql ================================================ DELETE FROM report_combined WHERE community_report_id IS NOT NULL; ALTER TABLE report_combined DROP CONSTRAINT report_combined_check, ADD CHECK (num_nonnulls (post_report_id, comment_report_id, private_message_report_id) = 1), DROP COLUMN community_report_id; DROP TABLE community_report CASCADE; ALTER TABLE community_aggregates DROP COLUMN report_count, DROP COLUMN unresolved_report_count; ================================================ FILE: migrations/2025-08-01-000032_community_report/up.sql ================================================ CREATE TABLE community_report ( id serial PRIMARY KEY, creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, original_community_name text NOT NULL, original_community_title text NOT NULL, original_community_description text, original_community_sidebar text, original_community_icon text, original_community_banner text, reason text NOT NULL, resolved bool NOT NULL DEFAULT FALSE, resolver_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published timestamptz NOT NULL DEFAULT now(), updated timestamptz NULL, UNIQUE (community_id, creator_id) ); CREATE INDEX idx_community_report_published ON community_report (published DESC); ALTER TABLE report_combined ADD COLUMN community_report_id int UNIQUE REFERENCES community_report ON UPDATE CASCADE ON DELETE CASCADE, DROP CONSTRAINT report_combined_check, ADD CHECK (num_nonnulls (post_report_id, comment_report_id, private_message_report_id, community_report_id) = 1); ALTER TABLE community_aggregates ADD COLUMN report_count smallint NOT NULL DEFAULT 0, ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; ================================================ FILE: migrations/2025-08-01-000033_add_post_keyword_block_table/down.sql ================================================ DROP TABLE local_user_keyword_block; ================================================ FILE: migrations/2025-08-01-000033_add_post_keyword_block_table/up.sql ================================================ CREATE TABLE local_user_keyword_block ( local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, keyword varchar(50) NOT NULL, PRIMARY KEY (local_user_id, keyword) ); ================================================ FILE: migrations/2025-08-01-000034_no-image-token/down.sql ================================================ ALTER TABLE local_image ADD COLUMN pictrs_delete_token text DEFAULT '', ADD CONSTRAINT image_upload_pictrs_delete_token_not_null NOT NULL pictrs_delete_token; ALTER TABLE local_image ALTER COLUMN pictrs_delete_token DROP DEFAULT; ALTER TABLE local_image ADD COLUMN published_new timestamp with time zone DEFAULT now(); UPDATE local_image SET published_new = published; ALTER TABLE local_image DROP COLUMN published; ALTER TABLE local_image RENAME published_new TO published; ALTER TABLE local_image ADD CONSTRAINT image_upload_published_not_null NOT NULL published; ================================================ FILE: migrations/2025-08-01-000034_no-image-token/up.sql ================================================ ALTER TABLE local_image DROP COLUMN pictrs_delete_token; ================================================ FILE: migrations/2025-08-01-000035_media_filter/down.sql ================================================ ALTER TABLE local_user DROP COLUMN hide_media; DROP INDEX idx_post_url_content_type; ================================================ FILE: migrations/2025-08-01-000035_media_filter/up.sql ================================================ ALTER TABLE local_user ADD COLUMN hide_media boolean DEFAULT FALSE NOT NULL; CREATE INDEX idx_post_url_content_type ON post USING gin (url_content_type gin_trgm_ops); ================================================ FILE: migrations/2025-08-01-000036_interactions_per_month_schema/down.sql ================================================ ALTER TABLE community_aggregates DROP COLUMN interactions_month; ================================================ FILE: migrations/2025-08-01-000036_interactions_per_month_schema/up.sql ================================================ -- Add the interactions_month column ALTER TABLE community_aggregates ADD COLUMN interactions_month bigint NOT NULL DEFAULT 0; ================================================ FILE: migrations/2025-08-01-000037_report_to_admins/down.sql ================================================ ALTER TABLE post_report DROP COLUMN violates_instance_rules; ALTER TABLE comment_report DROP COLUMN violates_instance_rules; ================================================ FILE: migrations/2025-08-01-000037_report_to_admins/up.sql ================================================ ALTER TABLE post_report ADD COLUMN violates_instance_rules bool NOT NULL DEFAULT FALSE; ALTER TABLE comment_report ADD COLUMN violates_instance_rules bool NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-08-01-000038_ap_id/down.sql ================================================ ALTER TABLE person RENAME ap_id TO actor_id; ALTER TABLE community RENAME ap_id TO actor_id; ALTER TABLE site RENAME ap_id TO actor_id; ================================================ FILE: migrations/2025-08-01-000038_ap_id/up.sql ================================================ ALTER TABLE person RENAME actor_id TO ap_id; ALTER TABLE community RENAME actor_id TO ap_id; ALTER TABLE site RENAME actor_id TO ap_id; ================================================ FILE: migrations/2025-08-01-000039_remove_post_sort_type_enums/down.sql ================================================ -- This removes all the extra post_sort_type_enums, -- and adds a default_post_time_range_seconds field. -- Drop the defaults because of a postgres bug ALTER TABLE local_user ALTER default_post_sort_type DROP DEFAULT; ALTER TABLE local_site ALTER default_post_sort_type DROP DEFAULT; -- Change all the top variants to top in the two tables that use the enum UPDATE local_user SET default_post_sort_type = 'Active' WHERE default_post_sort_type = 'Top'; UPDATE local_site SET default_post_sort_type = 'Active' WHERE default_post_sort_type = 'Top'; -- rename the old enum to a tmp name ALTER TYPE post_sort_type_enum RENAME TO post_sort_type_enum__; -- create the new enum CREATE TYPE post_sort_type_enum AS ENUM ( 'Active', 'Hot', 'New', 'Old', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'MostComments', 'NewComments', 'TopHour', 'TopSixHour', 'TopTwelveHour', 'TopThreeMonths', 'TopSixMonths', 'TopNineMonths', 'Controversial', 'Scaled' ); -- alter all you enum columns ALTER TABLE local_user ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum USING default_post_sort_type::text::post_sort_type_enum; ALTER TABLE local_site ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum USING default_post_sort_type::text::post_sort_type_enum; -- drop the old enum DROP TYPE post_sort_type_enum__; -- Add back in the default ALTER TABLE local_user ALTER default_post_sort_type SET DEFAULT 'Active'; ALTER TABLE local_site ALTER default_post_sort_type SET DEFAULT 'Active'; -- Drop the new columns ALTER TABLE local_user DROP COLUMN default_post_time_range_seconds; ALTER TABLE local_site DROP COLUMN default_post_time_range_seconds; ================================================ FILE: migrations/2025-08-01-000039_remove_post_sort_type_enums/up.sql ================================================ -- This removes all the extra post_sort_type_enums, -- and adds a default_post_time_range_seconds field. -- Change all the top variants to top in the two tables that use the enum -- Because of a postgres bug, you can't assign this to a new enum value, -- unless you run an unsafe commit first. So just use active. -- https://dba.stackexchange.com/questions/280371/postgres-unsafe-use-of-new-value-of-enum-type -- -- Disable the triggers temporarily ALTER TABLE local_user DISABLE TRIGGER ALL; ALTER TABLE local_site DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_user'); UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_site'); UPDATE local_user SET default_post_sort_type = 'Active' WHERE default_post_sort_type IN ('TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'TopHour', 'TopSixHour', 'TopTwelveHour', 'TopThreeMonths', 'TopSixMonths', 'TopNineMonths'); UPDATE local_site SET default_post_sort_type = 'Active' WHERE default_post_sort_type IN ('TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll', 'TopHour', 'TopSixHour', 'TopTwelveHour', 'TopThreeMonths', 'TopSixMonths', 'TopNineMonths'); -- Drop the defaults because of a postgres bug ALTER TABLE local_user ALTER default_post_sort_type DROP DEFAULT; ALTER TABLE local_site ALTER default_post_sort_type DROP DEFAULT; -- rename the old enum to a tmp name ALTER TYPE post_sort_type_enum RENAME TO post_sort_type_enum__; -- create the new enum CREATE TYPE post_sort_type_enum AS ENUM ( 'Active', 'Hot', 'New', 'Old', 'Top', 'MostComments', 'NewComments', 'Controversial', 'Scaled' ); -- alter all you enum columns ALTER TABLE local_user ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum USING default_post_sort_type::text::post_sort_type_enum; ALTER TABLE local_site ALTER COLUMN default_post_sort_type TYPE post_sort_type_enum USING default_post_sort_type::text::post_sort_type_enum; -- drop the old enum DROP TYPE post_sort_type_enum__; -- Add back in the default ALTER TABLE local_user ALTER default_post_sort_type SET DEFAULT 'Active'; ALTER TABLE local_site ALTER default_post_sort_type SET DEFAULT 'Active'; -- Add the new column to both tables (null means no limit) ALTER TABLE local_user ADD COLUMN default_post_time_range_seconds integer; ALTER TABLE local_site ADD COLUMN default_post_time_range_seconds integer; -- Re-enable the triggers ALTER TABLE local_user ENABLE TRIGGER ALL; ALTER TABLE local_site ENABLE TRIGGER ALL; -- re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_user'); UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_site'); -- reindex REINDEX TABLE local_user; REINDEX TABLE local_site; ================================================ FILE: migrations/2025-08-01-000040_block_nsfw/down.sql ================================================ ALTER TABLE local_site DROP COLUMN disallow_nsfw_content; ================================================ FILE: migrations/2025-08-01-000040_block_nsfw/up.sql ================================================ ALTER TABLE local_site ADD COLUMN disallow_nsfw_content boolean DEFAULT FALSE NOT NULL; ================================================ FILE: migrations/2025-08-01-000041_remove-aggregate-tables/down.sql ================================================ -- move comment_aggregates back into separate table CREATE TABLE IF NOT EXISTS comment_aggregates ( comment_id int PRIMARY KEY NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, score bigint NOT NULL DEFAULT 0, upvotes bigint NOT NULL DEFAULT 0, downvotes bigint NOT NULL DEFAULT 0, published timestamp with time zone NOT NULL DEFAULT now(), child_count integer NOT NULL DEFAULT 0, hot_rank double precision NOT NULL DEFAULT 0.0001, controversy_rank double precision NOT NULL DEFAULT 0, report_count smallint NOT NULL DEFAULT 0, unresolved_report_count smallint NOT NULL DEFAULT 0 ); INSERT INTO comment_aggregates SELECT id AS comment_id, score, upvotes, downvotes, published, child_count, hot_rank, controversy_rank, report_count, unresolved_report_count FROM COMMENT ON CONFLICT (comment_id) DO UPDATE SET score = excluded.score, upvotes = excluded.upvotes, downvotes = excluded.downvotes, published = excluded.published, child_count = excluded.child_count, hot_rank = excluded.hot_rank, controversy_rank = excluded.controversy_rank, report_count = excluded.report_count, unresolved_report_count = excluded.unresolved_report_count; ALTER TABLE comment DROP COLUMN score, DROP COLUMN upvotes, DROP COLUMN downvotes, DROP COLUMN child_count, DROP COLUMN hot_rank, DROP COLUMN controversy_rank, DROP COLUMN report_count, DROP COLUMN unresolved_report_count; ALTER TABLE comment_aggregates ALTER CONSTRAINT comment_aggregates_comment_id_fkey DEFERRABLE INITIALLY DEFERRED; CREATE INDEX IF NOT EXISTS idx_comment_aggregates_controversy ON comment_aggregates USING btree (controversy_rank DESC); CREATE INDEX IF NOT EXISTS idx_comment_aggregates_hot ON comment_aggregates USING btree (hot_rank DESC, score DESC); CREATE INDEX IF NOT EXISTS idx_comment_aggregates_nonzero_hotrank ON comment_aggregates USING btree (published) WHERE (hot_rank <> (0)::double precision); CREATE INDEX IF NOT EXISTS idx_comment_aggregates_published ON comment_aggregates USING btree (published DESC); CREATE INDEX IF NOT EXISTS idx_comment_aggregates_score ON comment_aggregates USING btree (score DESC); -- move comment_aggregates back into separate table CREATE TABLE IF NOT EXISTS post_aggregates ( post_id int PRIMARY KEY NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, comments bigint NOT NULL DEFAULT 0, score bigint NOT NULL DEFAULT 0, upvotes bigint NOT NULL DEFAULT 0, downvotes bigint NOT NULL DEFAULT 0, published timestamp with time zone NOT NULL DEFAULT now(), newest_comment_time_necro timestamp with time zone DEFAULT now(), newest_comment_time timestamp with time zone DEFAULT now(), featured_community boolean NOT NULL DEFAULT FALSE, featured_local boolean NOT NULL DEFAULT FALSE, hot_rank double precision NOT NULL DEFAULT 0.0001, hot_rank_active double precision NOT NULL DEFAULT 0.0001, community_id integer NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE, creator_id integer NOT NULL REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE, controversy_rank double precision NOT NULL DEFAULT 0, instance_id integer NOT NULL REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE, scaled_rank double precision NOT NULL DEFAULT 0.0001, report_count smallint NOT NULL DEFAULT 0, unresolved_report_count smallint NOT NULL DEFAULT 0, CONSTRAINT post_aggregates_newest_comment_time_not_null1 NOT NULL newest_comment_time, CONSTRAINT post_aggregates_newest_comment_time_not_null NOT NULL newest_comment_time_necro ); INSERT INTO post_aggregates SELECT id AS post_id, comments, score, upvotes, downvotes, published, coalesce(newest_comment_time_necro, published), coalesce(newest_comment_time, published), featured_community, featured_local, hot_rank, hot_rank_active, community_id, creator_id, controversy_rank, ( SELECT community.instance_id FROM community WHERE community.id = post.community_id) AS instance_id, scaled_rank, report_count, unresolved_report_count FROM post ON CONFLICT (post_id) DO UPDATE SET comments = excluded.comments, score = excluded.score, upvotes = excluded.upvotes, downvotes = excluded.downvotes, published = excluded.published, newest_comment_time_necro = excluded.newest_comment_time_necro, newest_comment_time = excluded.newest_comment_time, featured_community = excluded.featured_community, featured_local = excluded.featured_local, hot_rank = excluded.hot_rank, hot_rank_active = excluded.hot_rank_active, community_id = excluded.community_id, creator_id = excluded.creator_id, controversy_rank = excluded.controversy_rank, instance_id = excluded.instance_id, scaled_rank = excluded.scaled_rank, report_count = excluded.report_count, unresolved_report_count = excluded.unresolved_report_count; ALTER TABLE post DROP COLUMN comments, DROP COLUMN score, DROP COLUMN upvotes, DROP COLUMN downvotes, DROP COLUMN newest_comment_time_necro, DROP COLUMN newest_comment_time, DROP COLUMN hot_rank, DROP COLUMN hot_rank_active, DROP COLUMN controversy_rank, DROP COLUMN scaled_rank, DROP COLUMN report_count, DROP COLUMN unresolved_report_count; ALTER TABLE post_aggregates ALTER CONSTRAINT post_aggregates_community_id_fkey DEFERRABLE INITIALLY DEFERRED, ALTER CONSTRAINT post_aggregates_creator_id_fkey DEFERRABLE INITIALLY DEFERRED, ALTER CONSTRAINT post_aggregates_instance_id_fkey DEFERRABLE INITIALLY DEFERRED, ALTER CONSTRAINT post_aggregates_post_id_fkey DEFERRABLE INITIALLY DEFERRED; CREATE INDEX IF NOT EXISTS idx_post_aggregates_creator ON post_aggregates USING btree (creator_id); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community ON post_aggregates USING btree (community_id); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_active ON post_aggregates USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_controversy ON post_aggregates USING btree (community_id, featured_local DESC, controversy_rank DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_hot ON post_aggregates USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_most_comments ON post_aggregates USING btree (community_id, featured_local DESC, comments DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_newest_comment_time ON post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_newest_comment_time_necro ON post_aggregates USING btree (community_id, featured_local DESC, newest_comment_time_necro DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_published ON post_aggregates USING btree (community_id, featured_local DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_published_asc ON post_aggregates USING btree (community_id, featured_local DESC, reverse_timestamp_sort (published) DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_scaled ON post_aggregates USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_community_score ON post_aggregates USING btree (community_id, featured_local DESC, score DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_active ON post_aggregates USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_controversy ON post_aggregates USING btree (community_id, featured_community DESC, controversy_rank DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_hot ON post_aggregates USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_most_comments ON post_aggregates USING btree (community_id, featured_community DESC, comments DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_newest_comment_time ON post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_newest_comment_time_necr ON post_aggregates USING btree (community_id, featured_community DESC, newest_comment_time_necro DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_published ON post_aggregates USING btree (community_id, featured_community DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_published_asc ON post_aggregates USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_scaled ON post_aggregates USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_community_score ON post_aggregates USING btree (community_id, featured_community DESC, score DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_active ON post_aggregates USING btree (featured_local DESC, hot_rank_active DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_controversy ON post_aggregates USING btree (featured_local DESC, controversy_rank DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_hot ON post_aggregates USING btree (featured_local DESC, hot_rank DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_most_comments ON post_aggregates USING btree (featured_local DESC, comments DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_newest_comment_time ON post_aggregates USING btree (featured_local DESC, newest_comment_time DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_newest_comment_time_necro ON post_aggregates USING btree (featured_local DESC, newest_comment_time_necro DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_published ON post_aggregates USING btree (featured_local DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_published_asc ON post_aggregates USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_scaled ON post_aggregates USING btree (featured_local DESC, scaled_rank DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_featured_local_score ON post_aggregates USING btree (featured_local DESC, score DESC, published DESC, post_id DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_nonzero_hotrank ON post_aggregates USING btree (published DESC) WHERE ((hot_rank <> (0)::double precision) OR (hot_rank_active <> (0)::double precision)); CREATE INDEX IF NOT EXISTS idx_post_aggregates_published ON post_aggregates USING btree (published DESC); CREATE INDEX IF NOT EXISTS idx_post_aggregates_published_asc ON post_aggregates USING btree (reverse_timestamp_sort (published) DESC); DROP INDEX idx_post_featured_community_published_asc; DROP INDEX idx_post_featured_local_published; DROP INDEX idx_post_featured_local_published_asc; DROP INDEX idx_post_published_asc; DROP INDEX idx_search_combined_score; -- move community_aggregates back into separate table CREATE TABLE community_aggregates ( community_id int PRIMARY KEY NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, subscribers bigint NOT NULL DEFAULT 0, posts bigint NOT NULL DEFAULT 0, comments bigint NOT NULL DEFAULT 0, published timestamp with time zone DEFAULT now() NOT NULL, users_active_day bigint NOT NULL DEFAULT 0, users_active_week bigint NOT NULL DEFAULT 0, users_active_month bigint NOT NULL DEFAULT 0, users_active_half_year bigint NOT NULL DEFAULT 0, hot_rank double precision NOT NULL DEFAULT 0.0001, subscribers_local bigint NOT NULL DEFAULT 0, report_count smallint NOT NULL DEFAULT 0, unresolved_report_count smallint NOT NULL DEFAULT 0, interactions_month bigint NOT NULL DEFAULT 0 ); INSERT INTO community_aggregates SELECT id AS comment_id, subscribers, posts, comments, published, users_active_day, users_active_week, users_active_month, users_active_half_year, hot_rank, subscribers_local, report_count, unresolved_report_count, interactions_month FROM community; ALTER TABLE community DROP COLUMN subscribers, DROP COLUMN posts, DROP COLUMN comments, DROP COLUMN users_active_day, DROP COLUMN users_active_week, DROP COLUMN users_active_month, DROP COLUMN users_active_half_year, DROP COLUMN hot_rank, DROP COLUMN subscribers_local, DROP COLUMN report_count, DROP COLUMN unresolved_report_count, DROP COLUMN interactions_month; ALTER TABLE community ALTER CONSTRAINT community_instance_id_fkey NOT DEFERRABLE; CREATE INDEX idx_community_aggregates_hot ON public.community_aggregates USING btree (hot_rank DESC); CREATE INDEX idx_community_aggregates_nonzero_hotrank ON public.community_aggregates USING btree (published) WHERE (hot_rank <> (0)::double precision); CREATE INDEX idx_community_aggregates_published ON public.community_aggregates USING btree (published DESC); CREATE INDEX idx_community_aggregates_subscribers ON public.community_aggregates USING btree (subscribers DESC); CREATE INDEX idx_community_aggregates_users_active_month ON public.community_aggregates USING btree (users_active_month DESC); -- move person_aggregates back into separate table CREATE TABLE person_aggregates ( person_id int PRIMARY KEY REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, post_count bigint DEFAULT 0, post_score bigint DEFAULT 0, comment_count bigint DEFAULT 0, comment_score bigint DEFAULT 0, published timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT user_aggregates_comment_count_not_null NOT NULL comment_count, CONSTRAINT user_aggregates_comment_score_not_null NOT NULL comment_score, CONSTRAINT user_aggregates_user_id_not_null NOT NULL person_id, CONSTRAINT user_aggregates_post_count_not_null NOT NULL post_count, CONSTRAINT user_aggregates_post_score_not_null NOT NULL post_score ); INSERT INTO person_aggregates SELECT id AS person_id, post_count, post_score, comment_count, comment_score, published FROM person; ALTER TABLE person DROP COLUMN post_count, DROP COLUMN post_score, DROP COLUMN comment_count, DROP COLUMN comment_score; ALTER TABLE person_aggregates ALTER CONSTRAINT person_aggregates_person_id_fkey DEFERRABLE INITIALLY DEFERRED; CREATE INDEX idx_person_aggregates_comment_score ON public.person_aggregates USING btree (comment_score DESC); CREATE INDEX idx_person_aggregates_person ON public.person_aggregates USING btree (person_id); -- move site_aggregates back into separate table CREATE TABLE site_aggregates ( site_id int PRIMARY KEY NOT NULL REFERENCES site ON UPDATE CASCADE ON DELETE CASCADE, users bigint NOT NULL DEFAULT 1, posts bigint NOT NULL DEFAULT 0, comments bigint NOT NULL DEFAULT 0, communities bigint NOT NULL DEFAULT 0, users_active_day bigint NOT NULL DEFAULT 0, users_active_week bigint NOT NULL DEFAULT 0, users_active_month bigint NOT NULL DEFAULT 0, users_active_half_year bigint NOT NULL DEFAULT 0 ); INSERT INTO site_aggregates SELECT id AS site_id, users, posts, comments, communities, users_active_day, users_active_week, users_active_month, users_active_half_year FROM local_site; ALTER TABLE local_site DROP COLUMN users, DROP COLUMN posts, DROP COLUMN comments, DROP COLUMN communities, DROP COLUMN users_active_day, DROP COLUMN users_active_week, DROP COLUMN users_active_month, DROP COLUMN users_active_half_year; -- move local_user_vote_display_mode back into separate table CREATE TABLE local_user_vote_display_mode ( local_user_id int PRIMARY KEY NOT NULL REFERENCES local_user ON UPDATE CASCADE ON DELETE CASCADE, score boolean NOT NULL DEFAULT FALSE, upvotes boolean NOT NULL DEFAULT TRUE, downvotes boolean NOT NULL DEFAULT TRUE, upvote_percentage boolean NOT NULL DEFAULT FALSE ); INSERT INTO local_user_vote_display_mode SELECT id AS local_user_id, show_score AS score, show_upvotes AS upvotes, show_downvotes AS downvotes, show_upvote_percentage AS upvote_percentage FROM local_user; ALTER TABLE local_user DROP COLUMN show_score, DROP COLUMN show_upvotes, DROP COLUMN show_downvotes, DROP COLUMN show_upvote_percentage; CREATE INDEX idx_search_combined_score ON public.search_combined USING btree (score DESC, id DESC); ALTER TABLE site_aggregates ALTER CONSTRAINT site_aggregates_site_id_fkey DEFERRABLE INITIALLY DEFERRED; CREATE UNIQUE INDEX idx_site_aggregates_1_row_only ON public.site_aggregates USING btree ((TRUE)); ALTER TABLE community_aggregates ALTER CONSTRAINT community_aggregates_community_id_fkey DEFERRABLE INITIALLY DEFERRED; ================================================ FILE: migrations/2025-08-01-000041_remove-aggregate-tables/up.sql ================================================ -- Merge comment_aggregates into comment table ALTER TABLE comment ADD COLUMN score int NOT NULL DEFAULT 1, -- Default value only for previous rows, to match the similar thing done with `upvotes` ADD COLUMN upvotes int NOT NULL DEFAULT 1, -- Default value only for previous rows, so the update below can filter out more rows by using `upvotes != 1` instead of `upvotes != 0` ADD COLUMN downvotes int NOT NULL DEFAULT 0, ADD COLUMN child_count int NOT NULL DEFAULT 0, ADD COLUMN hot_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank != 0` instead of `hot_rank != 0.0001` ADD COLUMN controversy_rank real NOT NULL DEFAULT 0, ADD COLUMN report_count smallint NOT NULL DEFAULT 0, ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; -- Default values only for future rows ALTER TABLE comment ALTER COLUMN score SET DEFAULT 0, ALTER COLUMN upvotes SET DEFAULT 0, ALTER COLUMN hot_rank SET DEFAULT 0.0001; -- Disable the triggers temporarily ALTER TABLE comment DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'comment'); UPDATE comment SET score = ca.score, upvotes = ca.upvotes, downvotes = ca.downvotes, child_count = ca.child_count, hot_rank = ca.hot_rank, controversy_rank = ca.controversy_rank, report_count = ca.report_count, unresolved_report_count = ca.unresolved_report_count FROM comment_aggregates AS ca WHERE comment.id = ca.comment_id -- If `(upvotes, downvotes) = (1, 0)`, then `(score, controversy_rank) = (1, 0)`, so it would be redundant to check `score` and `controversy_rank` in this filter. AND (ca.upvotes != 1 OR ca.downvotes != 0 OR ca.child_count != 0 OR ca.hot_rank != 0 OR ca.report_count != 0 OR ca.unresolved_report_count != 0); DROP TABLE comment_aggregates; -- Re-enable triggers after upserts ALTER TABLE comment ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'comment'); -- reindex REINDEX TABLE comment; -- 30s-2m each CREATE INDEX idx_comment_controversy ON comment USING btree (controversy_rank DESC); CREATE INDEX idx_comment_hot ON comment USING btree (hot_rank DESC, score DESC); CREATE INDEX idx_comment_nonzero_hotrank ON comment USING btree (published) WHERE (hot_rank <> (0)::double precision); --CREATE INDEX idx_comment_published on comment USING btree (published DESC); CREATE INDEX idx_comment_score ON comment USING btree (score DESC); -- merge post_aggregates into post table ALTER TABLE post ADD COLUMN newest_comment_time_necro timestamp with time zone, ADD COLUMN newest_comment_time timestamp with time zone, ADD COLUMN comments int NOT NULL DEFAULT 0, ADD COLUMN score int NOT NULL DEFAULT 1, -- Default value only for previous rows, to match the similar thing done with `upvotes` ADD COLUMN upvotes int NOT NULL DEFAULT 1, -- Default value only for previous rows, so the update below can filter out more rows by using `upvotes != 1` instead of `upvotes != 0` ADD COLUMN downvotes int NOT NULL DEFAULT 0, ADD COLUMN hot_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank != 0` instead of `hot_rank != 0.0001` ADD COLUMN hot_rank_active real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank_active != 0` instead of `hot_rank_active != 0.0001` ADD COLUMN controversy_rank real NOT NULL DEFAULT 0, ADD COLUMN scaled_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `scaled_rank != 0` instead of `scaled_rank != 0.0001` ADD COLUMN report_count smallint NOT NULL DEFAULT 0, ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; -- Default values only for future rows ALTER TABLE post ALTER COLUMN score SET DEFAULT 0, ALTER COLUMN upvotes SET DEFAULT 0, ALTER COLUMN hot_rank SET DEFAULT 0.0001, ALTER COLUMN hot_rank_active SET DEFAULT 0.0001, ALTER COLUMN scaled_rank SET DEFAULT 0.0001; -- Disable the triggers temporarily ALTER TABLE post DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'post'); UPDATE post SET newest_comment_time_necro = nullif (pa.newest_comment_time_necro, pa.published), newest_comment_time = nullif (pa.newest_comment_time, pa.published), comments = pa.comments, score = pa.score, upvotes = pa.upvotes, downvotes = pa.downvotes, hot_rank = pa.hot_rank, hot_rank_active = pa.hot_rank_active, controversy_rank = pa.controversy_rank, scaled_rank = pa.scaled_rank, report_count = pa.report_count, unresolved_report_count = pa.unresolved_report_count FROM post_aggregates AS pa WHERE post.id = pa.post_id -- If `(upvotes, downvotes) = (1, 0)`, then `(score, controversy_rank) = (1, 0)`, so it would be redundant to check `score` and `controversy_rank` in this filter. AND (pa.newest_comment_time_necro != pa.published OR pa.newest_comment_time != pa.published OR pa.comments != 0 OR pa.upvotes != 1 OR pa.downvotes != 0 OR pa.hot_rank != 0 OR pa.hot_rank_active != 0 OR pa.scaled_rank != 0 OR pa.report_count != 0 OR pa.unresolved_report_count != 0); -- Delete that data DROP TABLE post_aggregates; -- Re-enable triggers after upserts ALTER TABLE post ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'post'); -- reindex REINDEX TABLE post; CREATE INDEX idx_post_community_active ON post USING btree (community_id, featured_local DESC, hot_rank_active DESC, published DESC, id DESC); CREATE INDEX idx_post_community_controversy ON post USING btree (community_id, featured_local DESC, controversy_rank DESC, id DESC); CREATE INDEX idx_post_community_hot ON post USING btree (community_id, featured_local DESC, hot_rank DESC, published DESC, id DESC); CREATE INDEX idx_post_community_most_comments ON post USING btree (community_id, featured_local DESC, comments DESC, published DESC, id DESC); CREATE INDEX idx_post_community_newest_comment_time ON post USING btree (community_id, featured_local DESC, coalesce(newest_comment_time, published) DESC, id DESC); CREATE INDEX idx_post_community_newest_comment_time_necro ON post USING btree (community_id, featured_local DESC, coalesce(newest_comment_time_necro, published) DESC, id DESC); -- INDEX idx_post_community_published ON post USING btree (community_id, featured_local DESC, published DESC); --CREATE INDEX idx_post_community_published_asc ON post USING btree (community_id, featured_local DESC, reverse_timestamp_sort (published) DESC); CREATE INDEX idx_post_community_scaled ON post USING btree (community_id, featured_local DESC, scaled_rank DESC, published DESC, id DESC); CREATE INDEX idx_post_community_score ON post USING btree (community_id, featured_local DESC, score DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_community_active ON post USING btree (community_id, featured_community DESC, hot_rank_active DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_community_controversy ON post USING btree (community_id, featured_community DESC, controversy_rank DESC, id DESC); CREATE INDEX idx_post_featured_community_hot ON post USING btree (community_id, featured_community DESC, hot_rank DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_community_most_comments ON post USING btree (community_id, featured_community DESC, comments DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_community_newest_comment_time ON post USING btree (community_id, featured_community DESC, coalesce(newest_comment_time, published) DESC, id DESC); CREATE INDEX idx_post_featured_community_newest_comment_time_necr ON post USING btree (community_id, featured_community DESC, coalesce(newest_comment_time_necro, published) DESC, id DESC); --CREATE INDEX idx_post_featured_community_published ON post USING btree (community_id, featured_community DESC, published DESC); CREATE INDEX idx_post_featured_community_published_asc ON post USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC, id DESC); CREATE INDEX idx_post_featured_community_scaled ON post USING btree (community_id, featured_community DESC, scaled_rank DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_community_score ON post USING btree (community_id, featured_community DESC, score DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_local_active ON post USING btree (featured_local DESC, hot_rank_active DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_local_controversy ON post USING btree (featured_local DESC, controversy_rank DESC, id DESC); CREATE INDEX idx_post_featured_local_hot ON post USING btree (featured_local DESC, hot_rank DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_local_most_comments ON post USING btree (featured_local DESC, comments DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_local_newest_comment_time ON post USING btree (featured_local DESC, coalesce(newest_comment_time, published) DESC, id DESC); CREATE INDEX idx_post_featured_local_newest_comment_time_necro ON post USING btree (featured_local DESC, coalesce(newest_comment_time_necro, published) DESC, id DESC); CREATE INDEX idx_post_featured_local_published ON post USING btree (featured_local DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_local_published_asc ON post USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC, id DESC); CREATE INDEX idx_post_featured_local_scaled ON post USING btree (featured_local DESC, scaled_rank DESC, published DESC, id DESC); CREATE INDEX idx_post_featured_local_score ON post USING btree (featured_local DESC, score DESC, published DESC, id DESC); CREATE INDEX idx_post_nonzero_hotrank ON post USING btree (published DESC) WHERE ((hot_rank <> (0)::double precision) OR (hot_rank_active <> (0)::double precision)); CREATE INDEX idx_post_published_asc ON post USING btree (reverse_timestamp_sort (published) DESC); -- merge community_aggregates into community table ALTER TABLE community ADD COLUMN subscribers int NOT NULL DEFAULT 1, -- Default value only for previous rows, so the update below can filter out more rows by using `subscribers != 1` instead of `subscribers != 0` ADD COLUMN posts int NOT NULL DEFAULT 0, ADD COLUMN comments int NOT NULL DEFAULT 0, ADD COLUMN users_active_day int NOT NULL DEFAULT 0, ADD COLUMN users_active_week int NOT NULL DEFAULT 0, ADD COLUMN users_active_month int NOT NULL DEFAULT 0, ADD COLUMN users_active_half_year int NOT NULL DEFAULT 0, ADD COLUMN hot_rank real NOT NULL DEFAULT 0, -- Default value only for previous rows, so the update below can filter out more rows by using `hot_rank != 0` instead of `hot_rank != 0.0001` ADD COLUMN subscribers_local int NOT NULL DEFAULT 0, ADD COLUMN interactions_month int NOT NULL DEFAULT 0, ADD COLUMN report_count smallint NOT NULL DEFAULT 0, ADD COLUMN unresolved_report_count smallint NOT NULL DEFAULT 0; -- Default values only for future rows ALTER TABLE community ALTER COLUMN subscribers SET DEFAULT 0, ALTER COLUMN hot_rank SET DEFAULT 0.0001; -- Disable the triggers temporarily ALTER TABLE community DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'community'); UPDATE community SET subscribers = ca.subscribers, posts = ca.posts, comments = ca.comments, users_active_day = ca.users_active_day, users_active_week = ca.users_active_week, users_active_month = ca.users_active_month, users_active_half_year = ca.users_active_half_year, hot_rank = ca.hot_rank, subscribers_local = ca.subscribers_local, interactions_month = ca.interactions_month, report_count = ca.report_count, unresolved_report_count = ca.unresolved_report_count FROM community_aggregates AS ca WHERE community.id = ca.community_id AND (ca.subscribers != 1 OR ca.posts != 0 OR ca.comments != 0 OR ca.users_active_day != 0 OR ca.users_active_week != 0 OR ca.users_active_month != 0 OR ca.users_active_half_year != 0 OR ca.hot_rank != 0 OR ca.subscribers_local != 0 OR ca.interactions_month != 0 OR ca.report_count != 0 OR ca.unresolved_report_count != 0); DROP TABLE community_aggregates; -- Re-enable triggers after upserts ALTER TABLE community ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'community'); -- reindex REINDEX TABLE community; CREATE INDEX idx_community_hot ON public.community USING btree (hot_rank DESC); CREATE INDEX idx_community_nonzero_hotrank ON community USING btree (published) WHERE (hot_rank <> (0)::double precision); CREATE INDEX idx_community_subscribers ON public.community USING btree (subscribers DESC); CREATE INDEX idx_community_users_active_month ON public.community USING btree (users_active_month DESC); -- merge person_aggregates into person table ALTER TABLE person ADD COLUMN post_count int NOT NULL DEFAULT 0, ADD COLUMN post_score int NOT NULL DEFAULT 0, ADD COLUMN comment_count int NOT NULL DEFAULT 0, ADD COLUMN comment_score int NOT NULL DEFAULT 0; -- Disable the triggers temporarily ALTER TABLE person DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'person'); UPDATE person SET post_count = pa.post_count, post_score = pa.post_score, comment_count = pa.comment_count, comment_score = pa.comment_score FROM person_aggregates AS pa WHERE person.id = pa.person_id AND (pa.post_count != 0 OR pa.post_score != 0 OR pa.comment_count != 0 OR pa.comment_score != 0); DROP TABLE person_aggregates; -- Re-enable triggers after upserts ALTER TABLE person ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'person'); -- reindex REINDEX TABLE person; -- merge site_aggregates into local_site table ALTER TABLE local_site ADD COLUMN users int NOT NULL DEFAULT 1, ADD COLUMN posts int NOT NULL DEFAULT 0, ADD COLUMN comments int NOT NULL DEFAULT 0, ADD COLUMN communities int NOT NULL DEFAULT 0, ADD COLUMN users_active_day int NOT NULL DEFAULT 0, ADD COLUMN users_active_week int NOT NULL DEFAULT 0, ADD COLUMN users_active_month int NOT NULL DEFAULT 0, ADD COLUMN users_active_half_year int NOT NULL DEFAULT 0; -- Disable the triggers temporarily ALTER TABLE local_site DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_site'); UPDATE local_site SET users = sa.users, posts = sa.posts, comments = sa.comments, communities = sa.communities, users_active_day = sa.users_active_day, users_active_week = sa.users_active_week, users_active_month = sa.users_active_month, users_active_half_year = sa.users_active_half_year FROM site_aggregates AS sa WHERE local_site.site_id = sa.site_id; DROP TABLE site_aggregates; -- Re-enable triggers after upserts ALTER TABLE local_site ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_site'); -- reindex REINDEX TABLE local_site; -- merge local_user_vote_display_mode into local_user table ALTER TABLE local_user ADD COLUMN show_score boolean NOT NULL DEFAULT FALSE, ADD COLUMN show_upvotes boolean NOT NULL DEFAULT TRUE, ADD COLUMN show_downvotes boolean NOT NULL DEFAULT TRUE, ADD COLUMN show_upvote_percentage boolean NOT NULL DEFAULT FALSE; -- Disable the triggers temporarily ALTER TABLE local_user DISABLE TRIGGER ALL; -- disable all table indexes UPDATE pg_index SET indisready = FALSE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_user'); UPDATE local_user SET show_score = v.score, show_upvotes = v.upvotes, show_downvotes = v.downvotes, show_upvote_percentage = v.upvote_percentage FROM local_user_vote_display_mode AS v WHERE local_user.id = v.local_user_id AND (v.score OR NOT v.upvotes OR NOT v.downvotes OR v.upvote_percentage); DROP TABLE local_user_vote_display_mode; -- Re-enable triggers after upserts ALTER TABLE local_user ENABLE TRIGGER ALL; -- Re-enable indexes UPDATE pg_index SET indisready = TRUE WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'local_user'); -- reindex REINDEX TABLE local_user; ================================================ FILE: migrations/2025-08-01-000042_community-hidden-visibility/down.sql ================================================ -- recreate columns in the original order ALTER TABLE community ADD COLUMN hidden bool DEFAULT FALSE NOT NULL, ADD COLUMN visibility_new community_visibility DEFAULT 'Public'; UPDATE community SET visibility_new = visibility; ALTER TABLE community DROP COLUMN visibility; ALTER TABLE community RENAME COLUMN visibility_new TO visibility; -- same changes as up.sql, but the other way round UPDATE community SET (hidden, visibility) = (TRUE, 'Public') WHERE visibility = 'Unlisted'; ALTER TYPE community_visibility RENAME VALUE 'LocalOnlyPrivate' TO 'LocalOnly'; ALTER TYPE community_visibility RENAME TO community_visibility__; CREATE TYPE community_visibility AS enum ( 'Public', 'LocalOnly', 'Private' ); ALTER TABLE community ALTER COLUMN visibility DROP DEFAULT; ALTER TABLE community ALTER COLUMN visibility TYPE community_visibility USING visibility::text::community_visibility; ALTER TABLE community ALTER COLUMN visibility SET DEFAULT 'Public', ALTER COLUMN visibility SET NOT NULL; CREATE INDEX idx_community_random_number ON community (random_number) INCLUDE (local, nsfw) WHERE NOT (deleted OR removed OR visibility = 'Private'); REINDEX TABLE community; -- revert modlog table changes CREATE TABLE mod_hide_community ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz DEFAULT now(), reason text, hidden boolean DEFAULT FALSE NOT NULL, CONSTRAINT mod_hide_community_when__not_null NOT NULL published ); ALTER TABLE modlog_combined DROP COLUMN mod_change_community_visibility_id, ADD COLUMN mod_hide_community_id int REFERENCES mod_hide_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_lock_post_id_new int, ADD COLUMN mod_remove_comment_id_new int, ADD COLUMN mod_remove_community_id_new int, ADD COLUMN mod_remove_post_id_new int, ADD COLUMN mod_transfer_community_id_new int; UPDATE modlog_combined SET (mod_lock_post_id_new, mod_remove_comment_id_new, mod_remove_community_id_new, mod_remove_post_id_new, mod_transfer_community_id_new) = (mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id); ALTER TABLE modlog_combined DROP COLUMN mod_lock_post_id, DROP COLUMN mod_remove_comment_id, DROP COLUMN mod_remove_community_id, DROP COLUMN mod_remove_post_id, DROP COLUMN mod_transfer_community_id; ALTER TABLE modlog_combined RENAME COLUMN mod_lock_post_id_new TO mod_lock_post_id; ALTER TABLE modlog_combined RENAME COLUMN mod_remove_comment_id_new TO mod_remove_comment_id; ALTER TABLE modlog_combined RENAME COLUMN mod_remove_community_id_new TO mod_remove_community_id; ALTER TABLE modlog_combined RENAME COLUMN mod_remove_post_id_new TO mod_remove_post_id; ALTER TABLE modlog_combined RENAME COLUMN mod_transfer_community_id_new TO mod_transfer_community_id; ALTER TABLE modlog_combined ADD CONSTRAINT modlog_combined_mod_hide_community_id_key UNIQUE (mod_hide_community_id), ADD CONSTRAINT modlog_combined_mod_lock_post_id_key UNIQUE (mod_lock_post_id), ADD CONSTRAINT modlog_combined_mod_remove_comment_id_key UNIQUE (mod_remove_comment_id), ADD CONSTRAINT modlog_combined_mod_remove_community_id_key UNIQUE (mod_remove_community_id), ADD CONSTRAINT modlog_combined_mod_remove_post_id_key UNIQUE (mod_remove_post_id), ADD CONSTRAINT modlog_combined_mod_transfer_community_id_key UNIQUE (mod_transfer_community_id), ADD CONSTRAINT modlog_combined_mod_lock_post_id_fkey FOREIGN KEY (mod_lock_post_id) REFERENCES mod_lock_post (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_combined_mod_remove_comment_id_fkey FOREIGN KEY (mod_remove_comment_id) REFERENCES mod_remove_comment (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_combined_mod_remove_community_id_fkey FOREIGN KEY (mod_remove_community_id) REFERENCES mod_remove_community (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_combined_mod_remove_post_id_fkey FOREIGN KEY (mod_remove_post_id) REFERENCES mod_remove_post (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_combined_mod_transfer_community_id_fkey FOREIGN KEY (mod_transfer_community_id) REFERENCES mod_transfer_community (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_combined_check CHECK ((num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, mod_add_id, mod_add_community_id, mod_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_hide_community_id, mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1)); DROP TABLE mod_change_community_visibility; DROP TYPE community_visibility__; ================================================ FILE: migrations/2025-08-01-000042_community-hidden-visibility/up.sql ================================================ -- Change community.visibility to allow values: -- ('Public', 'LocalOnlyPublic', 'LocalOnlyPrivate','Private', 'Hidden') -- rename old enum and add new one ALTER TYPE community_visibility RENAME TO community_visibility__; CREATE TYPE community_visibility AS enum ( 'Public', 'LocalOnlyPublic', 'LocalOnly', 'Private', 'Unlisted' ); -- drop default value and index which reference old enum ALTER TABLE community ALTER COLUMN visibility DROP DEFAULT; DROP INDEX idx_community_random_number; -- change the column type ALTER TABLE community ALTER COLUMN visibility TYPE community_visibility USING visibility::text::community_visibility; -- add default and index back in ALTER TABLE community ALTER COLUMN visibility SET DEFAULT 'Public'; CREATE INDEX idx_community_random_number ON community (random_number) INCLUDE (local, nsfw) WHERE NOT (deleted OR removed OR visibility = 'Private' OR visibility = 'Unlisted'); DROP TYPE community_visibility__ CASCADE; ALTER TYPE community_visibility RENAME VALUE 'LocalOnly' TO 'LocalOnlyPrivate'; -- write hidden value to visibility column UPDATE community SET visibility = 'Unlisted' WHERE hidden; -- drop the old hidden column ALTER TABLE community DROP COLUMN hidden; -- change modlog tables ALTER TABLE modlog_combined DROP COLUMN mod_hide_community_id; DROP TABLE mod_hide_community; CREATE TABLE mod_change_community_visibility ( id serial PRIMARY KEY, community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published timestamptz NOT NULL DEFAULT now(), reason text, visibility community_visibility NOT NULL ); ALTER TABLE modlog_combined ADD COLUMN mod_change_community_visibility_id int REFERENCES mod_change_community_visibility (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_combined_check CHECK ((num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, mod_add_id, mod_add_community_id, mod_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, mod_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1)); ================================================ FILE: migrations/2025-08-01-000043_community-local-removed/down.sql ================================================ ALTER TABLE community DROP COLUMN local_removed; ================================================ FILE: migrations/2025-08-01-000043_community-local-removed/up.sql ================================================ -- Same for remote community, local removal should not be overwritten by -- remove+restore on home instance ALTER TABLE community ADD COLUMN local_removed boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-08-01-000044_post_comment_pending/down.sql ================================================ ALTER TABLE post DROP COLUMN federation_pending; ALTER TABLE comment DROP COLUMN federation_pending; ================================================ FILE: migrations/2025-08-01-000044_post_comment_pending/up.sql ================================================ -- When posting to a remote community mark it as pending until it gets announced back to us. -- This way the posts of banned users wont appear in the community on other instances. ALTER TABLE post ADD COLUMN federation_pending boolean NOT NULL DEFAULT FALSE; ALTER TABLE comment ADD COLUMN federation_pending boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-08-01-000045_site_person_ban/down.sql ================================================ ALTER TABLE mod_ban DROP COLUMN instance_id; ALTER TABLE person ADD COLUMN banned boolean DEFAULT FALSE, ADD CONSTRAINT user__banned_not_null NOT NULL banned, ADD COLUMN published_new timestamp with time zone DEFAULT now() NOT NULL, ADD COLUMN updated_new timestamp with time zone, ADD COLUMN ap_id_new varchar(255) DEFAULT generate_unique_changeme () NOT NULL, ADD COLUMN bio_new text, ADD COLUMN local_new boolean DEFAULT TRUE, ADD COLUMN private_key_new text, ADD COLUMN public_key_new text, ADD COLUMN last_refreshed_at_new timestamptz DEFAULT now() NOT NULL, ADD COLUMN banner_new text, ADD COLUMN deleted_new boolean NOT NULL DEFAULT FALSE, ADD COLUMN inbox_url_new varchar(255) DEFAULT generate_unique_changeme () NOT NULL, ADD COLUMN matrix_user_id_new text, ADD COLUMN bot_account_new boolean DEFAULT FALSE, ADD COLUMN ban_expires timestamptz, ADD COLUMN instance_id_new int; UPDATE person SET (published_new, updated_new, ap_id_new, bio_new, local_new, private_key_new, public_key_new, last_refreshed_at_new, banner_new, deleted_new, inbox_url_new, matrix_user_id_new, bot_account_new, instance_id_new) = (published, updated, ap_id, bio, local, private_key, public_key, last_refreshed_at, banner, deleted, inbox_url, matrix_user_id, bot_account, instance_id); ALTER TABLE person DROP COLUMN published, DROP COLUMN updated, DROP COLUMN ap_id, DROP COLUMN bio, DROP COLUMN local, DROP COLUMN private_key, DROP COLUMN public_key, DROP COLUMN last_refreshed_at, DROP COLUMN banner, DROP COLUMN deleted, DROP COLUMN inbox_url, DROP COLUMN matrix_user_id, DROP COLUMN bot_account, DROP COLUMN instance_id; ALTER TABLE person RENAME COLUMN published_new TO published; ALTER TABLE person RENAME COLUMN updated_new TO updated; ALTER TABLE person RENAME COLUMN ap_id_new TO ap_id; ALTER TABLE person RENAME COLUMN bio_new TO bio; ALTER TABLE person RENAME COLUMN local_new TO local; ALTER TABLE person ADD CONSTRAINT user__local_not_null NOT NULL local; ALTER TABLE person RENAME COLUMN private_key_new TO private_key; ALTER TABLE person RENAME COLUMN public_key_new TO public_key; ALTER TABLE person RENAME COLUMN last_refreshed_at_new TO last_refreshed_at; ALTER TABLE person RENAME COLUMN banner_new TO banner; ALTER TABLE person RENAME COLUMN deleted_new TO deleted; ALTER TABLE person RENAME COLUMN inbox_url_new TO inbox_url; ALTER TABLE person RENAME COLUMN matrix_user_id_new TO matrix_user_id; ALTER TABLE person RENAME COLUMN bot_account_new TO bot_account; ALTER TABLE person ALTER COLUMN bot_account SET NOT NULL; ALTER TABLE person RENAME COLUMN instance_id_new TO instance_id; ALTER TABLE person RENAME CONSTRAINT person_ap_id_new_not_null TO user__actor_id_not_null; ALTER TABLE person RENAME CONSTRAINT person_deleted_new_not_null TO user__deleted_not_null; ALTER TABLE person RENAME CONSTRAINT person_inbox_url_new_not_null TO person_shared_inbox_url_not_null; ALTER TABLE person RENAME CONSTRAINT person_last_refreshed_at_new_not_null TO user__last_refreshed_at_not_null; ALTER TABLE person RENAME CONSTRAINT person_published_new_not_null TO user__published_not_null; ALTER TABLE person ALTER public_key SET NOT NULL, ALTER instance_id SET NOT NULL, ADD CONSTRAINT idx_person_actor_id UNIQUE (ap_id); CREATE INDEX idx_person_local_instance ON person USING btree (local DESC, instance_id); CREATE UNIQUE INDEX idx_person_lower_actor_id ON person USING btree (lower((ap_id)::text)); CREATE INDEX idx_person_published ON person USING btree (published DESC); ALTER TABLE ONLY person ADD CONSTRAINT person_instance_id_fkey FOREIGN KEY (instance_id) REFERENCES instance (id) ON UPDATE CASCADE ON DELETE CASCADE; -- write existing bans into person table UPDATE person SET (banned, ban_expires) = (TRUE, subquery.expires) FROM ( SELECT instance_actions.ban_expires AS expires FROM instance_actions INNER JOIN instance ON instance_actions.instance_id = instance.id INNER JOIN person ON person.instance_id = instance.id WHERE instance_actions.received_ban != NULL) AS subquery; ALTER TABLE instance_actions DROP COLUMN received_ban, DROP COLUMN ban_expires; ================================================ FILE: migrations/2025-08-01-000045_site_person_ban/up.sql ================================================ ALTER TABLE instance_actions ADD COLUMN received_ban timestamptz; ALTER TABLE instance_actions ADD COLUMN ban_expires timestamptz; ALTER TABLE mod_ban ADD COLUMN instance_id int REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE; UPDATE mod_ban SET instance_id = person.instance_id FROM person WHERE mod_ban.instance_id IS NULL AND mod_ban.mod_person_id = person.id; ALTER TABLE mod_ban ALTER COLUMN instance_id SET NOT NULL, ALTER CONSTRAINT mod_ban_instance_id_fkey NOT DEFERRABLE; -- insert existing bans into instance_actions table, assuming they were all banned from home instance INSERT INTO instance_actions (person_id, instance_id, received_ban, ban_expires) SELECT id, instance_id, now(), ban_expires FROM person WHERE banned; ALTER TABLE person DROP COLUMN banned; ALTER TABLE person DROP COLUMN ban_expires; ================================================ FILE: migrations/2025-08-01-000047_disable-email-notifications/down.sql ================================================ ALTER TABLE local_site DROP COLUMN disable_email_notifications; ================================================ FILE: migrations/2025-08-01-000047_disable-email-notifications/up.sql ================================================ ALTER TABLE local_site ADD COLUMN disable_email_notifications bool NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-08-01-000048_cursor_pagination_indexes/down.sql ================================================ DROP INDEX idx_tagline_published_id; DROP INDEX idx_comment_actions_like_score; DROP INDEX idx_post_actions_like_score; -- Fixing the community sorts for an id tie-breaker DROP INDEX idx_community_lower_name; DROP INDEX idx_community_hot; DROP INDEX idx_community_published; DROP INDEX idx_community_subscribers; DROP INDEX idx_community_title; DROP INDEX idx_community_users_active_month; CREATE INDEX idx_community_lower_name ON community USING btree (lower((name)::text)); CREATE INDEX idx_community_hot ON community USING btree (hot_rank DESC); CREATE INDEX idx_community_published ON community USING btree (published DESC); CREATE INDEX idx_community_subscribers ON community USING btree (subscribers DESC); CREATE INDEX idx_community_title ON community USING btree (title); CREATE INDEX idx_community_users_active_month ON community USING btree (users_active_month DESC); -- Drop the missing ones. DROP INDEX idx_community_users_active_half_year; DROP INDEX idx_community_users_active_week; DROP INDEX idx_community_users_active_day; DROP INDEX idx_community_subscribers_local; DROP INDEX idx_community_comments; DROP INDEX idx_community_posts; -- Fix the post reverse_timestamp key sorts. DROP INDEX idx_post_community_published; DROP INDEX idx_post_featured_community_published; CREATE INDEX idx_post_featured_community_published_asc ON post USING btree (community_id, featured_community DESC, reverse_timestamp_sort (published) DESC, id DESC); CREATE INDEX idx_post_featured_local_published_asc ON post USING btree (featured_local DESC, reverse_timestamp_sort (published) DESC, id DESC); CREATE INDEX idx_post_published_asc ON post USING btree (reverse_timestamp_sort (published) DESC); ================================================ FILE: migrations/2025-08-01-000048_cursor_pagination_indexes/up.sql ================================================ -- Taglines CREATE INDEX idx_tagline_published_id ON tagline (published DESC, id DESC); -- Some for the vote views CREATE INDEX idx_comment_actions_like_score ON comment_actions (comment_id, vote_is_upvote, person_id) WHERE vote_is_upvote IS NOT NULL; CREATE INDEX idx_post_actions_like_score ON post_actions (post_id, vote_is_upvote, person_id) WHERE vote_is_upvote IS NOT NULL; -- Fixing the community sorts for an id tie-breaker DROP INDEX idx_community_lower_name; DROP INDEX idx_community_hot; DROP INDEX idx_community_published; DROP INDEX idx_community_subscribers; DROP INDEX idx_community_title; DROP INDEX idx_community_users_active_month; CREATE INDEX idx_community_lower_name ON community USING btree (lower((name)::text) DESC, id DESC); CREATE INDEX idx_community_hot ON community USING btree (hot_rank DESC, id DESC); CREATE INDEX idx_community_published ON community USING btree (published DESC, id DESC); CREATE INDEX idx_community_subscribers ON community USING btree (subscribers DESC, id DESC); CREATE INDEX idx_community_title ON community USING btree (title DESC, id DESC); CREATE INDEX idx_community_users_active_month ON community USING btree (users_active_month DESC, id DESC); -- Create a few missing ones CREATE INDEX idx_community_users_active_half_year ON community USING btree (users_active_half_year DESC, id DESC); CREATE INDEX idx_community_users_active_week ON community USING btree (users_active_week DESC, id DESC); CREATE INDEX idx_community_users_active_day ON community USING btree (users_active_day DESC, id DESC); CREATE INDEX idx_community_subscribers_local ON community USING btree (subscribers_local DESC, id DESC); CREATE INDEX idx_community_comments ON community USING btree (comments DESC, id DESC); CREATE INDEX idx_community_posts ON community USING btree (posts DESC, id DESC); -- Fix the post reverse_timestamp key sorts. DROP INDEX idx_post_featured_community_published_asc; DROP INDEX idx_post_featured_local_published_asc; DROP INDEX idx_post_published_asc; CREATE INDEX idx_post_featured_community_published ON post USING btree (community_id, featured_community DESC, published DESC, id DESC); CREATE INDEX idx_post_community_published ON post USING btree (community_id, published DESC, id DESC); ================================================ FILE: migrations/2025-08-01-000049_add_liked_combined/down.sql ================================================ DROP TABLE person_liked_combined; ================================================ FILE: migrations/2025-08-01-000049_add_liked_combined/up.sql ================================================ -- Creates combined tables for -- person_liked: (comment, post) -- This one is special, because you use the liked date, not the ordinary published -- Updating the history CREATE SEQUENCE person_liked_combined_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; CREATE TABLE person_liked_combined AS SELECT pa.liked, -- `ADD COLUMN id serial` is not used for this because it would require either putting the column at the end (might increase the amount of padding bytes) or using an `INSERT` statement (not parallelizable). nextval('person_liked_combined_id_seq'::regclass)::int AS id, pa.person_id, po.creator_id, pa.post_id, NULL::int AS comment_id, pa.vote_is_upvote FROM post_actions pa, person p, post po WHERE pa.liked IS NOT NULL AND p.local = TRUE AND pa.person_id = p.id AND pa.post_id = po.id UNION ALL SELECT ca.liked, nextval('person_liked_combined_id_seq'::regclass)::int, ca.person_id, co.creator_id, NULL::int, ca.comment_id, ca.vote_is_upvote FROM comment_actions ca, person p, comment co WHERE liked IS NOT NULL AND p.local = TRUE AND ca.person_id = p.id AND ca.comment_id = co.id; ALTER TABLE person_liked_combined ALTER COLUMN id SET DEFAULT nextval('person_liked_combined_id_seq'::regclass), ALTER COLUMN liked SET NOT NULL, ALTER COLUMN vote_is_upvote SET NOT NULL, ALTER COLUMN person_id SET NOT NULL, ALTER COLUMN creator_id SET NOT NULL, ADD CONSTRAINT person_liked_combined_person_id_fkey FOREIGN KEY (person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_liked_combined_creator_id_fkey FOREIGN KEY (creator_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_liked_combined_post_id_fkey FOREIGN KEY (post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT person_liked_combined_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, ADD UNIQUE (person_id, post_id), ADD UNIQUE (person_id, comment_id), ADD PRIMARY KEY (id), ADD CONSTRAINT person_liked_combined_check CHECK (num_nonnulls (post_id, comment_id) = 1); ALTER SEQUENCE person_liked_combined_id_seq OWNED BY person_liked_combined.id; CREATE INDEX idx_person_liked_combined_person ON person_liked_combined (person_id); CREATE INDEX idx_person_liked_combined_creator ON person_liked_combined (creator_id); CREATE INDEX idx_person_liked_combined_person_voted_at ON person_liked_combined (person_id, liked DESC, id DESC); ================================================ FILE: migrations/2025-08-01-000050_show_downvotes_for_others_only/down.sql ================================================ ALTER TABLE local_user ALTER COLUMN show_downvotes DROP DEFAULT; ALTER TABLE local_user ALTER COLUMN show_downvotes TYPE boolean USING CASE show_downvotes WHEN 'Hide' THEN FALSE ELSE TRUE END; -- Make true the default ALTER TABLE local_user ALTER COLUMN show_downvotes SET DEFAULT TRUE; DROP TYPE vote_show_enum; ================================================ FILE: migrations/2025-08-01-000050_show_downvotes_for_others_only/up.sql ================================================ -- This changes the local_user.show_downvotes column to an enum, -- which by default shows all downvotes. CREATE TYPE vote_show_enum AS ENUM ( 'Show', 'ShowForOthers', 'Hide' ); ALTER TABLE local_user ALTER COLUMN show_downvotes DROP DEFAULT; ALTER TABLE local_user ALTER COLUMN show_downvotes TYPE vote_show_enum USING CASE show_downvotes WHEN FALSE THEN 'Hide' ELSE 'Show' END::vote_show_enum; -- Make ShowForOthers the default ALTER TABLE local_user ALTER COLUMN show_downvotes SET DEFAULT 'Show'; ================================================ FILE: migrations/2025-08-01-000051_local_image_person/down.sql ================================================ ALTER TABLE local_image ADD COLUMN local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE; UPDATE local_image AS li SET local_user_id = lu.id FROM local_user AS lu WHERE li.person_id = lu.person_id; -- You need to have the exact correct column order, so this needs to be re-created -- -- Rename the table ALTER TABLE local_image RENAME TO local_image_old; -- Rename a few constraints ALTER TABLE local_image_old RENAME CONSTRAINT image_upload_pkey TO image_upload_pkey_old; -- Create the old one again CREATE TABLE local_image ( local_user_id integer, pictrs_alias text, published timestamp with time zone DEFAULT now(), CONSTRAINT image_upload_pictrs_alias_not_null NOT NULL pictrs_alias, CONSTRAINT image_upload_published_not_null NOT NULL published ); ALTER TABLE ONLY local_image ADD CONSTRAINT image_upload_pkey PRIMARY KEY (pictrs_alias); CREATE INDEX idx_image_upload_local_user_id ON local_image USING btree (local_user_id); ALTER TABLE ONLY local_image ADD CONSTRAINT image_upload_local_user_id_fkey FOREIGN KEY (local_user_id) REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE; -- Insert the data again INSERT INTO local_image (local_user_id, pictrs_alias, published) SELECT local_user_id, pictrs_alias, published FROM local_image_old; DROP TABLE local_image_old; ================================================ FILE: migrations/2025-08-01-000051_local_image_person/up.sql ================================================ -- Since local thumbnails could be generated from posts of external users, -- use the person_id instead of local_user_id for the LocalImage table. -- -- Also connect the thumbnail to a post id. -- -- See https://github.com/LemmyNet/lemmy/issues/5564 ALTER TABLE local_image ADD COLUMN person_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE, ADD COLUMN thumbnail_for_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE; -- Update historical person_id columns -- Note: The local_user_id rows are null for thumbnails, so there's nothing you can do there. UPDATE local_image AS li SET person_id = lu.person_id FROM local_user AS lu WHERE li.local_user_id = lu.id; -- Remove the local_user_id column ALTER TABLE local_image DROP COLUMN local_user_id; CREATE INDEX idx_image_upload_person_id ON local_image (person_id); ALTER TABLE local_image ALTER CONSTRAINT local_image_person_id_fkey NOT DEFERRABLE, ALTER CONSTRAINT local_image_thumbnail_for_post_id_fkey NOT DEFERRABLE; ================================================ FILE: migrations/2025-08-01-000052_lock_reason/down.sql ================================================ ALTER TABLE mod_lock_post DROP COLUMN reason; ================================================ FILE: migrations/2025-08-01-000052_lock_reason/up.sql ================================================ -- Adding a lock reason field to mod_lock_post ALTER TABLE mod_lock_post ADD COLUMN reason text; ================================================ FILE: migrations/2025-08-01-000053_remove_hide_modlog_names/down.sql ================================================ -- You need to remake all the columns after the changed one. -- -- 1. Create old column, and add _new to every one after -- 2. Update the _new to the old -- 3. Drop the old -- 4. Rename the new ALTER TABLE local_site ADD COLUMN hide_modlog_mod_names boolean DEFAULT TRUE NOT NULL, ADD COLUMN application_email_admins_new boolean DEFAULT FALSE, ADD COLUMN slur_filter_regex_new text, ADD COLUMN actor_name_max_length_new integer DEFAULT 20, ADD COLUMN federation_enabled_new boolean DEFAULT TRUE, ADD COLUMN captcha_enabled_new boolean DEFAULT FALSE, ADD COLUMN captcha_difficulty_new character varying(255) DEFAULT 'medium'::character varying, ADD COLUMN published_new timestamp with time zone DEFAULT now(), ADD COLUMN updated_new timestamp with time zone, ADD COLUMN registration_mode_new public.registration_mode_enum DEFAULT 'RequireApplication'::public.registration_mode_enum, ADD COLUMN reports_email_admins_new boolean DEFAULT FALSE, ADD COLUMN federation_signed_fetch_new boolean DEFAULT TRUE, ADD COLUMN default_post_listing_mode_new public.post_listing_mode_enum DEFAULT 'List'::public.post_listing_mode_enum, ADD COLUMN default_post_sort_type_new public.post_sort_type_enum DEFAULT 'Active'::public.post_sort_type_enum, ADD COLUMN default_comment_sort_type_new public.comment_sort_type_enum DEFAULT 'Hot'::public.comment_sort_type_enum, ADD COLUMN oauth_registration_new boolean DEFAULT TRUE, ADD COLUMN post_upvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum, ADD COLUMN post_downvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum, ADD COLUMN comment_upvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum, ADD COLUMN comment_downvotes_new public.federation_mode_enum DEFAULT 'All'::public.federation_mode_enum, ADD COLUMN default_post_time_range_seconds_new integer, ADD COLUMN disallow_nsfw_content_new boolean DEFAULT FALSE, ADD COLUMN users_new int DEFAULT 1, ADD COLUMN posts_new int DEFAULT 0, ADD COLUMN comments_new int DEFAULT 0, ADD COLUMN communities_new int DEFAULT 0, ADD COLUMN users_active_day_new int DEFAULT 0, ADD COLUMN users_active_week_new int DEFAULT 0, ADD COLUMN users_active_month_new int DEFAULT 0, ADD COLUMN users_active_half_year_new int DEFAULT 0, ADD COLUMN disable_email_notifications_new boolean DEFAULT FALSE; -- Update UPDATE local_site SET (application_email_admins_new, slur_filter_regex_new, actor_name_max_length_new, federation_enabled_new, captcha_enabled_new, captcha_difficulty_new, published_new, updated_new, registration_mode_new, reports_email_admins_new, federation_signed_fetch_new, default_post_listing_mode_new, default_post_sort_type_new, default_comment_sort_type_new, oauth_registration_new, post_upvotes_new, post_downvotes_new, comment_upvotes_new, comment_downvotes_new, default_post_time_range_seconds_new, disallow_nsfw_content_new, users_new, posts_new, comments_new, communities_new, users_active_day_new, users_active_week_new, users_active_month_new, users_active_half_year_new, disable_email_notifications_new) = (application_email_admins, slur_filter_regex, actor_name_max_length, federation_enabled, captcha_enabled, captcha_difficulty, published, updated, registration_mode, reports_email_admins, federation_signed_fetch, default_post_listing_mode, default_post_sort_type, default_comment_sort_type, oauth_registration, post_upvotes, post_downvotes, comment_upvotes, comment_downvotes, default_post_time_range_seconds, disallow_nsfw_content, users, posts, comments, communities, users_active_day, users_active_week, users_active_month, users_active_half_year, disable_email_notifications); -- Drop ALTER TABLE local_site DROP COLUMN application_email_admins, DROP COLUMN slur_filter_regex, DROP COLUMN actor_name_max_length, DROP COLUMN federation_enabled, DROP COLUMN captcha_enabled, DROP COLUMN captcha_difficulty, DROP COLUMN published, DROP COLUMN updated, DROP COLUMN registration_mode, DROP COLUMN reports_email_admins, DROP COLUMN federation_signed_fetch, DROP COLUMN default_post_listing_mode, DROP COLUMN default_post_sort_type, DROP COLUMN default_comment_sort_type, DROP COLUMN oauth_registration, DROP COLUMN post_upvotes, DROP COLUMN post_downvotes, DROP COLUMN comment_upvotes, DROP COLUMN comment_downvotes, DROP COLUMN default_post_time_range_seconds, DROP COLUMN disallow_nsfw_content, DROP COLUMN users, DROP COLUMN posts, DROP COLUMN comments, DROP COLUMN communities, DROP COLUMN users_active_day, DROP COLUMN users_active_week, DROP COLUMN users_active_month, DROP COLUMN users_active_half_year, DROP COLUMN disable_email_notifications; -- Rename ALTER TABLE local_site RENAME COLUMN application_email_admins_new TO application_email_admins; ALTER TABLE local_site RENAME COLUMN slur_filter_regex_new TO slur_filter_regex; ALTER TABLE local_site RENAME COLUMN actor_name_max_length_new TO actor_name_max_length; ALTER TABLE local_site RENAME COLUMN federation_enabled_new TO federation_enabled; ALTER TABLE local_site RENAME COLUMN captcha_enabled_new TO captcha_enabled; ALTER TABLE local_site RENAME COLUMN captcha_difficulty_new TO captcha_difficulty; ALTER TABLE local_site RENAME COLUMN published_new TO published; ALTER TABLE local_site RENAME COLUMN updated_new TO updated; ALTER TABLE local_site RENAME COLUMN registration_mode_new TO registration_mode; ALTER TABLE local_site RENAME COLUMN reports_email_admins_new TO reports_email_admins; ALTER TABLE local_site RENAME COLUMN federation_signed_fetch_new TO federation_signed_fetch; ALTER TABLE local_site RENAME COLUMN default_post_listing_mode_new TO default_post_listing_mode; ALTER TABLE local_site RENAME COLUMN default_post_sort_type_new TO default_post_sort_type; ALTER TABLE local_site RENAME COLUMN default_comment_sort_type_new TO default_comment_sort_type; ALTER TABLE local_site RENAME COLUMN oauth_registration_new TO oauth_registration; ALTER TABLE local_site RENAME COLUMN post_upvotes_new TO post_upvotes; ALTER TABLE local_site RENAME COLUMN post_downvotes_new TO post_downvotes; ALTER TABLE local_site RENAME COLUMN comment_upvotes_new TO comment_upvotes; ALTER TABLE local_site RENAME COLUMN comment_downvotes_new TO comment_downvotes; ALTER TABLE local_site RENAME COLUMN default_post_time_range_seconds_new TO default_post_time_range_seconds; ALTER TABLE local_site RENAME COLUMN disallow_nsfw_content_new TO disallow_nsfw_content; ALTER TABLE local_site RENAME COLUMN users_new TO users; ALTER TABLE local_site RENAME COLUMN posts_new TO posts; ALTER TABLE local_site RENAME COLUMN comments_new TO comments; ALTER TABLE local_site RENAME COLUMN communities_new TO communities; ALTER TABLE local_site RENAME COLUMN users_active_day_new TO users_active_day; ALTER TABLE local_site RENAME COLUMN users_active_week_new TO users_active_week; ALTER TABLE local_site RENAME COLUMN users_active_month_new TO users_active_month; ALTER TABLE local_site RENAME COLUMN users_active_half_year_new TO users_active_half_year; ALTER TABLE local_site RENAME COLUMN disable_email_notifications_new TO disable_email_notifications; ALTER TABLE local_site ALTER COLUMN application_email_admins SET NOT NULL, ALTER COLUMN actor_name_max_length SET NOT NULL, ALTER COLUMN captcha_difficulty SET NOT NULL, ALTER COLUMN captcha_enabled SET NOT NULL, ALTER COLUMN comment_downvotes SET NOT NULL, ALTER COLUMN comments SET NOT NULL, ALTER COLUMN communities SET NOT NULL, ALTER COLUMN comment_upvotes SET NOT NULL, ALTER COLUMN default_comment_sort_type SET NOT NULL, ALTER COLUMN default_post_listing_mode SET NOT NULL, ADD CONSTRAINT local_site_default_sort_type_not_null NOT NULL default_post_sort_type, ALTER COLUMN disable_email_notifications SET NOT NULL, ALTER COLUMN disallow_nsfw_content SET NOT NULL, ALTER COLUMN federation_enabled SET NOT NULL, ALTER COLUMN federation_signed_fetch SET NOT NULL, ALTER COLUMN oauth_registration SET NOT NULL, ALTER COLUMN post_downvotes SET NOT NULL, ALTER COLUMN post_upvotes SET NOT NULL, ALTER COLUMN published SET NOT NULL, ALTER COLUMN posts SET NOT NULL, ALTER COLUMN users SET NOT NULL, ALTER COLUMN registration_mode SET NOT NULL, ALTER COLUMN reports_email_admins SET NOT NULL, ALTER COLUMN users_active_day SET NOT NULL, ALTER COLUMN users_active_week SET NOT NULL, ALTER COLUMN users_active_month SET NOT NULL, ALTER COLUMN users_active_half_year SET NOT NULL; ================================================ FILE: migrations/2025-08-01-000053_remove_hide_modlog_names/up.sql ================================================ ALTER TABLE local_site DROP COLUMN hide_modlog_mod_names; ================================================ FILE: migrations/2025-08-01-000054_mod-change-community-vis/down.sql ================================================ ALTER TABLE mod_change_community_visibility ADD COLUMN reason text, ADD COLUMN visibility_new community_visibility; UPDATE mod_change_community_visibility SET visibility_new = visibility; ALTER TABLE mod_change_community_visibility DROP COLUMN visibility; ALTER TABLE mod_change_community_visibility RENAME COLUMN visibility_new TO visibility; ALTER TABLE mod_change_community_visibility ALTER COLUMN visibility SET NOT NULL; ================================================ FILE: migrations/2025-08-01-000054_mod-change-community-vis/up.sql ================================================ ALTER TABLE mod_change_community_visibility DROP COLUMN reason; ================================================ FILE: migrations/2025-08-01-000055_rename_timestamp_add_at/down.sql ================================================ ALTER TABLE admin_allow_instance RENAME published_at TO published; ALTER TABLE admin_block_instance RENAME COLUMN expires_at TO expires; ALTER TABLE admin_block_instance RENAME COLUMN published_at TO published; ALTER TABLE admin_purge_comment RENAME COLUMN published_at TO published; ALTER TABLE admin_purge_community RENAME COLUMN published_at TO published; ALTER TABLE admin_purge_person RENAME COLUMN published_at TO published; ALTER TABLE admin_purge_post RENAME COLUMN published_at TO published; ALTER TABLE captcha_answer RENAME COLUMN published_at TO published; ALTER TABLE comment RENAME COLUMN published_at TO published; ALTER TABLE comment RENAME COLUMN updated_at TO updated; ALTER TABLE comment_actions RENAME COLUMN voted_at TO liked; ALTER TABLE comment_actions RENAME COLUMN saved_at TO saved; ALTER TABLE comment_reply RENAME COLUMN published_at TO published; ALTER TABLE comment_report RENAME COLUMN published_at TO published; ALTER TABLE comment_report RENAME COLUMN updated_at TO updated; ALTER TABLE community RENAME COLUMN published_at TO published; ALTER TABLE community RENAME COLUMN updated_at TO updated; ALTER TABLE community_actions RENAME COLUMN followed_at TO followed; ALTER TABLE community_actions RENAME COLUMN blocked_at TO blocked; ALTER TABLE community_actions RENAME COLUMN became_moderator_at TO became_moderator; ALTER TABLE community_actions RENAME COLUMN received_ban_at TO received_ban; ALTER TABLE community_actions RENAME COLUMN ban_expires_at TO ban_expires; ALTER TABLE community_report RENAME COLUMN published_at TO published; ALTER TABLE community_report RENAME COLUMN updated_at TO updated; ALTER TABLE custom_emoji RENAME COLUMN published_at TO published; ALTER TABLE custom_emoji RENAME COLUMN updated_at TO updated; ALTER TABLE email_verification RENAME COLUMN published_at TO published; ALTER TABLE federation_allowlist RENAME COLUMN published_at TO published; ALTER TABLE federation_allowlist RENAME COLUMN updated_at TO updated; ALTER TABLE federation_blocklist RENAME COLUMN published_at TO published; ALTER TABLE federation_blocklist RENAME COLUMN updated_at TO updated; ALTER TABLE federation_blocklist RENAME COLUMN expires_at TO expires; ALTER TABLE federation_queue_state RENAME COLUMN last_retry_at TO last_retry; ALTER TABLE federation_queue_state RENAME COLUMN last_successful_published_time_at TO last_successful_published_time; ALTER TABLE inbox_combined RENAME COLUMN published_at TO published; ALTER TABLE instance RENAME COLUMN published_at TO published; ALTER TABLE instance RENAME COLUMN updated_at TO updated; ALTER TABLE instance_actions RENAME COLUMN blocked_at TO blocked; ALTER TABLE instance_actions RENAME COLUMN received_ban_at TO received_ban; ALTER TABLE instance_actions RENAME COLUMN ban_expires_at TO ban_expires; ALTER TABLE local_image RENAME COLUMN published_at TO published; ALTER TABLE local_site RENAME COLUMN published_at TO published; ALTER TABLE local_site RENAME COLUMN updated_at TO updated; ALTER TABLE local_site_rate_limit RENAME COLUMN published_at TO published; ALTER TABLE local_site_rate_limit RENAME COLUMN updated_at TO updated; ALTER TABLE local_site_url_blocklist RENAME COLUMN published_at TO published; ALTER TABLE local_site_url_blocklist RENAME COLUMN updated_at TO updated; ALTER TABLE local_user RENAME COLUMN last_donation_notification_at TO last_donation_notification; ALTER TABLE login_token RENAME COLUMN published_at TO published; ALTER TABLE mod_add RENAME COLUMN published_at TO published; ALTER TABLE mod_add_community RENAME COLUMN published_at TO published; ALTER TABLE mod_ban RENAME COLUMN published_at TO published; ALTER TABLE mod_ban RENAME COLUMN expires_at TO expires; ALTER TABLE mod_ban_from_community RENAME COLUMN published_at TO published; ALTER TABLE mod_ban_from_community RENAME COLUMN expires_at TO expires; ALTER TABLE mod_change_community_visibility RENAME COLUMN published_at TO published; ALTER TABLE mod_feature_post RENAME COLUMN published_at TO published; ALTER TABLE mod_lock_post RENAME COLUMN published_at TO published; ALTER TABLE mod_remove_comment RENAME COLUMN published_at TO published; ALTER TABLE mod_remove_community RENAME COLUMN published_at TO published; ALTER TABLE mod_remove_post RENAME COLUMN published_at TO published; ALTER TABLE mod_transfer_community RENAME COLUMN published_at TO published; ALTER TABLE modlog_combined RENAME COLUMN published_at TO published; ALTER TABLE oauth_account RENAME COLUMN published_at TO published; ALTER TABLE oauth_account RENAME COLUMN updated_at TO updated; ALTER TABLE oauth_provider RENAME COLUMN published_at TO published; ALTER TABLE oauth_provider RENAME COLUMN updated_at TO updated; ALTER TABLE password_reset_request RENAME COLUMN published_at TO published; ALTER TABLE person RENAME COLUMN published_at TO published; ALTER TABLE person RENAME COLUMN updated_at TO updated; ALTER TABLE person_actions RENAME COLUMN followed_at TO followed; ALTER TABLE person_actions RENAME COLUMN blocked_at TO blocked; ALTER TABLE person_ban RENAME COLUMN published_at TO published; ALTER TABLE person_comment_mention RENAME COLUMN published_at TO published; ALTER TABLE person_content_combined RENAME COLUMN published_at TO published; ALTER TABLE person_liked_combined RENAME COLUMN voted_at TO liked; ALTER TABLE person_post_mention RENAME COLUMN published_at TO published; ALTER TABLE person_saved_combined RENAME COLUMN saved_at TO saved; ALTER TABLE post RENAME COLUMN published_at TO published; ALTER TABLE post RENAME COLUMN updated_at TO updated; ALTER TABLE post RENAME COLUMN scheduled_publish_time_at TO scheduled_publish_time; ALTER TABLE post RENAME COLUMN newest_comment_time_at TO newest_comment_time; ALTER TABLE post RENAME COLUMN newest_comment_time_necro_at TO newest_comment_time_necro; ALTER TABLE post_actions RENAME COLUMN read_at TO read; ALTER TABLE post_actions RENAME COLUMN read_comments_at TO read_comments; ALTER TABLE post_actions RENAME COLUMN saved_at TO saved; ALTER TABLE post_actions RENAME COLUMN voted_at TO liked; ALTER TABLE post_actions RENAME COLUMN hidden_at TO hidden; ALTER TABLE post_report RENAME COLUMN published_at TO published; ALTER TABLE post_report RENAME COLUMN updated_at TO updated; ALTER TABLE post_tag RENAME COLUMN published_at TO published; ALTER TABLE private_message RENAME COLUMN published_at TO published; ALTER TABLE private_message RENAME COLUMN updated_at TO updated; ALTER TABLE private_message_report RENAME COLUMN published_at TO published; ALTER TABLE private_message_report RENAME COLUMN updated_at TO updated; ALTER TABLE received_activity RENAME COLUMN published_at TO published; ALTER TABLE registration_application RENAME COLUMN published_at TO published; ALTER TABLE remote_image RENAME COLUMN published_at TO published; ALTER TABLE report_combined RENAME COLUMN published_at TO published; ALTER TABLE search_combined RENAME COLUMN published_at TO published; ALTER TABLE sent_activity RENAME COLUMN published_at TO published; ALTER TABLE site RENAME COLUMN published_at TO published; ALTER TABLE site RENAME COLUMN updated_at TO updated; ALTER TABLE tag RENAME COLUMN published_at TO published; ALTER TABLE tag RENAME COLUMN updated_at TO updated; ALTER TABLE tagline RENAME COLUMN published_at TO published; ALTER TABLE tagline RENAME COLUMN updated_at TO updated; ================================================ FILE: migrations/2025-08-01-000055_rename_timestamp_add_at/up.sql ================================================ ALTER TABLE admin_allow_instance RENAME COLUMN published TO published_at; ALTER TABLE admin_block_instance RENAME COLUMN expires TO expires_at; ALTER TABLE admin_block_instance RENAME COLUMN published TO published_at; ALTER TABLE admin_purge_comment RENAME COLUMN published TO published_at; ALTER TABLE admin_purge_community RENAME COLUMN published TO published_at; ALTER TABLE admin_purge_person RENAME COLUMN published TO published_at; ALTER TABLE admin_purge_post RENAME COLUMN published TO published_at; ALTER TABLE captcha_answer RENAME COLUMN published TO published_at; ALTER TABLE comment RENAME COLUMN published TO published_at; ALTER TABLE comment RENAME COLUMN updated TO updated_at; ALTER TABLE comment_actions RENAME COLUMN liked TO voted_at; ALTER TABLE comment_actions RENAME COLUMN saved TO saved_at; ALTER TABLE comment_reply RENAME COLUMN published TO published_at; ALTER TABLE comment_report RENAME COLUMN published TO published_at; ALTER TABLE comment_report RENAME COLUMN updated TO updated_at; ALTER TABLE community RENAME COLUMN published TO published_at; ALTER TABLE community RENAME COLUMN updated TO updated_at; ALTER TABLE community_actions RENAME COLUMN followed TO followed_at; ALTER TABLE community_actions RENAME COLUMN blocked TO blocked_at; ALTER TABLE community_actions RENAME COLUMN became_moderator TO became_moderator_at; ALTER TABLE community_actions RENAME COLUMN received_ban TO received_ban_at; ALTER TABLE community_actions RENAME COLUMN ban_expires TO ban_expires_at; ALTER TABLE community_report RENAME COLUMN published TO published_at; ALTER TABLE community_report RENAME COLUMN updated TO updated_at; ALTER TABLE custom_emoji RENAME COLUMN published TO published_at; ALTER TABLE custom_emoji RENAME COLUMN updated TO updated_at; ALTER TABLE email_verification RENAME COLUMN published TO published_at; ALTER TABLE federation_allowlist RENAME COLUMN published TO published_at; ALTER TABLE federation_allowlist RENAME COLUMN updated TO updated_at; ALTER TABLE federation_blocklist RENAME COLUMN published TO published_at; ALTER TABLE federation_blocklist RENAME COLUMN updated TO updated_at; ALTER TABLE federation_blocklist RENAME COLUMN expires TO expires_at; ALTER TABLE federation_queue_state RENAME COLUMN last_retry TO last_retry_at; ALTER TABLE federation_queue_state RENAME COLUMN last_successful_published_time TO last_successful_published_time_at; ALTER TABLE inbox_combined RENAME COLUMN published TO published_at; ALTER TABLE instance RENAME COLUMN published TO published_at; ALTER TABLE instance RENAME COLUMN updated TO updated_at; ALTER TABLE instance_actions RENAME COLUMN blocked TO blocked_at; ALTER TABLE instance_actions RENAME COLUMN received_ban TO received_ban_at; ALTER TABLE instance_actions RENAME COLUMN ban_expires TO ban_expires_at; ALTER TABLE local_image RENAME COLUMN published TO published_at; ALTER TABLE local_site RENAME COLUMN published TO published_at; ALTER TABLE local_site RENAME COLUMN updated TO updated_at; ALTER TABLE local_site_rate_limit RENAME COLUMN published TO published_at; ALTER TABLE local_site_rate_limit RENAME COLUMN updated TO updated_at; ALTER TABLE local_site_url_blocklist RENAME COLUMN published TO published_at; ALTER TABLE local_site_url_blocklist RENAME COLUMN updated TO updated_at; ALTER TABLE local_user RENAME COLUMN last_donation_notification TO last_donation_notification_at; ALTER TABLE login_token RENAME COLUMN published TO published_at; ALTER TABLE mod_add RENAME COLUMN published TO published_at; ALTER TABLE mod_add_community RENAME COLUMN published TO published_at; ALTER TABLE mod_ban RENAME COLUMN published TO published_at; ALTER TABLE mod_ban RENAME COLUMN expires TO expires_at; ALTER TABLE mod_ban_from_community RENAME COLUMN published TO published_at; ALTER TABLE mod_ban_from_community RENAME COLUMN expires TO expires_at; ALTER TABLE mod_change_community_visibility RENAME COLUMN published TO published_at; ALTER TABLE mod_feature_post RENAME COLUMN published TO published_at; ALTER TABLE mod_lock_post RENAME COLUMN published TO published_at; ALTER TABLE mod_remove_comment RENAME COLUMN published TO published_at; ALTER TABLE mod_remove_community RENAME COLUMN published TO published_at; ALTER TABLE mod_remove_post RENAME COLUMN published TO published_at; ALTER TABLE mod_transfer_community RENAME COLUMN published TO published_at; ALTER TABLE modlog_combined RENAME COLUMN published TO published_at; ALTER TABLE oauth_account RENAME COLUMN published TO published_at; ALTER TABLE oauth_account RENAME COLUMN updated TO updated_at; ALTER TABLE oauth_provider RENAME COLUMN published TO published_at; ALTER TABLE oauth_provider RENAME COLUMN updated TO updated_at; ALTER TABLE password_reset_request RENAME COLUMN published TO published_at; ALTER TABLE person RENAME COLUMN published TO published_at; ALTER TABLE person RENAME COLUMN updated TO updated_at; ALTER TABLE person_actions RENAME COLUMN followed TO followed_at; ALTER TABLE person_actions RENAME COLUMN blocked TO blocked_at; ALTER TABLE person_ban RENAME COLUMN published TO published_at; ALTER TABLE person_comment_mention RENAME COLUMN published TO published_at; ALTER TABLE person_content_combined RENAME COLUMN published TO published_at; ALTER TABLE person_liked_combined RENAME COLUMN liked TO voted_at; ALTER TABLE person_post_mention RENAME COLUMN published TO published_at; ALTER TABLE person_saved_combined RENAME COLUMN saved TO saved_at; ALTER TABLE post RENAME COLUMN published TO published_at; ALTER TABLE post RENAME COLUMN updated TO updated_at; ALTER TABLE post RENAME COLUMN scheduled_publish_time TO scheduled_publish_time_at; ALTER TABLE post RENAME COLUMN newest_comment_time TO newest_comment_time_at; ALTER TABLE post RENAME COLUMN newest_comment_time_necro TO newest_comment_time_necro_at; ALTER TABLE post_actions RENAME COLUMN read TO read_at; ALTER TABLE post_actions RENAME COLUMN read_comments TO read_comments_at; ALTER TABLE post_actions RENAME COLUMN saved TO saved_at; ALTER TABLE post_actions RENAME COLUMN liked TO voted_at; ALTER TABLE post_actions RENAME COLUMN hidden TO hidden_at; ALTER TABLE post_report RENAME COLUMN published TO published_at; ALTER TABLE post_report RENAME COLUMN updated TO updated_at; ALTER TABLE post_tag RENAME COLUMN published TO published_at; ALTER TABLE private_message RENAME COLUMN published TO published_at; ALTER TABLE private_message RENAME COLUMN updated TO updated_at; ALTER TABLE private_message_report RENAME COLUMN published TO published_at; ALTER TABLE private_message_report RENAME COLUMN updated TO updated_at; ALTER TABLE received_activity RENAME COLUMN published TO published_at; ALTER TABLE registration_application RENAME COLUMN published TO published_at; ALTER TABLE remote_image RENAME COLUMN published TO published_at; ALTER TABLE report_combined RENAME COLUMN published TO published_at; ALTER TABLE search_combined RENAME COLUMN published TO published_at; ALTER TABLE sent_activity RENAME COLUMN published TO published_at; ALTER TABLE site RENAME COLUMN published TO published_at; ALTER TABLE site RENAME COLUMN updated TO updated_at; ALTER TABLE tag RENAME COLUMN published TO published_at; ALTER TABLE tag RENAME COLUMN updated TO updated_at; ALTER TABLE tagline RENAME COLUMN published TO published_at; ALTER TABLE tagline RENAME COLUMN updated TO updated_at; ================================================ FILE: migrations/2025-08-01-000056_person_note/down.sql ================================================ ALTER TABLE person_actions DROP COLUMN noted_at, DROP COLUMN note; ================================================ FILE: migrations/2025-08-01-000056_person_note/up.sql ================================================ ALTER TABLE person_actions ADD COLUMN noted_at timestamptz, ADD COLUMN note text; ================================================ FILE: migrations/2025-08-01-000057_multi-community/down.sql ================================================ ALTER TABLE search_combined DROP CONSTRAINT search_combined_check; ALTER TABLE search_combined ADD CONSTRAINT search_combined_check CHECK (num_nonnulls (post_id, comment_id, community_id, person_id) = 1); ALTER TABLE search_combined DROP COLUMN multi_community_id; ALTER TABLE local_site DROP COLUMN suggested_communities; DROP TABLE multi_community_follow; DROP TABLE multi_community_entry; DROP TABLE multi_community; CREATE TYPE listing_type_enum_tmp AS ENUM ( 'All', 'Local', 'Subscribed', 'ModeratorView' ); UPDATE local_user SET default_listing_type = 'All' WHERE default_listing_type = 'Suggested'; UPDATE local_site SET default_post_listing_type = 'All' WHERE default_post_listing_type = 'Suggested'; ALTER TABLE local_user ALTER COLUMN default_listing_type DROP DEFAULT, ALTER COLUMN default_listing_type TYPE listing_type_enum_tmp USING (default_listing_type::text::listing_type_enum_tmp), ALTER COLUMN default_listing_type SET DEFAULT 'Local'; ALTER TABLE local_site ALTER COLUMN default_post_listing_type DROP DEFAULT, ALTER COLUMN default_post_listing_type TYPE listing_type_enum_tmp USING (default_post_listing_type::text::listing_type_enum_tmp), ALTER COLUMN default_post_listing_type SET DEFAULT 'Local', DROP COLUMN system_account; DROP TYPE listing_type_enum; ALTER TYPE listing_type_enum_tmp RENAME TO listing_type_enum; ================================================ FILE: migrations/2025-08-01-000057_multi-community/up.sql ================================================ CREATE TABLE multi_community ( id serial PRIMARY KEY, creator_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, instance_id int NOT NULL REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE, name varchar(255) NOT NULL, title varchar(255), description varchar(255), local bool NOT NULL DEFAULT TRUE, deleted bool NOT NULL DEFAULT FALSE, ap_id text UNIQUE NOT NULL DEFAULT generate_unique_changeme (), public_key text NOT NULL, private_key text, inbox_url text NOT NULL DEFAULT generate_unique_changeme (), last_refreshed_at timestamptz NOT NULL DEFAULT now(), following_url text NOT NULL DEFAULT generate_unique_changeme (), published_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz ); CREATE TABLE multi_community_entry ( multi_community_id int NOT NULL REFERENCES multi_community ON UPDATE CASCADE ON DELETE CASCADE, community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (multi_community_id, community_id) ); CREATE TABLE multi_community_follow ( multi_community_id int NOT NULL REFERENCES multi_community ON UPDATE CASCADE ON DELETE CASCADE, person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, follow_state community_follower_state NOT NULL, PRIMARY KEY (person_id, multi_community_id) ); ALTER TABLE local_site ADD COLUMN suggested_communities int REFERENCES multi_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN system_account int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE; -- generate new account with randomized name (max 20 chars) and set it -- as local_site.system_account WITH x AS ( INSERT INTO person (name, public_key, private_key, instance_id, inbox_url, bot_account) SELECT 'lemmy_' || substr(gen_random_uuid ()::text, 0, 14), public_key, private_key, instance_id, inbox_url, TRUE FROM site, local_site WHERE site.id = local_site.id RETURNING person.id) UPDATE local_site SET system_account = x.id FROM x; ALTER TABLE local_site ALTER COLUMN system_account SET NOT NULL; -- set ap_id for system_account (should use r.local_url but thats not defined here) UPDATE person SET ap_id = current_setting('lemmy.protocol_and_hostname') || '/u/' || person.name FROM local_site WHERE person.id = local_site.system_account; ALTER TYPE listing_type_enum ADD VALUE 'Suggested'; CREATE INDEX idx_multi_community_read_from_name ON multi_community (local) WHERE local AND NOT deleted; CREATE INDEX idx_multi_community_ap_id ON multi_community (ap_id); CREATE INDEX idx_multi_creator_id ON multi_community (creator_id); CREATE INDEX idx_multi_community_follow_multi_id ON multi_community_follow (multi_community_id); CREATE INDEX idx_multi_community_entry_community_id ON multi_community_entry (community_id); ALTER TABLE search_combined ADD COLUMN multi_community_id int REFERENCES multi_community (id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE search_combined DROP CONSTRAINT search_combined_check; ALTER TABLE search_combined ADD CONSTRAINT search_combined_check CHECK (num_nonnulls (post_id, comment_id, community_id, person_id, multi_community_id) = 1); ================================================ FILE: migrations/2025-08-01-000058_instance_block_communities_persons/down.sql ================================================ ALTER TABLE instance_actions RENAME COLUMN blocked_communities_at TO blocked_at; ALTER TABLE instance_actions DROP COLUMN blocked_persons_at; ================================================ FILE: migrations/2025-08-01-000058_instance_block_communities_persons/up.sql ================================================ -- Currently, the instance.blocked_at columns only blocks communities from the given instance. -- -- This creates a new block type, to also be able to block persons. -- Also changes the name of blocked_at to blocked_communities_at ALTER TABLE instance_actions RENAME COLUMN blocked_at TO blocked_communities_at; ALTER TABLE instance_actions ADD COLUMN blocked_persons_at timestamptz; ================================================ FILE: migrations/2025-08-01-000059_person_votes/down.sql ================================================ ALTER TABLE person_actions DROP COLUMN voted_at, DROP COLUMN upvotes, DROP COLUMN downvotes; ALTER TABLE local_user DROP COLUMN show_person_votes; ================================================ FILE: migrations/2025-08-01-000059_person_votes/up.sql ================================================ ALTER TABLE person_actions ADD COLUMN voted_at timestamptz, ADD COLUMN upvotes int, ADD COLUMN downvotes int; ALTER TABLE local_user ADD COLUMN show_person_votes boolean NOT NULL DEFAULT TRUE; -- Disable the triggers temporarily ALTER TABLE person_actions DISABLE TRIGGER ALL; -- Adding vote history -- This union alls the comment and post actions tables, -- inner joins to local_user for the above to filter out non-locals -- separates the like_score into upvote and downvote columns, -- groups and sums the upvotes and downvotes, -- handles conflicts using the `excluded` magic column. INSERT INTO person_actions (person_id, target_id, voted_at, upvotes, downvotes) SELECT votes.person_id, votes.creator_id, now(), count(*) FILTER (WHERE votes.vote_is_upvote) AS upvotes, count(*) FILTER (WHERE NOT votes.vote_is_upvote) AS downvotes FROM ( SELECT pa.person_id, p.creator_id, vote_is_upvote FROM post_actions pa INNER JOIN post p ON pa.post_id = p.id AND p.local UNION ALL SELECT ca.person_id, c.creator_id, vote_is_upvote FROM comment_actions ca INNER JOIN comment c ON ca.comment_id = c.id AND c.local) AS votes GROUP BY votes.person_id, votes.creator_id ON CONFLICT (person_id, target_id) DO UPDATE SET voted_at = now(), upvotes = excluded.upvotes, downvotes = excluded.downvotes; -- Re-enable the triggers ALTER TABLE person_actions ENABLE TRIGGER ALL; ================================================ FILE: migrations/2025-08-01-000060_rename-rate-limit-columns/down.sql ================================================ ALTER TABLE local_site_rate_limit RENAME COLUMN message_max_requests TO message; ALTER TABLE local_site_rate_limit RENAME COLUMN message_interval_seconds TO message_per_second; ALTER TABLE local_site_rate_limit RENAME COLUMN post_max_requests TO post; ALTER TABLE local_site_rate_limit RENAME COLUMN post_interval_seconds TO post_per_second; ALTER TABLE local_site_rate_limit RENAME COLUMN comment_max_requests TO comment; ALTER TABLE local_site_rate_limit RENAME COLUMN comment_interval_seconds TO comment_per_second; ALTER TABLE local_site_rate_limit RENAME COLUMN register_max_requests TO register; ALTER TABLE local_site_rate_limit RENAME COLUMN register_interval_seconds TO register_per_second; ALTER TABLE local_site_rate_limit RENAME COLUMN image_max_requests TO image; ALTER TABLE local_site_rate_limit RENAME COLUMN image_interval_seconds TO image_per_second; ALTER TABLE local_site_rate_limit RENAME COLUMN search_max_requests TO search; ALTER TABLE local_site_rate_limit RENAME COLUMN search_interval_seconds TO search_per_second; ALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings_max_requests TO import_user_settings; ALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings_interval_seconds TO import_user_settings_per_second; ================================================ FILE: migrations/2025-08-01-000060_rename-rate-limit-columns/up.sql ================================================ ALTER TABLE local_site_rate_limit RENAME COLUMN message TO message_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN message_per_second TO message_interval_seconds; ALTER TABLE local_site_rate_limit RENAME COLUMN post TO post_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN post_per_second TO post_interval_seconds; ALTER TABLE local_site_rate_limit RENAME COLUMN comment TO comment_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN comment_per_second TO comment_interval_seconds; ALTER TABLE local_site_rate_limit RENAME COLUMN register TO register_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN register_per_second TO register_interval_seconds; ALTER TABLE local_site_rate_limit RENAME COLUMN image TO image_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN image_per_second TO image_interval_seconds; ALTER TABLE local_site_rate_limit RENAME COLUMN search TO search_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN search_per_second TO search_interval_seconds; ALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings TO import_user_settings_max_requests; ALTER TABLE local_site_rate_limit RENAME COLUMN import_user_settings_per_second TO import_user_settings_interval_seconds; ================================================ FILE: migrations/2025-08-01-000061_drop-person-ban/down.sql ================================================ CREATE TABLE person_ban ( person_id integer REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamptz DEFAULT now(), CONSTRAINT user_ban_user_id_not_null NOT NULL person_id, CONSTRAINT user_ban_published_not_null NOT NULL published_at, PRIMARY KEY (person_id) ); ================================================ FILE: migrations/2025-08-01-000061_drop-person-ban/up.sql ================================================ DROP TABLE person_ban; ================================================ FILE: migrations/2025-08-01-000062_username-instance-unique/down.sql ================================================ ALTER TABLE person DROP CONSTRAINT person_name_instance_unique; ================================================ FILE: migrations/2025-08-01-000062_username-instance-unique/up.sql ================================================ -- lemmy requires (username + instance_id) to be unique -- delete any existing duplicates DELETE FROM person p1 USING ( SELECT min(id) AS id, name, instance_id FROM person GROUP BY name, instance_id HAVING count(*) > 1) p2 WHERE p1.name = p2.name AND p1.instance_id = p2.instance_id AND p1.id <> p2.id; ALTER TABLE person ADD CONSTRAINT person_name_instance_unique UNIQUE (name, instance_id); ================================================ FILE: migrations/2025-08-01-000063_post-or-comment-notification/down.sql ================================================ CREATE TABLE person_post_mention ( id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read bool NOT NULL DEFAULT FALSE, published_at timestamptz DEFAULT now(), CONSTRAINT person_post_mention_published_not_null NOT NULL published_at ); CREATE TABLE person_mention ( id serial, recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE, comment_id int REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE, read bool DEFAULT FALSE, published_at timestamptz DEFAULT now(), CONSTRAINT user_mention_id_not_null NOT NULL id, CONSTRAINT user_mention_comment_id_not_null NOT NULL comment_id, CONSTRAINT user_mention_recipient_id_not_null NOT NULL recipient_id, CONSTRAINT user_mention_published_not_null NOT NULL published_at, CONSTRAINT user_mention_read_not_null NOT NULL read, PRIMARY KEY (id), UNIQUE (recipient_id, comment_id) ); ALTER TABLE person_mention RENAME TO person_comment_mention; CREATE TABLE comment_reply ( id serial, recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE, comment_id int REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, read bool DEFAULT FALSE, published_at timestamptz DEFAULT now(), CONSTRAINT comment_reply_published_not_null NOT NULL published_at, UNIQUE (recipient_id, comment_id) ); CREATE TABLE inbox_combined ( id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- TODO add this foreign key constraint later comment_reply_id int, person_comment_mention_id int REFERENCES person_comment_mention (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, person_post_mention_id int REFERENCES person_post_mention (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, private_message_id int REFERENCES private_message (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, published_at timestamptz, CONSTRAINT inbox_combined_published_not_null NOT NULL published_at ); ALTER TABLE private_message ADD COLUMN read bool DEFAULT FALSE NOT NULL; -- copy back data to person_post_mention table INSERT INTO person_post_mention (recipient_id, post_id, read, published_at) SELECT recipient_id, post_id, read, published_at FROM notification WHERE kind = 'Mention' AND post_id IS NOT NULL; INSERT INTO inbox_combined (person_post_mention_id, published_at) SELECT id, published_at FROM person_post_mention; -- copy back data to person_comment_mention table INSERT INTO person_comment_mention (recipient_id, comment_id, read, published_at) SELECT recipient_id, comment_id, read, published_at FROM notification WHERE kind = 'Mention' AND comment_id IS NOT NULL; -- copy back data to person_comment_mention table UPDATE private_message p SET read = n.read FROM notification n WHERE p.id = n.private_message_id; INSERT INTO inbox_combined (person_comment_mention_id, published_at) SELECT id, published_at FROM person_comment_mention; -- copy back data to comment_reply table INSERT INTO comment_reply (recipient_id, comment_id, read, published_at) SELECT recipient_id, comment_id, read, published_at FROM notification WHERE kind = 'Reply' AND comment_id IS NOT NULL; INSERT INTO inbox_combined (comment_reply_id, published_at) SELECT id, published_at FROM comment_reply; ALTER TABLE ONLY person_post_mention ADD CONSTRAINT person_post_mention_recipient_id_post_id_key UNIQUE (recipient_id, post_id); CREATE INDEX idx_comment_reply_comment ON comment_reply USING btree (comment_id); CREATE INDEX idx_comment_reply_recipient ON comment_reply USING btree (recipient_id); CREATE INDEX idx_comment_reply_published ON comment_reply USING btree (published_at DESC); CREATE INDEX idx_inbox_combined_published_asc ON inbox_combined USING btree (reverse_timestamp_sort (published_at) DESC, id DESC); CREATE INDEX idx_inbox_combined_published ON inbox_combined USING btree (published_at DESC, id DESC); DROP TABLE notification; DROP TYPE notification_type_enum; ALTER TABLE community_actions DROP COLUMN notifications; DROP TYPE community_notifications_mode_enum; ALTER TABLE post_actions DROP COLUMN notifications; DROP TYPE post_notifications_mode_enum; ALTER TABLE comment_reply DROP CONSTRAINT comment_reply_id_not_null1, ALTER COLUMN id SET NOT NULL, ADD PRIMARY KEY (id); ALTER TABLE comment_reply ALTER COLUMN recipient_id SET NOT NULL, ALTER COLUMN read SET NOT NULL; ALTER TABLE inbox_combined ADD CONSTRAINT inbox_combined_comment_reply_id_fkey FOREIGN KEY (comment_reply_id) REFERENCES comment_reply (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT inbox_combined_comment_reply_id_key UNIQUE (comment_reply_id), ADD CONSTRAINT inbox_combined_check CHECK (num_nonnulls (comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) = 1); ================================================ FILE: migrations/2025-08-01-000063_post-or-comment-notification/up.sql ================================================ -- create new data types CREATE TYPE notification_type_enum AS enum ( 'Mention', 'Reply', 'Subscribed', 'PrivateMessage' ); -- create notification table by renaming comment_reply, to avoid copying lots of data around ALTER TABLE comment_reply RENAME TO notification; ALTER INDEX idx_comment_reply_comment RENAME TO idx_notification_comment; ALTER INDEX idx_comment_reply_recipient RENAME TO idx_notification_recipient; ALTER INDEX idx_comment_reply_published RENAME TO idx_notification_published; ALTER SEQUENCE comment_reply_id_seq RENAME TO notification_id_seq; ALTER TABLE notification RENAME CONSTRAINT comment_reply_comment_id_fkey TO notification_comment_id_fkey; ALTER TABLE notification RENAME CONSTRAINT comment_reply_pkey TO notification_pkey; ALTER TABLE notification DROP CONSTRAINT comment_reply_recipient_id_comment_id_key; ALTER TABLE notification RENAME CONSTRAINT comment_reply_recipient_id_fkey TO notification_recipient_id_fkey; ALTER TABLE notification ADD COLUMN kind notification_type_enum NOT NULL DEFAULT 'Reply', ALTER COLUMN comment_id DROP NOT NULL, ADD COLUMN post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN private_message_id int REFERENCES private_message (id) ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE notification ALTER COLUMN kind DROP DEFAULT; -- copy data from person_post_mention table INSERT INTO notification (post_id, recipient_id, kind, read, published_at) SELECT post_id, recipient_id, 'Mention', read, published_at FROM person_post_mention; -- copy data from person_comment_mention table INSERT INTO notification (comment_id, recipient_id, kind, read, published_at) SELECT comment_id, recipient_id, 'Mention', read, published_at FROM person_comment_mention; -- copy data from private_message table INSERT INTO notification (private_message_id, recipient_id, kind, read, published_at) SELECT id, recipient_id, 'PrivateMessage', read, published_at FROM private_message; ALTER TABLE private_message DROP COLUMN read; ALTER TABLE notification ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id) = 1); CREATE INDEX idx_notification_recipient_published ON notification (recipient_id, published_at); CREATE INDEX idx_notification_post ON notification (post_id) WHERE post_id IS NOT NULL; CREATE INDEX idx_notification_private_message ON notification (private_message_id) WHERE private_message_id IS NOT NULL; DROP TABLE inbox_combined, person_post_mention, person_comment_mention; CREATE TYPE post_notifications_mode_enum AS enum ( 'AllComments', 'RepliesAndMentions', 'Mute' ); ALTER TABLE post_actions ADD COLUMN notifications post_notifications_mode_enum; CREATE TYPE community_notifications_mode_enum AS enum ( 'AllPostsAndComments', 'AllPosts', 'RepliesAndMentions', 'Mute' ); ALTER TABLE community_actions ADD COLUMN notifications community_notifications_mode_enum; ================================================ FILE: migrations/2025-08-01-000064_add_missing_foreign_key_indexes/down.sql ================================================ DROP INDEX idx_registration_application_admin, idx_admin_allow_instance_admin, idx_admin_block_instance_admin, idx_admin_purge_comment_admin, idx_admin_purge_community_admin, idx_admin_purge_person_admin, idx_admin_purge_post_admin, idx_mod_remove_comment_comment, idx_person_liked_combined_comment, idx_person_saved_combined_comment, idx_comment_report_creator, idx_community_report_creator, idx_post_report_creator, idx_private_message_creator, idx_private_message_report_creator, idx_admin_purge_post_community, idx_mod_add_community_community, idx_mod_ban_from_community_community, idx_mod_change_community_visibility_community, idx_mod_remove_community_community, idx_mod_transfer_community_community, idx_tag_community, idx_community_actions_follow_approver, idx_admin_allow_instance_instance, idx_admin_block_instance_instance, idx_community_instance, idx_mod_ban_instance, idx_multi_community_instance, idx_person_instance, idx_community_language_language, idx_local_user_language_language, idx_site_language_language, idx_email_verification_user, idx_oauth_account_user, idx_password_reset_request_user, idx_modlog_combined_mod_change_community_visibility_id, idx_mod_add_community_mod, idx_mod_add_mod, idx_mod_ban_from_community_mod, idx_mod_ban_mod, idx_mod_change_community_visibility_mod, idx_mod_feature_post_mod, idx_mod_lock_post_mod, idx_mod_remove_comment_mod, idx_mod_remove_community_mod, idx_mod_remove_post_mod, idx_mod_transfer_community_mod, idx_local_site_system_account, idx_search_combined_multi_community, idx_mod_add_community_other_person, idx_mod_add_other_person, idx_mod_ban_from_community_other_person, idx_mod_other_person, idx_mod_transfer_community_other_person, idx_admin_purge_comment_post, idx_mod_feature_post_post, idx_mod_lock_post_post, idx_mod_remove_post_post, idx_person_liked_combined_post, idx_person_saved_combined_post, idx_private_message_recipient, idx_comment_report_resolver, idx_community_report_resolver, idx_post_report_resolver, idx_private_message_report_resolver, idx_local_site_suggested_communities, idx_post_tag_tag, idx_local_image_thumbnail_post; ================================================ FILE: migrations/2025-08-01-000064_add_missing_foreign_key_indexes/up.sql ================================================ CREATE INDEX idx_registration_application_admin ON registration_application (admin_id); CREATE INDEX idx_admin_allow_instance_admin ON admin_allow_instance (admin_person_id); CREATE INDEX idx_admin_block_instance_admin ON admin_block_instance (admin_person_id); CREATE INDEX idx_admin_purge_comment_admin ON admin_purge_comment (admin_person_id); CREATE INDEX idx_admin_purge_community_admin ON admin_purge_community (admin_person_id); CREATE INDEX idx_admin_purge_person_admin ON admin_purge_person (admin_person_id); CREATE INDEX idx_admin_purge_post_admin ON admin_purge_post (admin_person_id); CREATE INDEX idx_mod_remove_comment_comment ON mod_remove_comment (comment_id); CREATE INDEX idx_person_liked_combined_comment ON person_liked_combined (comment_id) WHERE comment_id IS NOT NULL; CREATE INDEX idx_person_saved_combined_comment ON person_saved_combined (comment_id) WHERE comment_id IS NOT NULL; CREATE INDEX idx_comment_report_creator ON comment_report (creator_id); CREATE INDEX idx_community_report_creator ON community_report (creator_id); CREATE INDEX idx_post_report_creator ON post_report (creator_id); CREATE INDEX idx_private_message_creator ON private_message (creator_id); CREATE INDEX idx_private_message_report_creator ON private_message_report (creator_id); CREATE INDEX idx_admin_purge_post_community ON admin_purge_post (community_id); CREATE INDEX idx_mod_add_community_community ON mod_add_community (community_id); CREATE INDEX idx_mod_ban_from_community_community ON mod_ban_from_community (community_id); CREATE INDEX idx_mod_change_community_visibility_community ON mod_change_community_visibility (community_id); CREATE INDEX idx_mod_remove_community_community ON mod_remove_community (community_id); CREATE INDEX idx_mod_transfer_community_community ON mod_transfer_community (community_id); CREATE INDEX idx_tag_community ON tag (community_id); CREATE INDEX idx_community_actions_follow_approver ON community_actions (follow_approver_id); CREATE INDEX idx_admin_allow_instance_instance ON admin_allow_instance (instance_id); CREATE INDEX idx_admin_block_instance_instance ON admin_block_instance (instance_id); CREATE INDEX idx_community_instance ON community (instance_id); CREATE INDEX idx_mod_ban_instance ON mod_ban (instance_id); CREATE INDEX idx_multi_community_instance ON multi_community (instance_id); CREATE INDEX idx_person_instance ON person (instance_id); CREATE INDEX idx_community_language_language ON community_language (language_id); CREATE INDEX idx_local_user_language_language ON local_user_language (language_id); CREATE INDEX idx_site_language_language ON site_language (language_id); CREATE INDEX idx_email_verification_user ON email_verification (local_user_id); CREATE INDEX idx_oauth_account_user ON oauth_account (local_user_id); CREATE INDEX idx_password_reset_request_user ON password_reset_request (local_user_id); CREATE INDEX idx_modlog_combined_mod_change_community_visibility_id ON modlog_combined (mod_change_community_visibility_id) WHERE mod_change_community_visibility_id IS NOT NULL; CREATE INDEX idx_mod_add_community_mod ON mod_add_community (mod_person_id); CREATE INDEX idx_mod_add_mod ON mod_add (mod_person_id); CREATE INDEX idx_mod_ban_from_community_mod ON mod_ban_from_community (mod_person_id); CREATE INDEX idx_mod_ban_mod ON mod_ban (mod_person_id); CREATE INDEX idx_mod_change_community_visibility_mod ON mod_change_community_visibility (mod_person_id); CREATE INDEX idx_mod_feature_post_mod ON mod_feature_post (mod_person_id); CREATE INDEX idx_mod_lock_post_mod ON mod_lock_post (mod_person_id); CREATE INDEX idx_mod_remove_comment_mod ON mod_remove_comment (mod_person_id); CREATE INDEX idx_mod_remove_community_mod ON mod_remove_community (mod_person_id); CREATE INDEX idx_mod_remove_post_mod ON mod_remove_post (mod_person_id); CREATE INDEX idx_mod_transfer_community_mod ON mod_transfer_community (mod_person_id); CREATE INDEX idx_local_site_system_account ON local_site (system_account); CREATE INDEX idx_search_combined_multi_community ON search_combined (multi_community_id) WHERE multi_community_id IS NOT NULL; CREATE INDEX idx_mod_add_community_other_person ON mod_add_community (other_person_id); CREATE INDEX idx_mod_add_other_person ON mod_add (other_person_id); CREATE INDEX idx_mod_ban_from_community_other_person ON mod_ban_from_community (other_person_id); CREATE INDEX idx_mod_other_person ON mod_ban (other_person_id); CREATE INDEX idx_mod_transfer_community_other_person ON mod_transfer_community (other_person_id); CREATE INDEX idx_admin_purge_comment_post ON admin_purge_comment (post_id); CREATE INDEX idx_mod_feature_post_post ON mod_feature_post (post_id); CREATE INDEX idx_mod_lock_post_post ON mod_lock_post (post_id); CREATE INDEX idx_mod_remove_post_post ON mod_remove_post (post_id); CREATE INDEX idx_person_liked_combined_post ON person_liked_combined (post_id) WHERE post_id IS NOT NULL; CREATE INDEX idx_person_saved_combined_post ON person_saved_combined (post_id) WHERE post_id IS NOT NULL; CREATE INDEX idx_private_message_recipient ON private_message (recipient_id); CREATE INDEX idx_comment_report_resolver ON comment_report (resolver_id); CREATE INDEX idx_community_report_resolver ON community_report (resolver_id); CREATE INDEX idx_post_report_resolver ON post_report (resolver_id); CREATE INDEX idx_private_message_report_resolver ON private_message_report (resolver_id); CREATE INDEX idx_local_site_suggested_communities ON local_site (suggested_communities); CREATE INDEX idx_post_tag_tag ON post_tag (tag_id); CREATE INDEX idx_local_image_thumbnail_post ON local_image (thumbnail_for_post_id); ================================================ FILE: migrations/2025-08-01-000065_group-follow/down.sql ================================================ DROP TABLE community_community_follow; ================================================ FILE: migrations/2025-08-01-000065_group-follow/up.sql ================================================ CREATE TABLE community_community_follow ( target_id int REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, community_id int REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, published_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (community_id, target_id) ); CREATE INDEX idx_community_community_follow_target ON community_community_follow (target_id); ================================================ FILE: migrations/2025-08-01-000066_modlog-rename/down.sql ================================================ ALTER TABLE admin_ban RENAME TO mod_ban; ALTER TABLE admin_add RENAME TO mod_add; ALTER TABLE admin_remove_community RENAME TO mod_remove_community; ALTER TABLE mod_add_to_community RENAME TO mod_add_community; ALTER TABLE modlog_combined RENAME COLUMN admin_ban_id TO mod_ban_id; ALTER TABLE modlog_combined RENAME COLUMN admin_add_id TO mod_add_id; ALTER TABLE modlog_combined RENAME COLUMN admin_remove_community_id TO mod_remove_community_id; ALTER TABLE modlog_combined RENAME COLUMN mod_add_to_community_id TO mod_add_community_id; ================================================ FILE: migrations/2025-08-01-000066_modlog-rename/up.sql ================================================ ALTER TABLE mod_ban RENAME TO admin_ban; ALTER TABLE mod_add RENAME TO admin_add; ALTER TABLE mod_remove_community RENAME TO admin_remove_community; ALTER TABLE mod_add_community RENAME TO mod_add_to_community; ALTER TABLE modlog_combined RENAME COLUMN mod_ban_id TO admin_ban_id; ALTER TABLE modlog_combined RENAME COLUMN mod_add_id TO admin_add_id; ALTER TABLE modlog_combined RENAME COLUMN mod_remove_community_id TO admin_remove_community_id; ALTER TABLE modlog_combined RENAME COLUMN mod_add_community_id TO mod_add_to_community_id; ================================================ FILE: migrations/2025-08-01-000067_add_default_items_per_page/down.sql ================================================ -- Drop the new columns ALTER TABLE local_user DROP COLUMN default_items_per_page; ALTER TABLE local_site DROP COLUMN default_items_per_page; ================================================ FILE: migrations/2025-08-01-000067_add_default_items_per_page/up.sql ================================================ -- Adds an optional default fetch limit (IE fetch a certain number of posts) to local_user and local_site ALTER TABLE local_user ADD COLUMN default_items_per_page integer NOT NULL DEFAULT 20; ALTER TABLE local_site ADD COLUMN default_items_per_page integer NOT NULL DEFAULT 20; ================================================ FILE: migrations/2025-08-01-000068_local_user_trigger/down.sql ================================================ UPDATE local_site SET users = ( SELECT count(*) FROM local_user); ================================================ FILE: migrations/2025-08-01-000068_local_user_trigger/up.sql ================================================ UPDATE local_site SET users = ( SELECT count(*) FROM local_user WHERE accepted_application); ================================================ FILE: migrations/2025-08-06-170325_add_indexes_for_aggregates_activity_new/down.sql ================================================ DROP INDEX idx_post_actions_voted_at, idx_comment_actions_voted_at; ================================================ FILE: migrations/2025-08-06-170325_add_indexes_for_aggregates_activity_new/up.sql ================================================ CREATE INDEX idx_post_actions_voted_at ON post_actions (voted_at) WHERE voted_at IS NOT NULL; CREATE INDEX idx_comment_actions_voted_at ON comment_actions (voted_at) WHERE voted_at IS NOT NULL; ================================================ FILE: migrations/2025-08-20-000000_comment-lock/down.sql ================================================ ALTER TABLE modlog_combined DROP COLUMN mod_lock_comment_id, ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, admin_remove_community_id, mod_remove_post_id, mod_transfer_community_id) = 1); DROP TABLE mod_lock_comment; ALTER TABLE comment DROP COLUMN LOCKED; ================================================ FILE: migrations/2025-08-20-000000_comment-lock/up.sql ================================================ ALTER TABLE comment ADD COLUMN "locked" bool NOT NULL DEFAULT FALSE; CREATE TABLE mod_lock_comment ( id serial PRIMARY KEY, mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, comment_id integer NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, locked boolean NOT NULL DEFAULT TRUE, reason text, published_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_mod_lock_comment_mod ON mod_lock_comment (mod_person_id); CREATE INDEX idx_mod_lock_comment_comment ON mod_lock_comment (comment_id); ALTER TABLE modlog_combined ADD COLUMN mod_lock_comment_id integer UNIQUE REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE; ALTER TABLE modlog_combined DROP CONSTRAINT modlog_combined_check, ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, admin_remove_community_id, mod_remove_post_id, mod_transfer_community_id, mod_lock_comment_id) = 1), ALTER CONSTRAINT modlog_combined_mod_lock_comment_id_fkey NOT DEFERRABLE; ================================================ FILE: migrations/2025-09-01-141127_local-community-collections/down.sql ================================================ UPDATE community SET moderators_url = NULL, featured_url = NULL WHERE local; ================================================ FILE: migrations/2025-09-01-141127_local-community-collections/up.sql ================================================ UPDATE community c1 SET moderators_url = trim(TRAILING '/' FROM c2.ap_id) || '/moderators', featured_url = trim(TRAILING '/' FROM c2.ap_id) || '/featured' FROM community c2 WHERE c1.local AND c1.id = c2.id; ================================================ FILE: migrations/2025-09-08-000001_add-video-dimensions/down.sql ================================================ ALTER TABLE post DROP COLUMN embed_video_width, DROP COLUMN embed_video_height; ================================================ FILE: migrations/2025-09-08-000001_add-video-dimensions/up.sql ================================================ ALTER TABLE post ADD COLUMN embed_video_width integer, ADD COLUMN embed_video_height integer; ================================================ FILE: migrations/2025-09-08-140711_remove-actor-name-max-length/down.sql ================================================ ALTER TABLE local_site ADD COLUMN actor_name_max_length int DEFAULT 20 NOT NULL; ALTER TABLE person ALTER COLUMN display_name TYPE varchar(255); ALTER TABLE community ALTER COLUMN title TYPE varchar(255); ================================================ FILE: migrations/2025-09-08-140711_remove-actor-name-max-length/up.sql ================================================ -- get rid of max name length setting ALTER TABLE local_site DROP COLUMN actor_name_max_length; -- truncate existing strings UPDATE person SET display_name = substring(display_name FROM 1 FOR 50) WHERE length(display_name) > 50; UPDATE community SET title = substring(title FROM 1 FOR 50) WHERE length(title) > 50; -- reduce max length of db columns ALTER TABLE person ALTER COLUMN display_name TYPE varchar(50); ALTER TABLE community ALTER COLUMN title TYPE varchar(50); ================================================ FILE: migrations/2025-09-12-093537_mod-reason-mandatory/down.sql ================================================ ALTER TABLE admin_allow_instance ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_ban ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_block_instance ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_purge_comment ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_purge_community ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_purge_person ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_purge_post ALTER COLUMN reason DROP NOT NULL; ALTER TABLE admin_remove_community ALTER COLUMN reason DROP NOT NULL; ALTER TABLE mod_ban_from_community ALTER COLUMN reason DROP NOT NULL; ALTER TABLE mod_lock_comment ALTER COLUMN reason DROP NOT NULL; ALTER TABLE mod_lock_post ALTER COLUMN reason DROP NOT NULL; ALTER TABLE mod_remove_comment ALTER COLUMN reason DROP NOT NULL; ALTER TABLE mod_remove_post ALTER COLUMN reason DROP NOT NULL; ================================================ FILE: migrations/2025-09-12-093537_mod-reason-mandatory/up.sql ================================================ -- provide default value for null rows UPDATE admin_allow_instance SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_ban SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_block_instance SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_purge_comment SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_purge_community SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_purge_person SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_purge_post SET reason = 'No reason given' WHERE reason IS NULL; UPDATE admin_remove_community SET reason = 'No reason given' WHERE reason IS NULL; UPDATE mod_ban_from_community SET reason = 'No reason given' WHERE reason IS NULL; UPDATE mod_lock_comment SET reason = 'No reason given' WHERE reason IS NULL; UPDATE mod_lock_post SET reason = 'No reason given' WHERE reason IS NULL; UPDATE mod_remove_comment SET reason = 'No reason given' WHERE reason IS NULL; UPDATE mod_remove_post SET reason = 'No reason given' WHERE reason IS NULL; -- set not null ALTER TABLE admin_allow_instance ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_ban ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_block_instance ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_purge_comment ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_purge_community ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_purge_person ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_purge_post ALTER COLUMN reason SET NOT NULL; ALTER TABLE admin_remove_community ALTER COLUMN reason SET NOT NULL; ALTER TABLE mod_ban_from_community ALTER COLUMN reason SET NOT NULL; ALTER TABLE mod_lock_comment ALTER COLUMN reason SET NOT NULL; ALTER TABLE mod_lock_post ALTER COLUMN reason SET NOT NULL; ALTER TABLE mod_remove_comment ALTER COLUMN reason SET NOT NULL; ALTER TABLE mod_remove_post ALTER COLUMN reason SET NOT NULL; ================================================ FILE: migrations/2025-09-15-090401_remove-keyboard-nav/down.sql ================================================ ALTER TABLE local_user ADD COLUMN enable_keyboard_navigation boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2025-09-15-090401_remove-keyboard-nav/up.sql ================================================ ALTER TABLE local_user DROP COLUMN enable_keyboard_navigation; ================================================ FILE: migrations/2025-09-19-090047_notify-mod-action/down.sql ================================================ -- drop new foreign keys ALTER TABLE notification DROP COLUMN mod_remove_comment_id, DROP COLUMN admin_add_id, DROP COLUMN mod_add_to_community_id, DROP COLUMN admin_ban_id, DROP COLUMN mod_ban_from_community_id, DROP COLUMN mod_lock_post_id, DROP COLUMN admin_remove_community_id, DROP COLUMN mod_remove_post_id, DROP COLUMN mod_lock_comment_id, DROP COLUMN mod_transfer_community_id; -- revert change to notification_type enum ALTER TYPE notification_type_enum RENAME TO notification_type_enum__; DELETE FROM notification WHERE kind = 'ModAction'; CREATE TYPE notification_type_enum AS ENUM ( 'Mention', 'Reply', 'Subscribed', 'PrivateMessage' ); ALTER TABLE notification ALTER COLUMN kind TYPE notification_type_enum USING kind::text::notification_type_enum; -- revert changes to constraint ALTER TABLE notification DROP CONSTRAINT IF EXISTS notification_check; ALTER TABLE notification ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id) = 1); -- drop the old enum DROP TYPE notification_type_enum__; DROP INDEX idx_notification_unread; ================================================ FILE: migrations/2025-09-19-090047_notify-mod-action/up.sql ================================================ -- new foreign keys ALTER TABLE notification ADD COLUMN admin_add_id int REFERENCES admin_add ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_add_to_community_id int REFERENCES mod_add_to_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN admin_ban_id int REFERENCES admin_ban ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_ban_from_community_id int REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_lock_post_id int REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_remove_comment_id int REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN admin_remove_community_id int REFERENCES admin_remove_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_remove_post_id int REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_lock_comment_id int REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_transfer_community_id int REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE; -- new types for mod actions ALTER TYPE notification_type_enum ADD value 'ModAction'; -- update constraint with new columns ALTER TABLE notification DROP CONSTRAINT IF EXISTS notification_check; ALTER TABLE notification ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_lock_post_id, mod_remove_post_id, mod_lock_comment_id, mod_remove_comment_id, admin_remove_community_id, mod_transfer_community_id) = 1); -- add indexes CREATE INDEX idx_notification_unread ON notification (read); CREATE INDEX idx_notification_admin_add_id ON notification (admin_add_id) WHERE admin_add_id IS NOT NULL; CREATE INDEX idx_notification_mod_add_to_community_id ON notification (mod_add_to_community_id) WHERE mod_add_to_community_id IS NOT NULL; CREATE INDEX idx_notification_admin_ban_id ON notification (admin_ban_id) WHERE admin_ban_id IS NOT NULL; CREATE INDEX idx_notification_mod_ban_from_community_id ON notification (mod_ban_from_community_id) WHERE mod_ban_from_community_id IS NOT NULL; CREATE INDEX idx_notification_mod_lock_post_id ON notification (mod_lock_post_id) WHERE mod_lock_post_id IS NOT NULL; CREATE INDEX idx_notification_mod_remove_comment_id ON notification (mod_remove_comment_id) WHERE mod_remove_comment_id IS NOT NULL; CREATE INDEX idx_notification_admin_remove_community_id ON notification (admin_remove_community_id) WHERE admin_remove_community_id IS NOT NULL; CREATE INDEX idx_notification_mod_remove_post_id ON notification (mod_remove_post_id) WHERE mod_remove_post_id IS NOT NULL; CREATE INDEX idx_notification_mod_lock_comment_id ON notification (mod_lock_comment_id) WHERE mod_lock_comment_id IS NOT NULL; CREATE INDEX idx_notification_mod_transfer_community_id ON notification (mod_transfer_community_id) WHERE mod_transfer_community_id IS NOT NULL; ================================================ FILE: migrations/2025-09-19-132648-0000_theme-instance-default/down.sql ================================================ UPDATE local_user SET theme = 'browser' WHERE theme = 'instance'; UPDATE local_user SET theme = 'browser-compact' WHERE theme = 'instance-compact'; ================================================ FILE: migrations/2025-09-19-132648-0000_theme-instance-default/up.sql ================================================ UPDATE local_user SET theme = 'instance' WHERE theme = 'browser'; UPDATE local_user SET theme = 'instance-compact' WHERE theme = 'browser-compact'; ================================================ FILE: migrations/2025-10-08-084508-0000_multi-comm-index-lower/down.sql ================================================ DROP INDEX idx_multi_community_lower_actor_id; ================================================ FILE: migrations/2025-10-08-084508-0000_multi-comm-index-lower/up.sql ================================================ CREATE UNIQUE INDEX idx_multi_community_lower_actor_id ON multi_community (lower(ap_id)); ================================================ FILE: migrations/2025-10-09-101527-0000_community-follower-denied/down.sql ================================================ -- revert change to community follow state enum ALTER TYPE community_follower_state RENAME TO community_follower_state__; CREATE TYPE community_follower_state AS ENUM ( 'Accepted', 'Pending', 'ApprovalRequired' ); ALTER TABLE community_actions ALTER COLUMN follow_state TYPE community_follower_state USING follow_state::text::community_follower_state; ALTER TABLE multi_community_follow ALTER COLUMN follow_state TYPE community_follower_state USING follow_state::text::community_follower_state; DROP TYPE community_follower_state__; ================================================ FILE: migrations/2025-10-09-101527-0000_community-follower-denied/up.sql ================================================ -- add follow state denied for private communities ALTER TYPE community_follower_state ADD value 'Denied'; ================================================ FILE: migrations/2025-10-15-114811-0000_merge-modlog-tables/down.sql ================================================ CREATE TABLE mod_add_to_community ( community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, id serial, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, other_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), removed boolean DEFAULT FALSE, CONSTRAINT mod_add_community_community_id_not_null NOT NULL community_id, CONSTRAINT mod_add_community_id_not_null NOT NULL id, CONSTRAINT mod_add_community_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_add_community_other_user_id_not_null NOT NULL other_person_id, CONSTRAINT mod_add_community_when__not_null NOT NULL published_at, CONSTRAINT mod_add_community_removed_not_null NOT NULL removed, PRIMARY KEY (id) ); ALTER SEQUENCE mod_add_to_community_id_seq RENAME TO mod_add_community_id_seq; ALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_community_id_fkey TO mod_add_community_community_id_fkey; ALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_mod_person_id_fkey TO mod_add_community_mod_person_id_fkey; ALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_other_person_id_fkey TO mod_add_community_other_person_id_fkey; ALTER TABLE mod_add_to_community RENAME CONSTRAINT mod_add_to_community_pkey TO mod_add_community_pkey; CREATE TABLE admin_purge_comment ( admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, post_id integer NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT admin_purge_comment_when__not_null NOT NULL published_at ); CREATE TABLE admin_add ( id serial, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, other_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), removed boolean DEFAULT FALSE, CONSTRAINT mod_add_id_not_null NOT NULL id, CONSTRAINT mod_add_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_add_other_user_id_not_null NOT NULL other_person_id, CONSTRAINT mod_add_when__not_null NOT NULL published_at, CONSTRAINT mod_add_removed_not_null NOT NULL removed, PRIMARY KEY (id) ); ALTER SEQUENCE admin_add_id_seq RENAME TO mod_add_id_seq; ALTER TABLE admin_add RENAME CONSTRAINT admin_add_mod_person_id_fkey TO mod_add_mod_person_id_fkey; ALTER TABLE admin_add RENAME CONSTRAINT admin_add_other_person_id_fkey TO mod_add_other_person_id_fkey; ALTER TABLE admin_add RENAME CONSTRAINT admin_add_pkey TO mod_add_pkey; CREATE TABLE mod_transfer_community ( community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, other_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), CONSTRAINT mod_transfer_community_when__not_null NOT NULL published_at ); CREATE TABLE admin_allow_instance ( admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, allowed boolean NOT NULL, id serial PRIMARY KEY, instance_id integer NOT NULL REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT admin_allow_instance_when__not_null NOT NULL published_at ); CREATE TABLE mod_lock_post ( id serial PRIMARY KEY, locked boolean DEFAULT TRUE NOT NULL, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, post_id integer NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT mod_lock_post_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_lock_post_when__not_null NOT NULL published_at ); CREATE TABLE mod_remove_post ( id serial PRIMARY KEY, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, post_id integer NOT NULL REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, removed boolean DEFAULT TRUE NOT NULL, CONSTRAINT mod_remove_post_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_remove_post_when__not_null NOT NULL published_at ); CREATE TABLE mod_change_community_visibility ( community_id integer NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), visibility community_visibility NOT NULL, CONSTRAINT mod_change_community_visibility_published_not_null NOT NULL published_at ); CREATE TABLE mod_remove_comment ( comment_id integer NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, removed boolean DEFAULT TRUE NOT NULL, CONSTRAINT mod_remove_comment_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_remove_comment_when__not_null NOT NULL published_at ); CREATE TABLE admin_remove_community ( community_id int REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, id serial, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, removed boolean DEFAULT TRUE, CONSTRAINT mod_remove_community_id_not_null NOT NULL id, CONSTRAINT mod_remove_community_community_id_not_null NOT NULL community_id, CONSTRAINT mod_remove_community_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_remove_community_removed_not_null NOT NULL removed, CONSTRAINT mod_remove_community_when__not_null NOT NULL published_at, PRIMARY KEY (id) ); ALTER SEQUENCE admin_remove_community_id_seq RENAME TO mod_remove_community_id_seq; ALTER TABLE admin_remove_community RENAME CONSTRAINT admin_remove_community_community_id_fkey TO mod_remove_community_community_id_fkey; ALTER TABLE admin_remove_community RENAME CONSTRAINT admin_remove_community_mod_person_id_fkey TO mod_remove_community_mod_person_id_fkey; ALTER TABLE admin_remove_community RENAME CONSTRAINT admin_remove_community_pkey TO mod_remove_community_pkey; CREATE TABLE mod_lock_comment ( comment_id integer NOT NULL REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, locked boolean DEFAULT TRUE NOT NULL, mod_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now() NOT NULL, reason text NOT NULL ); CREATE TABLE mod_feature_post ( featured boolean DEFAULT TRUE, id serial, is_featured_community boolean DEFAULT TRUE, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, post_id integer REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), CONSTRAINT mod_sticky_post_featured_not_null NOT NULL featured, CONSTRAINT mod_sticky_post_id_not_null NOT NULL id, CONSTRAINT mod_sticky_post_is_featured_community_not_null NOT NULL is_featured_community, CONSTRAINT mod_sticky_post_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_sticky_post_post_id_not_null NOT NULL post_id, CONSTRAINT mod_sticky_post_when__not_null NOT NULL published_at, PRIMARY KEY (id) ); ALTER SEQUENCE mod_feature_post_id_seq RENAME TO mod_sticky_post_id_seq; ALTER TABLE mod_feature_post RENAME CONSTRAINT mod_feature_post_mod_person_id_fkey TO mod_sticky_post_mod_person_id_fkey; ALTER TABLE mod_feature_post RENAME CONSTRAINT mod_feature_post_pkey TO mod_sticky_post_pkey; ALTER TABLE mod_feature_post RENAME CONSTRAINT mod_feature_post_post_id_fkey TO mod_sticky_post_post_id_fkey; CREATE TABLE admin_block_instance ( admin_person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, blocked boolean NOT NULL, expires_at timestamp with time zone, id serial PRIMARY KEY, instance_id integer NOT NULL REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT admin_block_instance_when__not_null NOT NULL published_at ); CREATE TABLE admin_ban ( banned boolean DEFAULT TRUE, expires_at timestamp with time zone, id serial, instance_id integer REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE, mod_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, other_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT mod_ban_banned_not_null NOT NULL banned, CONSTRAINT mod_ban_id_not_null NOT NULL id, CONSTRAINT mod_ban_instance_id_not_null NOT NULL instance_id, CONSTRAINT mod_ban_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_ban_other_user_id_not_null NOT NULL other_person_id, CONSTRAINT mod_ban_when__not_null NOT NULL published_at, PRIMARY KEY (id) ); ALTER SEQUENCE admin_ban_id_seq RENAME TO mod_ban_id_seq; ALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_instance_id_fkey TO mod_ban_instance_id_fkey; ALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_mod_person_id_fkey TO mod_ban_mod_person_id_fkey; ALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_other_person_id_fkey TO mod_ban_other_person_id_fkey; ALTER TABLE admin_ban RENAME CONSTRAINT admin_ban_pkey TO mod_ban_pkey; CREATE TABLE admin_purge_post ( admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT admin_purge_post_when__not_null NOT NULL published_at ); CREATE TABLE admin_purge_person ( admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT admin_purge_person_when__not_null NOT NULL published_at ); CREATE TABLE admin_purge_community ( admin_person_id integer NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, id serial PRIMARY KEY, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, CONSTRAINT admin_purge_community_when__not_null NOT NULL published_at ); CREATE TABLE mod_ban_from_community ( id serial PRIMARY KEY, published_at timestamp with time zone DEFAULT now(), reason text NOT NULL, mod_person_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, community_id int NOT NULL REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, expires_at timestamp with time zone, other_person_id integer REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, banned bool NOT NULL DEFAULT TRUE, CONSTRAINT mod_ban_from_community_mod_user_id_not_null NOT NULL mod_person_id, CONSTRAINT mod_ban_from_community_other_user_id_not_null NOT NULL other_person_id, CONSTRAINT mod_ban_from_community_when__not_null NOT NULL published_at ); CREATE TABLE modlog_combined ( id serial PRIMARY KEY, published_at timestamptz, admin_allow_instance_id int UNIQUE REFERENCES admin_allow_instance ON UPDATE CASCADE ON DELETE CASCADE, admin_block_instance_id int UNIQUE REFERENCES admin_block_instance ON UPDATE CASCADE ON DELETE CASCADE, admin_purge_comment_id int UNIQUE REFERENCES admin_purge_comment ON UPDATE CASCADE ON DELETE CASCADE, admin_purge_community_id int UNIQUE REFERENCES admin_purge_community ON UPDATE CASCADE ON DELETE CASCADE, admin_purge_person_id int UNIQUE REFERENCES admin_purge_person ON UPDATE CASCADE ON DELETE CASCADE, admin_purge_post_id int UNIQUE REFERENCES admin_purge_post ON UPDATE CASCADE ON DELETE CASCADE, admin_add_id int UNIQUE REFERENCES admin_add ON UPDATE CASCADE ON DELETE CASCADE, mod_add_to_community_id int UNIQUE REFERENCES mod_add_to_community ON UPDATE CASCADE ON DELETE CASCADE, admin_ban_id int UNIQUE REFERENCES admin_ban ON UPDATE CASCADE ON DELETE CASCADE, mod_ban_from_community_id int UNIQUE REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE, mod_feature_post_id int UNIQUE REFERENCES mod_feature_post ON UPDATE CASCADE ON DELETE CASCADE, mod_change_community_visibility_id int REFERENCES mod_change_community_visibility ON UPDATE CASCADE ON DELETE CASCADE, mod_lock_post_id int UNIQUE REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE, mod_lock_comment_id int UNIQUE REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE, mod_remove_comment_id int UNIQUE REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE, admin_remove_community_id int UNIQUE REFERENCES admin_remove_community ON UPDATE CASCADE ON DELETE CASCADE, mod_remove_post_id int UNIQUE REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE, mod_transfer_community_id int UNIQUE REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE, CONSTRAINT modlog_combined_published_not_null NOT NULL published_at ); ALTER TABLE modlog_combined ADD CONSTRAINT modlog_combined_check CHECK (num_nonnulls (admin_allow_instance_id, admin_block_instance_id, admin_purge_comment_id, admin_purge_community_id, admin_purge_person_id, admin_purge_post_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_feature_post_id, mod_change_community_visibility_id, mod_lock_post_id, mod_remove_comment_id, admin_remove_community_id, mod_remove_post_id, mod_transfer_community_id, mod_lock_comment_id) = 1); ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_add_id_fkey TO modlog_combined_mod_add_id_fkey; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_add_id_key TO modlog_combined_mod_add_id_key; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_ban_id_fkey TO modlog_combined_mod_ban_id_fkey; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_ban_id_key TO modlog_combined_mod_ban_id_key; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_remove_community_id_fkey TO modlog_combined_mod_remove_community_id_fkey; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_admin_remove_community_id_key TO modlog_combined_mod_remove_community_id_key; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_mod_add_to_community_id_key TO modlog_combined_mod_add_community_id_key; ALTER TABLE modlog_combined RENAME CONSTRAINT modlog_combined_mod_add_to_community_id_fkey TO modlog_combined_mod_add_community_id_fkey; ALTER TABLE notification ADD COLUMN admin_add_id int REFERENCES admin_add ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_add_to_community_id int REFERENCES mod_add_to_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN admin_ban_id int REFERENCES admin_ban ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_ban_from_community_id int REFERENCES mod_ban_from_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_lock_post_id int REFERENCES mod_lock_post ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_remove_comment_id int REFERENCES mod_remove_comment ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN admin_remove_community_id int REFERENCES admin_remove_community ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_remove_post_id int REFERENCES mod_remove_post ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_lock_comment_id int REFERENCES mod_lock_comment ON UPDATE CASCADE ON DELETE CASCADE, ADD COLUMN mod_transfer_community_id int REFERENCES mod_transfer_community ON UPDATE CASCADE ON DELETE CASCADE, DROP COLUMN modlog_id; ALTER TABLE notification DROP CONSTRAINT IF EXISTS notification_check; ALTER TABLE notification ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id, admin_add_id, mod_add_to_community_id, admin_ban_id, mod_ban_from_community_id, mod_lock_post_id, mod_remove_post_id, mod_lock_comment_id, mod_remove_comment_id, admin_remove_community_id, mod_transfer_community_id) = 1); DROP TABLE modlog; DROP TYPE modlog_kind; CREATE INDEX idx_mod_add_mod ON admin_add USING btree (mod_person_id); CREATE INDEX idx_mod_ban_mod ON admin_ban USING btree (mod_person_id); CREATE INDEX idx_mod_ban_instance ON admin_ban USING btree (instance_id); CREATE INDEX idx_mod_lock_post_post ON mod_lock_post USING btree (post_id); CREATE INDEX idx_mod_other_person ON admin_ban USING btree (other_person_id); CREATE INDEX idx_mod_remove_post_post ON mod_remove_post USING btree (post_id); CREATE INDEX idx_mod_lock_post_mod ON mod_lock_post USING btree (mod_person_id); CREATE INDEX idx_mod_add_other_person ON admin_add USING btree (other_person_id); CREATE INDEX idx_mod_feature_post_post ON mod_feature_post USING btree (post_id); CREATE INDEX idx_mod_remove_post_mod ON mod_remove_post USING btree (mod_person_id); CREATE INDEX idx_mod_feature_post_mod ON mod_feature_post USING btree (mod_person_id); CREATE INDEX idx_mod_lock_comment_mod ON mod_lock_comment USING btree (mod_person_id); CREATE INDEX idx_admin_purge_comment_post ON admin_purge_comment USING btree (post_id); CREATE INDEX idx_mod_lock_comment_comment ON mod_lock_comment USING btree (comment_id); CREATE INDEX idx_admin_purge_post_admin ON admin_purge_post USING btree (admin_person_id); CREATE INDEX idx_mod_remove_comment_mod ON mod_remove_comment USING btree (mod_person_id); CREATE INDEX idx_admin_purge_post_community ON admin_purge_post USING btree (community_id); CREATE INDEX idx_mod_add_community_mod ON mod_add_to_community USING btree (mod_person_id); CREATE INDEX idx_mod_remove_comment_comment ON mod_remove_comment USING btree (comment_id); CREATE INDEX idx_admin_purge_person_admin ON admin_purge_person USING btree (admin_person_id); CREATE INDEX idx_admin_purge_comment_admin ON admin_purge_comment USING btree (admin_person_id); CREATE INDEX idx_mod_add_community_community ON mod_add_to_community USING btree (community_id); CREATE INDEX idx_mod_remove_community_mod ON admin_remove_community USING btree (mod_person_id); CREATE INDEX idx_admin_allow_instance_instance ON admin_allow_instance USING btree (instance_id); CREATE INDEX idx_admin_block_instance_instance ON admin_block_instance USING btree (instance_id); CREATE INDEX idx_admin_allow_instance_admin ON admin_allow_instance USING btree (admin_person_id); CREATE INDEX idx_admin_block_instance_admin ON admin_block_instance USING btree (admin_person_id); CREATE INDEX idx_mod_ban_from_community_mod ON mod_ban_from_community USING btree (mod_person_id); CREATE INDEX idx_mod_transfer_community_mod ON mod_transfer_community USING btree (mod_person_id); CREATE INDEX idx_admin_purge_community_admin ON admin_purge_community USING btree (admin_person_id); CREATE INDEX idx_mod_remove_community_community ON admin_remove_community USING btree (community_id); CREATE INDEX idx_mod_add_community_other_person ON mod_add_to_community USING btree (other_person_id); CREATE INDEX idx_mod_ban_from_community_community ON mod_ban_from_community USING btree (community_id); CREATE INDEX idx_mod_transfer_community_community ON mod_transfer_community USING btree (community_id); CREATE INDEX idx_modlog_combined_published ON modlog_combined USING btree (published_at DESC, id DESC); CREATE INDEX idx_mod_ban_from_community_other_person ON mod_ban_from_community USING btree (other_person_id); CREATE INDEX idx_mod_transfer_community_other_person ON mod_transfer_community USING btree (other_person_id); CREATE INDEX idx_mod_change_community_visibility_mod ON mod_change_community_visibility USING btree (mod_person_id); CREATE INDEX idx_notification_admin_add_id ON notification USING btree (admin_add_id) WHERE (admin_add_id IS NOT NULL); CREATE INDEX idx_notification_admin_ban_id ON notification USING btree (admin_ban_id) WHERE (admin_ban_id IS NOT NULL); CREATE INDEX idx_mod_change_community_visibility_community ON mod_change_community_visibility USING btree (community_id); CREATE INDEX idx_notification_mod_lock_post_id ON notification USING btree (mod_lock_post_id) WHERE (mod_lock_post_id IS NOT NULL); CREATE INDEX idx_notification_mod_remove_post_id ON notification USING btree (mod_remove_post_id) WHERE (mod_remove_post_id IS NOT NULL); CREATE INDEX idx_notification_mod_lock_comment_id ON notification USING btree (mod_lock_comment_id) WHERE (mod_lock_comment_id IS NOT NULL); CREATE INDEX idx_notification_mod_remove_comment_id ON notification USING btree (mod_remove_comment_id) WHERE (mod_remove_comment_id IS NOT NULL); CREATE INDEX idx_notification_mod_add_to_community_id ON notification USING btree (mod_add_to_community_id) WHERE (mod_add_to_community_id IS NOT NULL); CREATE INDEX idx_notification_admin_remove_community_id ON notification USING btree (admin_remove_community_id) WHERE (admin_remove_community_id IS NOT NULL); CREATE INDEX idx_notification_mod_ban_from_community_id ON notification USING btree (mod_ban_from_community_id) WHERE (mod_ban_from_community_id IS NOT NULL); CREATE INDEX idx_notification_mod_transfer_community_id ON notification USING btree (mod_transfer_community_id) WHERE (mod_transfer_community_id IS NOT NULL); CREATE INDEX idx_modlog_combined_mod_change_community_visibility_id ON modlog_combined USING btree (mod_change_community_visibility_id) WHERE (mod_change_community_visibility_id IS NOT NULL); ================================================ FILE: migrations/2025-10-15-114811-0000_merge-modlog-tables/up.sql ================================================ -- New enum with all possible mod actions -- TODO: We could also remove the Admin/Mod prefix CREATE TYPE modlog_kind AS enum ( 'AdminAdd', 'AdminBan', 'AdminAllowInstance', 'AdminBlockInstance', 'AdminPurgeComment', 'AdminPurgeCommunity', 'AdminPurgePerson', 'AdminPurgePost', 'ModAddToCommunity', 'ModBanFromCommunity', 'ModFeaturePostCommunity', 'AdminFeaturePostSite', 'ModChangeCommunityVisibility', 'ModLockPost', 'ModRemoveComment', 'AdminRemoveCommunity', 'ModRemovePost', 'ModTransferCommunity', 'ModLockComment' ); -- New table with data for all mod actions CREATE TABLE modlog ( id serial PRIMARY KEY, kind modlog_kind NOT NULL, -- Used to be `revert`, but that makes little sense for things like feature post or -- transfer community. Instead we use this which means values have to be inverted. is_revert boolean NOT NULL, -- Not using `references person` for any of the foreign keys to avoid modlog entries -- disappearing if the mod or any target gets purged. mod_id int NOT NULL, -- For some actions reason is quite pointless so leave it optional (eg add admin, feature post) reason text, target_person_id int, target_community_id int, target_post_id int, target_comment_id int, target_instance_id int, expires_at timestamptz, published_at timestamptz NOT NULL DEFAULT now() ); -- Most mod actions can have only one target. We could make this much more specific and state -- which exact column must be set for each kind but that would be excessive. ALTER TABLE modlog ADD CHECK ((kind = 'AdminAdd' AND num_nonnulls (target_person_id) = 1 AND num_nonnulls (target_community_id, target_post_id, target_comment_id, target_instance_id) = 0) OR (kind = 'AdminBan' AND num_nonnulls (target_person_id, target_instance_id) = 2 AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0) OR (kind = 'ModRemovePost' AND num_nonnulls (target_post_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModRemoveComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id) = 3 AND num_nonnulls (target_community_id, target_instance_id) = 0) OR (kind = 'ModLockComment' AND num_nonnulls (target_comment_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_post_id) = 0) OR (kind = 'ModLockPost' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminRemoveCommunity' AND num_nonnulls (target_community_id) = 1 -- target_person_id (community owner) can be either null or not null here AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModChangeCommunityVisibility' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'ModBanFromCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModAddToCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModTransferCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminAllowInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminBlockInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminPurgeComment' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePost' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgeCommunity' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePerson' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModFeaturePostCommunity' AND num_nonnulls (target_post_id, target_community_id) = 2 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'AdminFeaturePostSite' AND num_nonnulls (target_post_id) = 1 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id, target_community_id) = 0)); -- copy old data to new table INSERT INTO modlog (kind, is_revert, mod_id, target_person_id, published_at) SELECT 'AdminAdd', NOT removed, mod_person_id, other_person_id, published_at FROM admin_add; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_person_id, target_instance_id, published_at) SELECT 'AdminBan', reason, NOT banned, mod_person_id, other_person_id, p.instance_id, a. published_at FROM admin_ban a INNER JOIN person p ON p.id = mod_person_id; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_post_id, target_person_id, published_at) SELECT 'ModRemovePost', reason, NOT m.removed, mod_person_id, post_id, p.creator_id, m.published_at FROM mod_remove_post m INNER JOIN post p ON p.id = post_id; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_comment_id, target_person_id, target_post_id, published_at) SELECT 'ModRemoveComment', reason, NOT m.removed, mod_person_id, comment_id, c.creator_id, c.post_id, m.published_at FROM mod_remove_comment m INNER JOIN comment c ON c.id = comment_id; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_comment_id, target_person_id, published_at) SELECT 'ModLockComment', reason, NOT m.LOCKED, mod_person_id, comment_id, c.creator_id, m.published_at FROM mod_lock_comment m INNER JOIN comment c ON c.id = comment_id; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_post_id, target_person_id, target_community_id, published_at) SELECT 'ModLockPost', reason, NOT m.LOCKED, mod_person_id, post_id, p.creator_id, p.community_id, m.published_at FROM mod_lock_post m INNER JOIN post p ON p.id = post_id; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_community_id, published_at) SELECT 'AdminRemoveCommunity', reason, NOT removed, mod_person_id, community_id, published_at FROM admin_remove_community; INSERT INTO modlog (kind, is_revert, mod_id, target_community_id, published_at) SELECT 'ModChangeCommunityVisibility', FALSE, mod_person_id, community_id, published_at FROM mod_change_community_visibility; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_community_id, target_person_id, expires_at, published_at) SELECT 'ModBanFromCommunity', reason, NOT banned, mod_person_id, community_id, other_person_id, expires_at, published_at FROM mod_ban_from_community; INSERT INTO modlog (kind, is_revert, mod_id, target_community_id, target_person_id, published_at) SELECT 'ModAddToCommunity', NOT removed, mod_person_id, community_id, other_person_id, published_at FROM mod_add_to_community; INSERT INTO modlog (kind, is_revert, mod_id, target_community_id, target_person_id, published_at) SELECT 'ModTransferCommunity', FALSE, mod_person_id, community_id, other_person_id, published_at FROM mod_transfer_community; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_instance_id, published_at) SELECT 'AdminAllowInstance', reason, NOT allowed, admin_person_id, instance_id, published_at FROM admin_allow_instance; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_instance_id, published_at) SELECT 'AdminBlockInstance', reason, NOT blocked, admin_person_id, instance_id, published_at FROM admin_block_instance; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_post_id, target_person_id, target_community_id, published_at) SELECT 'AdminPurgeComment', reason, FALSE, admin_person_id, post_id, p.creator_id, p.community_id, a.published_at FROM admin_purge_comment a INNER JOIN post p ON p.id = post_id; INSERT INTO modlog (kind, reason, is_revert, mod_id, target_community_id, published_at) SELECT 'AdminPurgePost', reason, FALSE, admin_person_id, community_id, published_at FROM admin_purge_post; INSERT INTO modlog (kind, reason, is_revert, mod_id, published_at) SELECT 'AdminPurgeCommunity', reason, FALSE, admin_person_id, published_at FROM admin_purge_community; INSERT INTO modlog (kind, reason, is_revert, mod_id, published_at) SELECT 'AdminPurgePerson', reason, FALSE, admin_person_id, published_at FROM admin_purge_person; INSERT INTO modlog (kind, is_revert, mod_id, target_post_id, target_community_id, published_at) SELECT 'ModFeaturePostCommunity', NOT featured, mod_person_id, post_id, post.community_id, m.published_at FROM mod_feature_post m INNER JOIN post ON post.id = m.post_id WHERE is_featured_community; INSERT INTO modlog (kind, is_revert, mod_id, target_post_id, published_at) SELECT 'AdminFeaturePostSite', NOT featured, mod_person_id, post_id, published_at FROM mod_feature_post WHERE NOT is_featured_community; ALTER TABLE notification DROP CONSTRAINT IF EXISTS notification_check; -- Rewrite notifications to reference new modlog table. This is not used in production yet -- so no need to copy over data. ALTER TABLE notification DROP COLUMN admin_add_id, DROP COLUMN mod_add_to_community_id, DROP COLUMN admin_ban_id, DROP COLUMN mod_ban_from_community_id, DROP COLUMN mod_lock_post_id, DROP COLUMN mod_remove_comment_id, DROP COLUMN admin_remove_community_id, DROP COLUMN mod_remove_post_id, DROP COLUMN mod_lock_comment_id, DROP COLUMN mod_transfer_community_id, ADD COLUMN modlog_id int REFERENCES modlog ON UPDATE CASCADE ON DELETE CASCADE; DELETE FROM notification WHERE post_id IS NULL AND comment_id IS NULL AND private_message_id IS NULL AND modlog_id IS NULL; ALTER TABLE notification ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id, modlog_id) = 1); CREATE INDEX idx_notification_modlog_id ON notification USING btree (modlog_id) WHERE (modlog_id IS NOT NULL); DROP TABLE modlog_combined, admin_add, admin_allow_instance, admin_ban, admin_block_instance, admin_remove_community, admin_purge_comment, admin_purge_community, admin_purge_person, admin_purge_post, mod_add_to_community, mod_ban_from_community, mod_change_community_visibility, mod_feature_post, mod_lock_comment, mod_lock_post, mod_remove_comment, mod_remove_post, mod_transfer_community; ================================================ FILE: migrations/2025-11-05-181519-0000_add_registration_application_updated_at/down.sql ================================================ DROP INDEX idx_registration_application_updated; ALTER TABLE registration_application DROP COLUMN updated_at; ================================================ FILE: migrations/2025-11-05-181519-0000_add_registration_application_updated_at/up.sql ================================================ ALTER TABLE registration_application ADD COLUMN updated_at timestamptz; CREATE INDEX idx_registration_application_updated ON registration_application (updated_at DESC); ================================================ FILE: migrations/2025-11-08-123111-0000_add_multi_community_subscribers_community_count/down.sql ================================================ DROP INDEX idx_multi_community_lower_name; DROP INDEX idx_multi_community_subscribers; DROP INDEX idx_multi_community_subscribers_local; DROP INDEX idx_multi_community_communities; DROP INDEX idx_multi_community_published; ALTER TABLE multi_community DROP COLUMN subscribers, DROP COLUMN subscribers_local, DROP COLUMN communities; ================================================ FILE: migrations/2025-11-08-123111-0000_add_multi_community_subscribers_community_count/up.sql ================================================ ALTER TABLE multi_community ADD COLUMN subscribers int NOT NULL DEFAULT 0, ADD COLUMN subscribers_local int NOT NULL DEFAULT 0, ADD COLUMN communities int NOT NULL DEFAULT 0; -- Add indexes for all the sorts, to somewhat match the ones on community CREATE INDEX idx_multi_community_lower_name ON multi_community (lower(name::text) DESC, id DESC); CREATE INDEX idx_multi_community_subscribers ON multi_community (subscribers DESC, id DESC); CREATE INDEX idx_multi_community_subscribers_local ON multi_community (subscribers_local DESC, id DESC); CREATE INDEX idx_multi_community_communities ON multi_community (communities DESC, id DESC); CREATE INDEX idx_multi_community_published ON multi_community (published_at DESC, id DESC); ================================================ FILE: migrations/2026-01-08-132525-0000_community-sidebar-summary/down.sql ================================================ ALTER TABLE community RENAME COLUMN description TO sidebar; ALTER TABLE community RENAME summary TO description; ALTER TABLE community_report RENAME original_community_description TO original_community_sidebar; ALTER TABLE community_report RENAME original_community_summary TO original_community_description; ALTER TABLE site RENAME COLUMN description TO sidebar; ALTER TABLE site RENAME summary TO description; ================================================ FILE: migrations/2026-01-08-132525-0000_community-sidebar-summary/up.sql ================================================ ALTER TABLE community RENAME description TO summary; ALTER TABLE community RENAME COLUMN sidebar TO description; ALTER TABLE community_report RENAME original_community_description TO original_community_summary; ALTER TABLE community_report RENAME original_community_sidebar TO original_community_description; ALTER TABLE site RENAME description TO summary; ALTER TABLE site RENAME COLUMN sidebar TO description; ================================================ FILE: migrations/2026-01-19-122321-0000_add_community_tag_color/down.sql ================================================ ALTER TABLE tag DROP COLUMN color; DROP TYPE tag_color_enum; ================================================ FILE: migrations/2026-01-19-122321-0000_add_community_tag_color/up.sql ================================================ -- creates a new tag color enum CREATE TYPE tag_color_enum AS ENUM ( 'color01', 'color02', 'color03', 'color04', 'color05', 'color06', 'color07', 'color08', 'color09', 'color10' ); ALTER TABLE tag ADD COLUMN color tag_color_enum DEFAULT 'color01' NOT NULL; ================================================ FILE: migrations/2026-01-23-094410-0000_rename-sidebar-again/down.sql ================================================ ALTER TABLE community RENAME sidebar TO description; ALTER TABLE community_report RENAME original_community_sidebar TO original_community_description; ALTER TABLE site RENAME sidebar TO description; ALTER TABLE multi_community RENAME summary TO description; ALTER TABLE tag RENAME summary TO description; ALTER TABLE tag ALTER description TYPE text; ================================================ FILE: migrations/2026-01-23-094410-0000_rename-sidebar-again/up.sql ================================================ ALTER TABLE community RENAME description TO sidebar; ALTER TABLE community_report RENAME original_community_description TO original_community_sidebar; ALTER TABLE site RENAME description TO sidebar; -- using summary for this because it has 150 char limit ALTER TABLE multi_community RENAME description TO summary; ALTER TABLE tag RENAME description TO summary; ALTER TABLE tag ALTER summary TYPE varchar(150); ================================================ FILE: migrations/2026-01-23-140244-0000_rename-tag-to-community-tag/down.sql ================================================ ALTER TABLE post_community_tag RENAME TO post_tag; ALTER TABLE post_tag RENAME community_tag_id TO tag_id; ALTER TABLE community_tag RENAME TO tag; ================================================ FILE: migrations/2026-01-23-140244-0000_rename-tag-to-community-tag/up.sql ================================================ ALTER TABLE tag RENAME TO community_tag; ALTER TABLE post_tag RENAME tag_id TO community_tag_id; ALTER TABLE post_tag RENAME TO post_community_tag; ================================================ FILE: migrations/2026-01-28-115414-0000_captcha-plugin/down.sql ================================================ CREATE TABLE captcha_answer ( uuid uuid NOT NULL DEFAULT gen_random_uuid () PRIMARY KEY, answer text NOT NULL, published timestamptz NOT NULL DEFAULT now() ); ALTER TABLE captcha_answer RENAME COLUMN published TO published_at; ALTER TABLE local_site ADD COLUMN captcha_enabled boolean DEFAULT FALSE NOT NULL; ALTER TABLE local_site ADD COLUMN captcha_difficulty varchar(255) DEFAULT 'medium'::character varying NOT NULL; ================================================ FILE: migrations/2026-01-28-115414-0000_captcha-plugin/up.sql ================================================ DROP TABLE captcha_answer; ALTER TABLE local_site DROP COLUMN captcha_enabled; ALTER TABLE local_site DROP COLUMN captcha_difficulty; ================================================ FILE: migrations/2026-02-01-205644-0000_add_moderator_warn_modlog_kind/down.sql ================================================ -- reverting an enum value addition is not supported by postgres: -- https://www.postgresql.org/docs/current/datatype-enum.html#DATATYPE-ENUM-IMPLEMENTATION-DETAILS -- so this workaround is necessary CREATE TYPE modlog_kind_old AS ENUM ( 'AdminAdd', 'AdminBan', 'AdminAllowInstance', 'AdminBlockInstance', 'AdminPurgeComment', 'AdminPurgeCommunity', 'AdminPurgePerson', 'AdminPurgePost', 'ModAddToCommunity', 'ModBanFromCommunity', 'ModFeaturePostCommunity', 'AdminFeaturePostSite', 'ModChangeCommunityVisibility', 'ModLockPost', 'ModRemoveComment', 'AdminRemoveCommunity', 'ModRemovePost', 'ModTransferCommunity', 'ModLockComment' ); ALTER TABLE modlog DROP CONSTRAINT IF EXISTS modlog_check; ALTER TABLE modlog ALTER COLUMN kind TYPE modlog_kind_old USING kind::text::modlog_kind_old; DROP TYPE modlog_kind; ALTER TYPE modlog_kind_old RENAME TO modlog_kind; ALTER TABLE modlog ADD CONSTRAINT modlog_check CHECK ((kind = 'AdminAdd' AND num_nonnulls (target_person_id) = 1 AND num_nonnulls (target_community_id, target_post_id, target_comment_id, target_instance_id) = 0) OR (kind = 'AdminBan' AND num_nonnulls (target_person_id, target_instance_id) = 2 AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0) OR (kind = 'ModRemovePost' AND num_nonnulls (target_post_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModRemoveComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id) = 3 AND num_nonnulls (target_community_id, target_instance_id) = 0) OR (kind = 'ModLockComment' AND num_nonnulls (target_comment_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_post_id) = 0) OR (kind = 'ModLockPost' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminRemoveCommunity' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModChangeCommunityVisibility' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'ModBanFromCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModAddToCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModTransferCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminAllowInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminBlockInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminPurgeComment' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePost' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgeCommunity' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePerson' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModFeaturePostCommunity' AND num_nonnulls (target_post_id, target_community_id) = 2 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'AdminFeaturePostSite' AND num_nonnulls (target_post_id) = 1 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id, target_community_id) = 0)); ================================================ FILE: migrations/2026-02-01-205644-0000_add_moderator_warn_modlog_kind/up.sql ================================================ ALTER TYPE modlog_kind ADD VALUE 'ModWarnComment'; ALTER TYPE modlog_kind ADD VALUE 'ModWarnPost'; ================================================ FILE: migrations/2026-02-03-235249-0000_add_moderator_warn_constraint_check/down.sql ================================================ -- remove ModWarn from constraint checks ALTER TABLE modlog DROP CONSTRAINT IF EXISTS modlog_check; ALTER TABLE modlog ADD CHECK ((kind = 'AdminAdd' AND num_nonnulls (target_person_id) = 1 AND num_nonnulls (target_community_id, target_post_id, target_comment_id, target_instance_id) = 0) OR (kind = 'AdminBan' AND num_nonnulls (target_person_id, target_instance_id) = 2 AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0) OR (kind = 'ModRemovePost' AND num_nonnulls (target_post_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModRemoveComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id) = 3 AND num_nonnulls (target_community_id, target_instance_id) = 0) OR (kind = 'ModLockComment' AND num_nonnulls (target_comment_id, target_person_id) = 2 AND num_nonnulls (target_community_id, target_instance_id, target_post_id) = 0) OR (kind = 'ModLockPost' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminRemoveCommunity' AND num_nonnulls (target_community_id) = 1 -- target_person_id (community owner) can be either null or not null here AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModChangeCommunityVisibility' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'ModBanFromCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModAddToCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModTransferCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminAllowInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminBlockInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminPurgeComment' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePost' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgeCommunity' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePerson' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModFeaturePostCommunity' AND num_nonnulls (target_post_id, target_community_id) = 2 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'AdminFeaturePostSite' AND num_nonnulls (target_post_id) = 1 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id, target_community_id) = 0)); ================================================ FILE: migrations/2026-02-03-235249-0000_add_moderator_warn_constraint_check/up.sql ================================================ -- add ModWarn to constraint checks ALTER TABLE modlog DROP CONSTRAINT IF EXISTS modlog_check; ALTER TABLE modlog ADD CHECK ((kind = 'AdminAdd' AND num_nonnulls (target_person_id, target_instance_id) = 2 AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0) OR (kind = 'AdminBan' AND num_nonnulls (target_person_id, target_instance_id) = 2 AND num_nonnulls (target_community_id, target_post_id, target_comment_id) = 0) OR (kind = 'ModRemovePost' AND num_nonnulls (target_post_id, target_community_id, target_person_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'ModRemoveComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id, target_community_id) = 4 AND num_nonnulls (target_instance_id) = 0) OR (kind = 'ModLockComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id, target_community_id) = 4 AND num_nonnulls (target_instance_id) = 0) OR (kind = 'ModWarnComment' AND num_nonnulls (target_comment_id, target_person_id, target_post_id, target_community_id) = 4 AND num_nonnulls (target_instance_id) = 0) OR (kind = 'ModLockPost' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'ModWarnPost' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminRemoveCommunity' AND num_nonnulls (target_community_id, target_instance_id) = 2 -- target_person_id (community owner) can be either null or not null here AND num_nonnulls (target_post_id, target_comment_id) = 0) OR (kind = 'ModChangeCommunityVisibility' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'ModBanFromCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModAddToCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModTransferCommunity' AND num_nonnulls (target_community_id, target_person_id) = 2 AND num_nonnulls (target_post_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminAllowInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminBlockInstance' AND num_nonnulls (target_instance_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_comment_id) = 0) OR (kind = 'AdminPurgeComment' AND num_nonnulls (target_post_id, target_person_id, target_community_id) = 3 AND num_nonnulls (target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePost' AND num_nonnulls (target_community_id) = 1 AND num_nonnulls (target_post_id, target_person_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgeCommunity' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'AdminPurgePerson' AND num_nonnulls (target_post_id, target_person_id, target_community_id, target_instance_id, target_comment_id) = 0) OR (kind = 'ModFeaturePostCommunity' AND num_nonnulls (target_post_id, target_community_id) = 2 AND num_nonnulls (target_instance_id, target_person_id, target_comment_id) = 0) OR (kind = 'AdminFeaturePostSite' AND num_nonnulls (target_post_id, target_community_id, target_instance_id) = 3 AND num_nonnulls (target_person_id, target_comment_id) = 0)); ================================================ FILE: migrations/2026-02-19-120000-0000_add_bulk_to_modlog/down.sql ================================================ DROP INDEX IF EXISTS idx_modlog_bulk_action_parent_id; ALTER TABLE modlog DROP COLUMN bulk_action_parent_id; ================================================ FILE: migrations/2026-02-19-120000-0000_add_bulk_to_modlog/up.sql ================================================ ALTER TABLE modlog ADD COLUMN bulk_action_parent_id int REFERENCES modlog (id) ON UPDATE CASCADE ON DELETE CASCADE; CREATE INDEX idx_modlog_bulk_action_parent_id ON modlog (bulk_action_parent_id); ================================================ FILE: migrations/2026-02-19-192014-0000_rename_suggested_communities/down.sql ================================================ ALTER TABLE local_site RENAME COLUMN suggested_multi_community_id TO suggested_communities; ================================================ FILE: migrations/2026-02-19-192014-0000_rename_suggested_communities/up.sql ================================================ ALTER TABLE local_site RENAME COLUMN suggested_communities TO suggested_multi_community_id; ================================================ FILE: migrations/2026-02-24-205759-0000_add_notification_creator_id/down.sql ================================================ ALTER TABLE notification DROP COLUMN creator_id; ================================================ FILE: migrations/2026-02-24-205759-0000_add_notification_creator_id/up.sql ================================================ -- Add a creator_id column to notifications. ALTER TABLE notification ADD COLUMN creator_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE; -- Update the data -- Private messages UPDATE notification n SET creator_id = p.creator_id FROM private_message p WHERE n.private_message_id = p.id; -- Posts UPDATE notification n SET creator_id = p.creator_id FROM post p WHERE n.post_id = p.id; -- Comments UPDATE notification n SET creator_id = c.creator_id FROM comment c WHERE n.comment_id = c.id; -- Mod actions UPDATE notification n SET creator_id = m.mod_id FROM modlog m WHERE n.modlog_id = m.id; -- Make column not null ALTER TABLE notification ALTER COLUMN creator_id SET NOT NULL; -- Create an index CREATE INDEX idx_notification_creator ON notification (creator_id); ================================================ FILE: migrations/2026-03-02-231448-0000_add_multi_community_sidebar/down.sql ================================================ ALTER TABLE multi_community DROP COLUMN sidebar; ================================================ FILE: migrations/2026-03-02-231448-0000_add_multi_community_sidebar/up.sql ================================================ ALTER TABLE multi_community ADD COLUMN sidebar text; ================================================ FILE: migrations/2026-03-03-211442-0000_move_config_pictrs_to_db/down.sql ================================================ ALTER TABLE local_site DROP COLUMN image_mode, DROP COLUMN image_proxy_bypass_domains, DROP COLUMN image_upload_timeout_seconds, DROP COLUMN image_max_thumbnail_size, DROP COLUMN image_max_avatar_size, DROP COLUMN image_max_banner_size, DROP COLUMN image_max_upload_size, DROP COLUMN image_allow_video_uploads, DROP COLUMN image_upload_disabled; DROP TYPE image_mode_enum; ================================================ FILE: migrations/2026-03-03-211442-0000_move_config_pictrs_to_db/up.sql ================================================ -- This moves a few pictrs related settings in the config, to the database CREATE TYPE image_mode_enum AS enum ( 'None', 'StoreLinkPreviews', 'ProxyAllImages' ); ALTER TABLE local_site ADD COLUMN image_mode image_mode_enum NOT NULL DEFAULT 'ProxyAllImages', ADD COLUMN image_proxy_bypass_domains text, ADD COLUMN image_upload_timeout_seconds int NOT NULL DEFAULT 30, ADD COLUMN image_max_thumbnail_size int NOT NULL DEFAULT 512, ADD COLUMN image_max_avatar_size int NOT NULL DEFAULT 512, ADD COLUMN image_max_banner_size int NOT NULL DEFAULT 1024, ADD COLUMN image_max_upload_size int NOT NULL DEFAULT 1024, ADD COLUMN image_allow_video_uploads boolean NOT NULL DEFAULT TRUE, ADD COLUMN image_upload_disabled boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2026-03-04-143123-0000_add_deleted_by_recip_to_pm/down.sql ================================================ ALTER TABLE private_message DROP COLUMN deleted_by_recipient; ================================================ FILE: migrations/2026-03-04-143123-0000_add_deleted_by_recip_to_pm/up.sql ================================================ ALTER TABLE private_message ADD COLUMN deleted_by_recipient boolean NOT NULL DEFAULT FALSE; ================================================ FILE: migrations/2026-03-08-021022-0000_fixup_post_action_indexes/down.sql ================================================ DROP INDEX idx_post_actions_person_hidden; CREATE INDEX idx_post_actions_hidden_not_null ON post_actions (person_id, post_id) WHERE hidden_at IS NOT NULL; DROP INDEX idx_post_actions_person_read; CREATE INDEX idx_post_actions_read_not_null ON post_actions (person_id, post_id) WHERE read_at IS NOT NULL; CREATE INDEX idx_post_actions_on_read_read_not_null ON post_actions (person_id, read_at, post_id) WHERE read_at IS NOT NULL; ================================================ FILE: migrations/2026-03-08-021022-0000_fixup_post_action_indexes/up.sql ================================================ -- Remove a pointless hidden index, and add a better one. DROP INDEX idx_post_actions_hidden_not_null; CREATE INDEX idx_post_actions_person_hidden ON post_actions (person_id, hidden_at DESC, post_id DESC) WHERE hidden_at IS NOT NULL; -- Remove 2 pointless read_at indexes, create a better one. DROP INDEX idx_post_actions_read_not_null, idx_post_actions_on_read_read_not_null; CREATE INDEX idx_post_actions_person_read ON post_actions (person_id, read_at DESC, post_id DESC) WHERE read_at IS NOT NULL; ================================================ FILE: migrations/2026-03-08-202630-0000_add_modlog_foreign_keys/down.sql ================================================ ALTER TABLE modlog DROP CONSTRAINT modlog_mod_fkey, DROP CONSTRAINT modlog_target_person_fkey, DROP CONSTRAINT modlog_target_community_fkey, DROP CONSTRAINT modlog_target_post_fkey, DROP CONSTRAINT modlog_target_comment_fkey, DROP CONSTRAINT modlog_target_instance_fkey; DROP INDEX idx_modlog_mod, idx_modlog_kind, idx_modlog_target_person, idx_modlog_target_community, idx_modlog_target_post, idx_modlog_target_comment, idx_modlog_target_instance, idx_modlog_published_id ================================================ FILE: migrations/2026-03-08-202630-0000_add_modlog_foreign_keys/up.sql ================================================ -- Use on delete set null for all these (except require mod_id), so that if an item is purged, the modlog rows will still remain. ALTER TABLE modlog ADD CONSTRAINT modlog_mod_fkey FOREIGN KEY (mod_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_target_person_fkey FOREIGN KEY (target_person_id) REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_target_community_fkey FOREIGN KEY (target_community_id) REFERENCES community ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_target_post_fkey FOREIGN KEY (target_post_id) REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_target_comment_fkey FOREIGN KEY (target_comment_id) REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, ADD CONSTRAINT modlog_target_instance_fkey FOREIGN KEY (target_instance_id) REFERENCES instance ON UPDATE CASCADE ON DELETE CASCADE; CREATE INDEX idx_modlog_kind ON modlog (kind); CREATE INDEX idx_modlog_mod ON modlog (mod_id); CREATE INDEX idx_modlog_target_person ON modlog (target_person_id) WHERE target_person_id IS NOT NULL; CREATE INDEX idx_modlog_target_community ON modlog (target_community_id) WHERE target_community_id IS NOT NULL; CREATE INDEX idx_modlog_target_post ON modlog (target_post_id) WHERE target_post_id IS NOT NULL; CREATE INDEX idx_modlog_target_comment ON modlog (target_comment_id) WHERE target_comment_id IS NOT NULL; CREATE INDEX idx_modlog_target_instance ON modlog (target_instance_id) WHERE target_instance_id IS NOT NULL; CREATE INDEX idx_modlog_published_id ON modlog (published_at DESC, id DESC); ================================================ FILE: migrations/2026-03-09-014616-0000_add_resolved_report_combined/down.sql ================================================ DROP INDEX idx_report_combined_published_asc; ALTER TABLE report_combined DROP COLUMN resolved; CREATE INDEX idx_report_combined_published_asc ON report_combined (reverse_timestamp_sort (published_at) DESC, id DESC); ================================================ FILE: migrations/2026-03-09-014616-0000_add_resolved_report_combined/up.sql ================================================ -- Adds resolved to the report combined table to speed up queries. ALTER TABLE report_combined ADD COLUMN resolved boolean NOT NULL DEFAULT FALSE; -- post UPDATE report_combined AS rc SET resolved = r.resolved FROM post_report r WHERE rc.post_report_id = r.id; -- comment UPDATE report_combined AS rc SET resolved = r.resolved FROM comment_report r WHERE rc.comment_report_id = r.id; -- community UPDATE report_combined AS rc SET resolved = r.resolved FROM community_report r WHERE rc.community_report_id = r.id; -- private message UPDATE report_combined AS rc SET resolved = r.resolved FROM private_message_report r WHERE rc.private_message_report_id = r.id; -- For unresolved, its an asc query DROP INDEX idx_report_combined_published_asc; CREATE INDEX idx_report_combined_published_asc ON report_combined (resolved, published_at, id); ================================================ FILE: readmes/README.es.md ================================================
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg) [![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/) [![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues) [![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/) [![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/) [![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE) ![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social) [![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)

English | Español | Русский | 汉语 | 漢語 | 日本語

Lemmy

Un agregador de enlaces / alternativa a Menéame - Reddit para el fediverso.

Unirse a Lemmy · Documentación · Reportar Errores (bugs) · Solicitar Características · Lanzamientos · Código de Conducta

## Sobre El Proyecto | Escritorio | Móvil | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) | [Lemmy](https://github.com/LemmyNet/lemmy) es similar a sitios como [Menéame](https://www.meneame.net/), [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), o [Hacker News](https://news.ycombinator.com/): te subscribes a los foros que te interesan, publicas enlaces y debates, luego votas y comentas en ellos. Entre bastidores, es muy diferente; cualquiera puede gestionar fácilmente un servidor, y todos estos servidores son federados (piensa en el correo electrónico), y conectados al mismo universo, llamado [Fediverso](https://es.wikipedia.org/wiki/Fediverso). Para un agregador de enlaces, esto significa que un usuario registrado en un servidor puede suscribirse a los foros de otro servidor, y puede mantener discusiones con usuarios registrados en otros lugares. El objetivo general es crear una alternativa a reddit y otros agregadores de enlaces, fácilmente auto-hospedada, descentralizada, fuera de su control e intromisión corporativa. Cada servidor lemmy puede establecer su propia política de moderación; nombrando a los administradores del sitio y a los moderadores de la comunidad para mantener alejados a los trolls, y fomentar un entorno saludable y no tóxico en el que puedan sentirse cómodos contribuyendo. _Nota: Las APIs WebSocket y HTTP actualmente son inestables_ ### ¿Por qué se llama Lemmy? - Cantante principal de [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U). - El [videojuego de la vieja escuela](https://es.wikipedia.org/wiki/Lemmings). - El [Koopa de Super Mario](https://www.mariowiki.com/Lemmy_Koopa). - Los [roedores peludos](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/). ### Creado Con - [Rust](https://www.rust-lang.org) - [Actix](https://actix.rs/) - [Diesel](http://diesel.rs/) - [Inferno](https://infernojs.org) - [Typescript](https://www.typescriptlang.org/) # Características - Código abierto, [Licencia AGPL](/LICENSE). - Auto-hospedado, fácil de desplegar (deploy). - Viene con [Docker](#docker) y [Ansible](#ansible). - Interfaz limpia y fácil de usar. Apta para dispositivos móviles. - Sólo se requiere como mínimo un nombre de usuario y una contraseñar para inscribirse! - Soporte de avatar de usuario. - Hilos de comentarios actualizados en directo. - Puntuaciones completas de los votos `(+/-)` como en el antiguo reddit. - Temas, incluidos los claros, los oscuros, y los solarizados. - Emojis con soporte de autocompletado. Empieza tecleando `:` - _Ejemplo_ `miau :cat:` => `miau 🐈` - Etiquetado de Usuarios con `@`, etiquetado de Comunidades con `!`. - _Ejemplo_ `@miguel@lemmy.ml me invitó a la comunidad !gaming@lemmy.ml` - Carga de imágenes integrada tanto en las publicaciones como en los comentarios. - Una publicación puede consistir en un título y cualquier combinación de texto propio, una URL o nada más. - Notificaciones, sobre las respuestas a los comentarios y cuando te etiquetan. - Las notificaciones se pueden enviar por correo electrónico. - Soporte para mensajes privados. - Soporte de i18n / internacionalización. - Fuentes RSS / Atom para Todo `All`, Suscrito `Subscribed`, Bandeja de entrada `inbox`, Usuario `User`, y Comunidad `Community`. - Soporte para la publicación cruzada (cross-posting). - **búsqueda de publicaciones similares** al crear una nueva. Ideal para comunidades de preguntas y respuestas. - Capacidades de moderación. - Registros públicos de moderación. - Puedes pegar las publicaciones a la parte superior de las comunidades. - Tanto los administradores del sitio, como los moderadores de la comunidad, pueden nombrar a otros moderadores. - Puedes bloquear, eliminar y restaurar publicaciones y comentarios. - Puedes banear y desbanear usuarios de las comunidades y del sitio. - Puedes transferir el sitio y las comunidades a otros. - Puedes borrar completamente tus datos, reemplazando todas las publicaciones y comentarios. - Soporte para publicaciones y comunidades NSFW. - Alto rendimiento. - El servidor está escrito en rust. - El front end está comprimido (gzipped) en `~80kB`. - El front end funciona sin javascript (sólo lectura). - Soporta arm64 / Raspberry Pi. ## Instalación - [Docker](https://join-lemmy.org/docs/es/administration/install_docker.html) - [Ansible](https://join-lemmy.org/docs/es/administration/install_ansible.html) ## Proyectos de Lemmy ### Aplicaciones - [lemmy-ui - La aplicación web oficial para lemmy](https://github.com/LemmyNet/lemmy-ui) - [Lemmur - Un cliente móvil para Lemmy (Android, Linux, Windows)](https://github.com/LemmurOrg/lemmur) - [Remmel - Una aplicación IOS nativa](https://github.com/uuttff8/Lemmy-iOS) ### Librerías - [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client) - [Kotlin API ( en desarrollo )](https://github.com/eiknat/lemmy-client) - [Dart API client ( en desarrollo )](https://github.com/LemmurOrg/lemmy_api_client) ## Apoyo / Donación Lemmy es un software libre y de código abierto, lo que significa que no hay publicidad, monetización o capital de riesgo, nunca. Tus donaciones apoyan directamente el desarrollo a tiempo completo del proyecto. - [Apoya en Liberapay](https://liberapay.com/Lemmy). - [Apoya en Ko-fi](https://ko-fi.com/lemmynet). - [Apoya en Patreon](https://www.patreon.com/dessalines). - [Apoya en OpenCollective](https://opencollective.com/lemmy). - [Lista de patrocinadores](https://join-lemmy.org/sponsors). ### Crypto - bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` - ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` ## Contribuir - [Instrucciones para contribuir](https://join-lemmy.org/docs/es/contributing/contributing.html) - [Desarrollo por Docker](https://join-lemmy.org/docs/es/contributing/docker_development.html) - [Desarrollo Local](https://join-lemmy.org/docs/es/contributing/local_development.html) ### Traducciones Si quieres ayudar con la traducción, echa un vistazo a [Weblate](https://weblate.yerbamate.ml/projects/lemmy/). También puedes ayudar [traduciendo la documentación](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language). ## Contacto - [Mastodon](https://mastodon.social/@LemmyDev) - [Matrix](https://matrix.to/#/#lemmy:matrix.org) ## Repositorios del código - [GitHub](https://github.com/LemmyNet/lemmy) - [Gitea](https://yerbamate.ml/LemmyNet/lemmy) - [Codeberg](https://codeberg.org/LemmyNet/lemmy) ## Creditos Logo hecho por Andy Cuccaro (@andycuccaro) bajo la licencia CC-BY-SA 4.0. ================================================ FILE: readmes/README.ja.md ================================================
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg) [![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy) [![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues) [![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/) [![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/) [![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE) ![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social) [![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)

English | Español | Русский | 汉语 | 漢語 | 日本語

Lemmy

フェディバースのリンクアグリゲーターとフォーラムです。

Lemmy に参加 · ドキュメント · マトリックスチャット · バグを報告 · 機能リクエスト · リリース · 行動規範

## プロジェクトについて | デスクトップ | モバイル | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) | [Lemmy](https://github.com/LemmyNet/lemmy) は、[Reddit](https://reddit.com)、[Lobste.rs](https://lobste.rs)、[Hacker News](https://news.ycombinator.com/) といったサイトに似ています。興味のあるフォーラムを購読してリンクや議論を掲載し、投票したり、コメントしたりしています。誰でも簡単にサーバーを運営することができ、これらのサーバーはすべて連合しており(電子メールを考えてください)、[Fediverse](https://en.wikipedia.org/wiki/Fediverse) と呼ばれる同じ宇宙に接続されています。 リンクアグリゲーターの場合、あるサーバーに登録したユーザーが他のサーバーのフォーラムを購読し、他のサーバーに登録したユーザーとディスカッションができることを意味します。 Reddit や他のリンクアグリゲーターに代わる、企業の支配や干渉を受けない、簡単にセルフホスティングできる分散型の代替手段です。 各 Lemmy サーバーは、独自のモデレーションポリシーを設定することができます。サイト全体の管理者やコミュニティモデレーターを任命し、荒らしを排除し、誰もが安心して貢献できる健全で毒気のない環境を育みます。 ### なぜ Lemmy というのか? - [Motörhead](https://invidio.us/watch?v=3mbvWn1EY6g) のリードシンガー。 - 古くは[ビデオゲーム]()。 - [スーパーマリオのクッパ](https://www.mariowiki.com/Lemmy_Koopa)。 - [毛むくじゃらの齧歯類](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/)。 ### こちらでビルド - [Rust](https://www.rust-lang.org) - [Actix](https://actix.rs/) - [Diesel](http://diesel.rs/) - [Inferno](https://infernojs.org) - [Typescript](https://www.typescriptlang.org/) ## 特徴 - オープンソース、[AGPL License](/LICENSE) です。 - セルフホスティングが可能で、デプロイが容易です。 - [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) と [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html) が付属しています。 - クリーンでモバイルフレンドリーなインターフェイス。 - サインアップに必要なのは、最低限のユーザー名とパスワードのみ! - ユーザーアバター対応 - ライブ更新のコメントスレッド - 古い Reddit のような完全な投票スコア `(+/-)`. - ライト、ダーク、ソラライズなどのテーマがあります。 - オートコンプリートをサポートする絵文字。`:` と入力することでスタート - ユーザータグは `@` で、コミュニティタグは `!` で入力できます。 - 投稿とコメントの両方で、画像のアップロードが可能です。 - 投稿は、タイトルと自己テキスト、URL、またはそれ以外の任意の組み合わせで構成できます。 - コメントの返信や、タグ付けされたときに、通知します。 - 通知はメールで送ることができます。 - プライベートメッセージのサポート - i18n / 国際化のサポート - `All`、`Subscribed`、`Inbox`、`User`、`Community` の RSS / Atom フィードを提供します。 - クロスポストのサポート。 - 新しい投稿を作成する際の _類似投稿検索_。質問/回答コミュニティに最適です。 - モデレーション機能。 - モデレーションのログを公開。 - コミュニティのトップページにスティッキー・ポストを貼ることができます。 - サイト管理者、コミュニティモデレーターの両方が、他のモデレーターを任命することができます。 - 投稿やコメントのロック、削除、復元が可能。 - コミュニティやサイトの利用を禁止したり、禁止を解除したりすることができます。 - サイトとコミュニティを他者に譲渡することができます。 - すべての投稿とコメントを削除し、データを完全に消去することができます。 - NSFW 投稿/コミュニティサポート - 高いパフォーマンス。 - サーバーは Rust で書かれています。 - フロントエンドは `~80kB` gzipped です。 - arm64 / Raspberry Pi をサポートします。 ## インストール - [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) - [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html) ## Lemmy プロジェクト ### アプリ - [lemmy-ui - Lemmy の公式ウェブアプリ](https://github.com/LemmyNet/lemmy-ui) - [lemmyBB -phpBB をベースにした Lemmy フォーラム UI](https://github.com/LemmyNet/lemmyBB) - [Jerboa - Lemmy の開発者が作った Android ネイティブアプリ](https://github.com/dessalines/jerboa) - [Mlem - iOS 用 Lemmy クライアント](https://github.com/buresdv/Mlem) ### ライブラリ - [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client) - [lemmy-rust-client](https://github.com/LemmyNet/lemmy/tree/main/crates/api_common) - [go-lemmy](https://gitea.arsenm.dev/Arsen6331/go-lemmy) - [Dart API client](https://github.com/LemmurOrg/lemmy_api_client) - [Lemmy-Swift-Client](https://github.com/rrainn/Lemmy-Swift-Client) - [Reddit -> Lemmy Importer](https://github.com/rileynull/RedditLemmyImporter) - [lemmy-bot - Lemmy のボットを簡単に作るための Typescript ライブラリ](https://github.com/SleeplessOne1917/lemmy-bot) - [Lemmy の Reddit API ラッパー](https://github.com/derivator/tafkars) - [Pythörhead - Lemmy API と統合するための Python パッケージ](https://pypi.org/project/pythorhead/) ## サポート / 寄付 Lemmy はフリーでオープンソースのソフトウェアです。つまり、広告やマネタイズ、ベンチャーキャピタルは一切ありません。あなたの寄付は、直接プロジェクトのフルタイム開発をサポートします。 - [Liberapay でのサポート](https://liberapay.com/Lemmy)。 - [Ko-fi でのサポート](https://ko-fi.com/lemmynet). - [Patreon でのサポート](https://www.patreon.com/dessalines)。 - [OpenCollective でのサポート](https://opencollective.com/lemmy)。 - [スポンサーのリスト](https://join-lemmy.org/donate)。 ### 暗号通貨 - bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` - ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` ## コントリビュート - [コントリビュート手順](https://join-lemmy.org/docs/en/contributors/01-overview.html) - [Docker 開発](https://join-lemmy.org/docs/en/contributors/03-docker-development.html) - [Local 開発](https://join-lemmy.org/docs/en/contributors/02-local-development.html) ### 翻訳について - 翻訳を手伝いたい方は、[Weblate](https://weblate.join-lemmy.org/projects/lemmy/) を見てみてください。また、[ドキュメントを翻訳する](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language)ことでも支援できます。 ## お問い合わせ - [Mastodon](https://mastodon.social/@LemmyDev) - [Lemmy サポートフォーラム](https://lemmy.ml/c/lemmy_support) ## コードのミラー - [GitHub](https://github.com/LemmyNet/lemmy) - [Gitea](https://git.join-lemmy.org/LemmyNet/lemmy) - [Codeberg](https://codeberg.org/LemmyNet/lemmy) ## クレジット ロゴは Andy Cuccaro (@andycuccaro) が CC-BY-SA 4.0 ライセンスで作成しました。 ================================================ FILE: readmes/README.ru.md ================================================
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg) [![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/) [![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues) [![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/) [![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/) [![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE) ![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social) [![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)

English | Español | Русский | 汉语 | 漢語 | 日本語

Lemmy

Агрегатор ссылок / Клон Reddit для федиверс.

Присоединяйтесь к Lemmy · Документация · Сообщить об Ошибке · Запросить функционал · Релизы · Нормы поведения

## О проекте | Десктоп | Мобильный | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) | [Lemmy](https://github.com/LemmyNet/lemmy) это аналог таких сайтов как [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), или [Hacker News](https://news.ycombinator.com/): вы подписываетесь на форумы, которые вас интересуют , размещаете ссылки и дискутируете, затем голосуете и комментируете их. Однако за кулисами всё совсем по-другому; любой может легко запустить сервер, и все эти серверы объединены (например электронная почта) и подключены к одной вселенной, именуемой [Федиверс](https://ru.wikipedia.org/wiki/Fediverse). Для агрегатора ссылок это означает, что пользователь, зарегистрированный на одном сервере, может подписаться на форумы на любом другом сервере и может вести обсуждения с пользователями, зарегистрированными в другом месте. Основная цель - создать легко размещаемую, децентрализованную альтернативу Reddit и другим агрегаторам ссылок, вне их корпоративного контроля и вмешательства. Каждый сервер Lemmy может устанавливать свою собственную политику модерации; назначать администраторов всего сайта и модераторов сообщества для защиты от троллей и создания здоровой, нетоксичной среды, в которой каждый может чувствовать себя комфортно. _Примечание: API-интерфейсы WebSocket и HTTP в настоящее время нестабильны_ ### Почему назвали Lemmy (рус.Лемми)? - Ведущий певец из [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U). - Старая школа [video game](). - Это [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa). - Это [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/). ### Содержит - [Rust](https://www.rust-lang.org) - [Actix](https://actix.rs/) - [Diesel](http://diesel.rs/) - [Inferno](https://infernojs.org) - [Typescript](https://www.typescriptlang.org/) ## Возможности - Открытое программное обеспечение, [Лицензия AGPL](/LICENSE). - Возможность самостоятельного размещения, простота развёртывания. - Работает на [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) и [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html). - Понятый и удобный интерфейс для мобильных устройств. - Для регистрации требуется минимум: имя пользователя и пароль! - Поддержка аватара пользователя. - Обновление цепочек комментариев в реальном времени. - Полный подсчёт голосов `(+/-)` как в старом reddit. - Темы, включая светлые, темные и солнечные. - Эмодзи с поддержкой автозаполнения. Напечатайте `:` - Упоминание пользователя тегом `@`, Упоминание сообщества тегом `!`. - Интегрированная загрузка изображений как в сообщениях, так и в комментариях. - Сообщение может состоять из заголовка и любой комбинации собственного текста, URL-адреса или чего-либо еще. - Уведомления, ответы на комментарии и когда вас отметили. - Уведомления могут быть отправлены на электронную почту. - Поддержка личных сообщений. - i18n / поддержка интернационализации. - RSS / Atom ленты для `Все`, `Подписок`, `Входящих`, `Пользователь`, and `Сообщества`. - Поддержка кросс-постинга. - _Поиск похожих постов_ при создании новых. Отлично подходит для вопросов / ответов сообществ. - Возможности модерации. - Журналы (Логи) Публичной Модерации. - Можно прикреплять посты в топ сообщества. - Оба и администраторы сайта и модераторы сообщества, могут назначать других модераторов. - Можно блокировать, удалять и восстанавливать сообщения и комментарии. - Можно банить и разблокировать пользователей в сообществе и на сайте. - Можно передавать сайт и сообщества другим. - Можно полностью стереть ваши данные, удалив все посты и комментарии. - NSFW (аббр. Небезопасный/неподходящий для работы) пост / поддерживается сообществом. - Поддержка OEmbed через Iframely. - Высокая производительность. - Сервер написан на rust. - Фронтэнд (клиентская сторона пользовательского интерфейса) всего `~80kB` архив gzipp. - Поддерживается архитектура arm64 / устройства Raspberry Pi. ## Установка - [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) - [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html) ## Проекты Lemmy ### Приложения - [lemmy-ui - Официальное веб приложение для lemmy](https://github.com/LemmyNet/lemmy-ui) - [Lemmur - Мобильные клиенты Lemmy для (Android, Linux, Windows)](https://github.com/LemmurOrg/lemmur) - [Remmel - Оригинальное приложение для iOS](https://github.com/uuttff8/Lemmy-iOS) ### Библиотеки - [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client) - [Kotlin API ( в разработке )](https://github.com/eiknat/lemmy-client) - [Dart API client ( в разработке )](https://github.com/LemmurOrg/lemmy_api_client) ## Поддержать / Пожертвовать Lemmy - бесплатное программное обеспечение с открытым исходным кодом, что означает отсутствие рекламы, монетизации и даже венчурного капитала. Ваши пожертвования, напрямую поддерживают постоянное развитие проекта. - [Поддержать на Liberapay](https://liberapay.com/Lemmy). - [Поддержать на Ko-fi](https://ko-fi.com/lemmynet). - [Поддержать на Patreon](https://www.patreon.com/dessalines). - [Поддержать на OpenCollective](https://opencollective.com/lemmy). - [Список Спонсоров](https://join-lemmy.org/sponsors). ### Криптовалюты - bitcoin (Биткоин): `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` - ethereum (Эфириум): `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - monero (Монеро): `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` ## Вклад - [Инструкции по внесению вклада](https://join-lemmy.org/docs/en/contributing/contributing.html) - [Docker разработка](https://join-lemmy.org/docs/en/contributing/docker_development.html) - [Локальное развитие](https://join-lemmy.org/docs/en/contributing/local_development.html) ### Переводы Если вы хотите помочь с переводом, взгляните на [Weblate](https://weblate.yerbamate.ml/projects/lemmy/joinlemmy/ru/). Вы также можете помочь нам [перевести документацию](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language). ## Контакт - [Mastodon](https://mastodon.social/@LemmyDev) - [Matrix](https://matrix.to/#/#lemmy:matrix.org) ## Зеркала с кодом - [GitHub](https://github.com/LemmyNet/lemmy) - [Gitea](https://yerbamate.ml/LemmyNet/lemmy) - [Codeberg](https://codeberg.org/LemmyNet/lemmy) ## Благодарность Логотип сделан Andy Cuccaro (@andycuccaro) под лицензией CC-BY-SA 4.0. ================================================ FILE: readmes/README.zh.hans.md ================================================
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg) [![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/) [![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues) [![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/) [![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/) [![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE) ![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social) [![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)

English | Español | Русский | 汉语 | 漢語 | 日本語

Lemmy

一个联邦宇宙的链接聚合器和论坛。

加入 Lemmy · 文档 · Matrix 群组 · 报告缺陷 · 请求新特性 · 发行版 · 行为准则

## 关于项目 | 桌面应用 | 移动应用 | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) | [Lemmy](https://github.com/LemmyNet/lemmy) 与 [Reddit](https://reddit.com)、[Lobste.rs](https://lobste.rs) 或 [Hacker News](https://news.ycombinator.com/) 等网站类似:你可以订阅你感兴趣的论坛,发布链接和讨论,然后进行投票或评论。但在幕后,Lemmy 和他们不同——任何人都可以很容易地运行一个服务器,所有服务器都是联邦式的(想想电子邮件),并连接到 [联邦宇宙](https://zh.wikipedia.org/wiki/%E8%81%94%E9%82%A6%E5%AE%87%E5%AE%99)。 对于一个链接聚合器来说,这意味着在一个服务器上注册的用户可以订阅任何其他服务器上的论坛,并可以与其他地方注册的用户进行讨论。 它是 Reddit 和其他链接聚合器的一个易于自托管的、分布式的替代方案,不受公司的控制和干涉。 每个 Lemmy 服务器都可以设置自己的管理政策;任命全站管理员和社区版主来阻止引战和钓鱼的用户,并培养一个健康、无毒的环境,让所有人都能放心地作出贡献。 ### 为什么叫 Lemmy? - 来自 [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U) 的主唱。 - 老式的 [电子游戏]()。 - [超级马里奥中的库巴](https://www.mariowiki.com/Lemmy_Koopa)。 - [毛茸茸的啮齿动物](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/)。 ### 采用以下项目构建 - [Rust](https://www.rust-lang.org) - [Actix](https://actix.rs/) - [Diesel](http://diesel.rs/) - [Inferno](https://infernojs.org) - [Typescript](https://www.typescriptlang.org/) ## 特性 - 开源,采用 [AGPL 协议](/LICENSE)。 - 可自托管,易于部署。 - 附带 [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) 或 [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)。 - 干净、移动设备友好的界面。 - 仅需用户名和密码就可以注册! - 支持用户头像。 - 实时更新的评论串。 - 类似旧版 Reddit 的评分功能 `(+/-)`。 - 主题,有深色 / 浅色主题和 Solarized 主题。 - Emoji 和自动补全。输入 `:` 开始。 - 通过 `@` 提及用户,`!` 提及社区。 - 在帖子和评论中都集成了图片上传功能。 - 一个帖子可以由一个标题和自我文本的任何组合组成,一个 URL,或没有其他。 - 评论回复和提及时的通知。 - 通知可通过电子邮件发送。 - 支持私信。 - i18n(国际化)支持。 - `All`、`Subscribed`、`Inbox`、`User` 和 `Community` 的 RSS / Atom 订阅。 - 支持多重发布。 - 在创建新的帖子时,有 _相似帖子_ 的建议,对问答式社区很有帮助。 - 监管能力。 - 公开的修改日志。 - 可以把帖子在社区置顶。 - 既有网站管理员,也有可以任命其他版主社区版主。 - 可以锁定、删除和恢复帖子和评论。 - 可以禁止和解禁社区和网站的用户。 - 可以将网站和社区转让给其他人。 - 可以完全删除你的数据,替换所有的帖子和评论。 - NSFW 帖子 / 社区支持。 - 高性能。 - 服务器采用 Rust 编写。 - 前端 gzip 后约 `~80kB`。 - 支持 arm64 架构和树莓派。 ## 安装 - [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) - [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html) ## Lemmy 项目 ### 应用 - [lemmy-ui - Lemmy 的官方网页应用](https://github.com/LemmyNet/lemmy-ui) - [Lemmur - 一个 Lemmy 的移动客户端(支持安卓、Linux、Windows)](https://github.com/LemmurOrg/lemmur) - [Jerboa - 一个由 Lemmy 的开发者打造的原生 Android 应用](https://github.com/dessalines/jerboa) - [Remmel - 一个原生 iOS 应用](https://github.com/uuttff8/Lemmy-iOS) ### 库 - [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client) - [Kotlin API (尚在开发)](https://github.com/eiknat/lemmy-client) - [Dart API client](https://github.com/LemmurOrg/lemmy_api_client) ## 支持和捐助 Lemmy 是免费的开源软件,无广告,无营利,无风险投资。您的捐款直接支持我们全职开发这一项目。 - [在 Liberapay 上支持](https://liberapay.com/Lemmy)。 - [在 Ko-fi 上支持](https://ko-fi.com/lemmynet). - [在 Patreon 上支持](https://www.patreon.com/dessalines)。 - [在 OpenCollective 上支持](https://opencollective.com/lemmy)。 - [赞助者列表](https://join-lemmy.org/sponsors)。 ### 加密货币 - 比特币:`1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` - 以太坊: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - 门罗币:`41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` - 艾达币:`addr1q858t89l2ym6xmrugjs0af9cslfwvnvsh2xxp6x4dcez7pf5tushkp4wl7zxfhm2djp6gq60dk4cmc7seaza5p3slx0sakjutm` ## 贡献 - [贡献指南](https://join-lemmy.org/docs/en/contributing/contributing.html) - [Docker 开发](https://join-lemmy.org/docs/en/contributing/docker_development.html) - [本地开发](https://join-lemmy.org/docs/en/contributing/local_development.html) ### 翻译 如果你想帮助翻译,请至 [Weblate](https://weblate.yerbamate.ml/projects/lemmy/);也可以 [翻译文档](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language)。 ## 联系 - [Mastodon](https://mastodon.social/@LemmyDev) - [Lemmy 支持论坛](https://lemmy.ml/c/lemmy_support) ## 代码镜像 - [GitHub](https://github.com/LemmyNet/lemmy) - [Gitea](https://yerbamate.ml/LemmyNet/lemmy) - [Codeberg](https://codeberg.org/LemmyNet/lemmy) ## 致谢 Logo 由 Andy Cuccaro (@andycuccaro) 制作,采用 CC-BY-SA 4.0 协议释出。 ================================================ FILE: readmes/README.zh.hant.md ================================================
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg) [![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/) [![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues) [![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/) [![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/) [![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE) ![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social) [![Delightful Humane Tech](https://codeberg.org/teaserbot-labs/delightful-humane-design/raw/branch/main/humane-tech-badge.svg)](https://codeberg.org/teaserbot-labs/delightful-humane-design)

English | Español | Русский | 汉语 | 漢語 | 日本語

Lemmy

一個聯邦宇宙的連結聚合器和論壇。

加入 Lemmy · 文檔 · Matrix 群組 · 報告缺陷 · 請求新特性 · 發行版 · 行為準則

## 關於專案 | 桌面設備 | 行動裝置 | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) | [Lemmy](https://github.com/LemmyNet/lemmy) 與 [Reddit](https://reddit.com)、[Lobste.rs](https://lobste.rs) 或 [Hacker News](https://news.ycombinator.com/) 等網站類似:你可以訂閱你感興趣的論壇,釋出連結和討論,然後進行投票或評論。但在幕後,Lemmy 和他們不同——任何人都可以很容易地架設一個伺服器,所有伺服器都是聯邦式的(想想電子郵件),並與 [聯邦宇宙](https://zh.wikipedia.org/wiki/%E8%81%94%E9%82%A6%E5%AE%87%E5%AE%99) 互聯。 對於一個連結聚合器來說,這意味著在一個伺服器上註冊的使用者可以訂閱任何其他伺服器上的論壇,並可以與其他地方註冊的使用者進行討論。 它是 Reddit 和其他連結聚合器的一個易於自託管的、分散式的替代方案,不受公司的控制和干涉。 每個 Lemmy 伺服器都可以設定自己的管理政策;任命全站管理員和社群版主來阻止網路白目,並培養一個健康、無毒的環境,讓所有人都能放心地作出貢獻。 ### 為什麼叫 Lemmy? - 來自 [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U) 的主唱。 - 老式的 [電子遊戲]()。 - [超級馬里奧中的庫巴](https://www.mariowiki.com/Lemmy_Koopa)。 - [毛茸茸的齧齒動物](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/)。 ### 採用以下專案構建 - [Rust](https://www.rust-lang.org) - [Actix](https://actix.rs/) - [Diesel](http://diesel.rs/) - [Inferno](https://infernojs.org) - [Typescript](https://www.typescriptlang.org/) ## 特性 - 開源,採用 [AGPL 協議](/LICENSE)。 - 可自託管,易於部署。 - 附帶 [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) 或 [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html)。 - 乾淨、移動裝置友好的介面。 - 僅需使用者名稱和密碼就可以註冊! - 支援使用者頭像。 - 實時更新的評論串。 - 類似舊版 Reddit 的評分功能 `(+/-)`。 - 主題,有深色 / 淺色主題和 Solarized 主題。 - Emoji 和自動補全。輸入 `:` 開始。 - 透過 `@` 提及使用者,`!` 提及社群。 - 在帖子和評論中都集成了圖片上傳功能。 - 一個帖子可以由一個標題和自我文字的任何組合組成,一個 URL,或沒有其他。 - 評論回覆和提及時的通知。 - 通知可透過電子郵件傳送。 - 支援私信。 - i18n(國際化)支援。 - `All`、`Subscribed`、`Inbox`、`User` 和 `Community` 的 RSS / Atom 訂閱。 - 支援多重發布。 - 在建立新的帖子時,有 _相似帖子_ 的建議,對問答式社群很有幫助。 - 監管能力。 - 公開的修改日誌。 - 可以把帖子在社群置頂。 - 既有網站管理員,也有可以任命其他版主社群版主。 - 可以鎖定、刪除和恢復帖子和評論。 - 可以封鎖和解除封鎖社群和網站的使用者。 - 可以將網站和社群轉讓給其他人。 - 可以完全刪除你的資料,替換所有的帖子和評論。 - NSFW 帖子 / 社群支援。 - 高效能。 - 伺服器採用 Rust 編寫。 - 前端 gzip 後約 `~80kB`。 - 支援 arm64 架構和樹莓派。 ## 安裝 - [Docker](https://join-lemmy.org/docs/en/administration/install_docker.html) - [Ansible](https://join-lemmy.org/docs/en/administration/install_ansible.html) ## Lemmy 專案 ### 應用 - [lemmy-ui - Lemmy 的官方網頁應用](https://github.com/LemmyNet/lemmy-ui) - [Lemmur - 一個 Lemmy 的行動應用程式(支援安卓、Linux、Windows)](https://github.com/LemmurOrg/lemmur) - [Jerboa - 一個由 Lemmy 的開發者打造的原生 Android 應用程式](https://github.com/dessalines/jerboa) - [Remmel - 一個原生 iOS 應用程式](https://github.com/uuttff8/Lemmy-iOS) ### 庫 - [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client) - [Kotlin API (尚在開發)](https://github.com/eiknat/lemmy-client) - [Dart API client](https://github.com/LemmurOrg/lemmy_api_client) ## 支援和捐助 Lemmy 是免費的開放原始碼軟體,無廣告,無營利,無風險投資。您的捐款直接支援我們全職開發這一專案。 - [在 Liberapay 上支援](https://liberapay.com/Lemmy)。 - [在 Ko-fi 上支援](https://ko-fi.com/lemmynet). - [在 Patreon 上支援](https://www.patreon.com/dessalines)。 - [在 OpenCollective 上支援](https://opencollective.com/lemmy)。 - [贊助者列表](https://join-lemmy.org/sponsors)。 ### 加密貨幣 - 比特幣:`1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK` - 以太坊:`0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01` - 門羅幣:`41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV` - 艾達幣:`addr1q858t89l2ym6xmrugjs0af9cslfwvnvsh2xxp6x4dcez7pf5tushkp4wl7zxfhm2djp6gq60dk4cmc7seaza5p3slx0sakjutm` ## 貢獻 - [貢獻指南](https://join-lemmy.org/docs/en/contributing/contributing.html) - [Docker 開發](https://join-lemmy.org/docs/en/contributing/docker_development.html) - [本地開發](https://join-lemmy.org/docs/en/contributing/local_development.html) ### 翻譯 如果你想幫助翻譯,請至 [Weblate](https://weblate.yerbamate.ml/projects/lemmy/);也可以 [翻譯文檔](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language)。 ## 聯絡 - [Mastodon](https://mastodon.social/@LemmyDev) - [Lemmy 支援論壇](https://lemmy.ml/c/lemmy_support) ## 程式碼鏡像 - [GitHub](https://github.com/LemmyNet/lemmy) - [Gitea](https://yerbamate.ml/LemmyNet/lemmy) - [Codeberg](https://codeberg.org/LemmyNet/lemmy) ## 致謝 Logo 由 Andy Cuccaro (@andycuccaro) 製作,採用 CC-BY-SA 4.0 協議釋出。 ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "1.94" ================================================ FILE: scripts/alpine_install_pg_formatter.sh ================================================ #!/usr/bin/env bash set -e version=5.9 wget https://github.com/darold/pgFormatter/archive/refs/tags/v${version}.tar.gz -q tar xzf v${version}.tar.gz cd pgFormatter-${version}/ perl Makefile.PL make && make install cd ../ && rm -rf v${version}.tar.gz && rm -rf pgFormatter-${version} #clean up ================================================ FILE: scripts/clean-workspace.sh ================================================ #!/bin/bash set -e # Run `cargo clean -p` for each workspace member. This allows to accurately measure the time for # an incremental build. clear && cargo metadata --no-deps | jq .packages.[].name | sed 's/.*/-p &/' | xargs cargo clean ================================================ FILE: scripts/clear_db.sh ================================================ #!/usr/bin/env bash psql -U lemmy -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA utils CASCADE;" ================================================ FILE: scripts/compilation_benchmark.sh ================================================ #!/usr/bin/env bash set -e times=3 duration=0 for ((i = 0; i < times; i++)); do echo "Starting iteration $i" echo "cargo clean" # to benchmark incremental compilation time, do a full build with the same compiler version first, # and use the following clean command: cargo clean -p lemmy_utils #cargo clean echo "cargo build" start=$(date +%s.%N) RUSTC_WRAPPER='' cargo build -q end=$(date +%s.%N) echo "Finished iteration $i after $(bc <<<"scale=0; $end - $start") seconds" duration=$(bc <<<"$duration + $end - $start") done average=$(bc <<<"scale=0; $duration / $times") echo "Average compilation time over $times runs is $average seconds" ================================================ FILE: scripts/db-init.sh ================================================ #!/usr/bin/env bash set -e # Default configurations username=lemmy password=password dbname=lemmy port=5432 yes_no_prompt_invalid() { echo "Invalid input. Please enter either \"y\" or \"n\"." 1>&2 } print_config() { echo " database name: $dbname" echo " username: $username" echo " password: $password" echo " port: $port" } ask_for_db_config() { echo "The default database configuration is:" print_config echo default_config_final=0 default_config_valid=0 while [ "$default_config_valid" == 0 ]; do read -p "Use this configuration (y/n)? " default_config case "$default_config" in [yY]*) default_config_valid=1 default_config_final=1 ;; [nN]*) default_config_valid=1 default_config_final=0 ;; *) yes_no_prompt_invalid ;; esac echo done if [ "$default_config_final" == 0 ]; then config_ok_final=0 while [ "$config_ok_final" == 0 ]; do read -p "Database name: " dbname read -p "Username: " username read -p "Password: password" read -p "Port: " port #echo #echo "The database configuration is:" #print_config #echo config_ok_valid=0 while [ "$config_ok_valid" == 0 ]; do read -p "Use this configuration (y/n)? " config_ok case "$config_ok" in [yY]*) config_ok_valid=1 config_ok_final=1 ;; [nN]*) config_ok_valid=1 config_ok_final=0 ;; *) yes_no_prompt_invalid ;; esac echo done done fi } ask_for_db_config psql -c "CREATE USER $username WITH PASSWORD '$password' SUPERUSER;" -U postgres psql -c "CREATE DATABASE $dbname WITH OWNER $username;" -U postgres export LEMMY_DATABASE_URL=postgres://$username:$password@localhost:$port/$dbname echo "The database URL is $LEMMY_DATABASE_URL" ================================================ FILE: scripts/db_perf.sh ================================================ #!/usr/bin/env bash # This script runs crates/db_views/post/src/db_perf/mod.rs, which lets you see info related to database query performance, such as query plans. set -e CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" source scripts/start_dev_db.sh export LEMMY_CONFIG_LOCATION=$(pwd)/config/config.hjson export RUST_BACKTRACE=1 cargo test -p lemmy_db_views_post --features full --no-fail-fast db_perf -- --nocapture pg_ctl stop --silent # $PGDATA directory is kept so log can be seen ================================================ FILE: scripts/dump_schema.sh ================================================ #!/usr/bin/env bash set -e # Dumps database schema, not including things that are added outside of migrations CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" source scripts/start_dev_db.sh cargo run --package lemmy_diesel_utils pg_dump --no-owner --no-privileges --no-table-access-method --schema-only --exclude-schema=r --no-sync -f schema.sqldump pg_ctl stop rm -rf $PGDATA ================================================ FILE: scripts/install.sh ================================================ #!/usr/bin/env bash set -e # Set the database variable to the default first. # Don't forget to change this string to your actual database parameters # if you don't plan to initialize the database in this script. export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy # Set other environment variables export JWT_SECRET=changeme export HOSTNAME=rrr yes_no_prompt_invalid() { echo "Invalid input. Please enter either \"y\" or \"n\"." 1>&2 } ask_to_init_db() { init_db_valid=0 init_db_final=0 while [ "$init_db_valid" == 0 ]; do read -p "Initialize database (y/n)? " init_db case "$init_db" in [yY]*) init_db_valid=1 init_db_final=1 ;; [nN]*) init_db_valid=1 init_db_final=0 ;; *) yes_no_prompt_invalid ;; esac echo done if [ "$init_db_final" = 1 ]; then source ./db-init.sh read -n 1 -s -r -p "Press ANY KEY to continue execution of this script, press CTRL+C to quit..." echo fi } ask_to_auto_reload() { auto_reload_valid=0 auto_reload_final=0 while [ "$auto_reload_valid" == 0 ]; do echo "Automagically reload the project when source files are changed?" echo "ONLY ENABLE THIS FOR DEVELOPMENT!" read -p "(y/n) " auto_reload case "$auto_reload" in [yY]*) auto_reload_valid=1 auto_reload_final=1 ;; [nN]*) auto_reload_valid=1 auto_reload_final=0 ;; *) yes_no_prompt_invalid ;; esac echo done if [ "$auto_reload_final" = 1 ]; then cd ui && pnpm dev cd server && cargo watch -x run fi } # Optionally initialize the database ask_to_init_db # Build the web client cd ui pnpm i pnpm prebuild:prod pnpm build:prod # Build and run the backend cd ../server RUST_LOG=debug cargo run # For live coding, where both the front and back end, automagically reload on any save ask_to_auto_reload ================================================ FILE: scripts/lint.sh ================================================ #!/usr/bin/env bash set -e CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" # Format rust files cargo +nightly fmt # Format toml files taplo format # Format sql files find migrations crates/diesel_utils/replaceable_schema -type f -name '*.sql' -print0 | xargs -0 -P 10 -L 10 pg_format -i cargo clippy --workspace --fix --allow-staged --allow-dirty --tests --all-targets --all-features -- -D warnings ================================================ FILE: scripts/postgres_12_to_15_upgrade.sh ================================================ #!/bin/sh set -e echo "Updating docker-compose to use postgres version 16." sudo sed -i "s/image: .*postgres:.*/image: pgautoupgrade\/pgautoupgrade:16-alpine/" ./docker-compose.yml echo "Starting up lemmy..." sudo docker-compose up -d ================================================ FILE: scripts/postgres_15_to_16_upgrade.sh ================================================ #!/bin/sh set -e echo "Updating docker-compose to use postgres version 16." sudo sed -i "s/image: .*postgres:.*/image: pgautoupgrade\/pgautoupgrade:16-alpine/" ./docker-compose.yml echo "Starting up lemmy..." sudo docker-compose up -d ================================================ FILE: scripts/query_testing/apache_bench_report.sh ================================================ #!/usr/bin/env bash set -e declare -a arr=( "https://mastodon.social/" "https://peertube.social/" "https://lemmy.ml/" "https://lemmy.ml/feeds/all.xml" "https://lemmy.ml/.well-known/nodeinfo" "https://fediverse.blog/.well-known/nodeinfo" "https://torrents-csv.ml/service/search?q=wheel&page=1&type_=torrent" ) ## check if ab installed if ! [ -x "$(command -v ab)" ]; then echo 'Error: ab (Apache Bench) is not installed. https://httpd.apache.org/docs/2.4/programs/ab.html' >&2 exit 1 fi ## now loop through the above array for i in "${arr[@]}"; do ab -c 10 -t 10 "$i" >out.abtest grep "Server Hostname:" out.abtest grep "Document Path:" out.abtest grep "Requests per second" out.abtest grep "(mean, across all concurrent requests)" out.abtest grep "Transfer rate:" out.abtest echo "---" done rm *.abtest ================================================ FILE: scripts/query_testing/api_benchmark.sh ================================================ #!/usr/bin/env bash set -e # By default, this script runs against `http://127.0.0.1:8536`, but you can pass a different Lemmy instance, # eg `./api_benchmark.sh "https://example.com"`. DOMAIN=${1:-"http://127.0.0.1:8536"} declare -a arr=( "/api/v1/site" "/api/v1/categories" "/api/v1/modlog" "/api/v1/search?q=test&type_=Posts&sort=Hot" "/api/v1/community" "/api/v1/community/list?sort=Hot" "/api/v1/post/list?sort=Hot&type_=All" ) ## check if ab installed if ! [ -x "$(command -v ab)" ]; then echo 'Error: ab (Apache Bench) is not installed. https://httpd.apache.org/docs/2.4/programs/ab.html' >&2 exit 1 fi ## now loop through the above array for path in "${arr[@]}"; do URL="$DOMAIN$path" printf "\n\n\n" echo "testing $URL" curl --show-error --fail --silent "$URL" >/dev/null ab -c 64 -t 10 "$URL" >out.abtest grep "Server Hostname:" out.abtest grep "Document Path:" out.abtest grep "Requests per second" out.abtest grep "(mean, across all concurrent requests)" out.abtest grep "Transfer rate:" out.abtest echo "---" done rm *.abtest ================================================ FILE: scripts/query_testing/bulk_upsert_timings.md ================================================ # post_read -> post_actions bulk upsert timings ## normal, 1 month: 491s Insert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=169235.026..169235.026 rows=0 loops=1) Conflict Resolution: UPDATE Conflict Arbiter Indexes: post_actions_pkey Tuples Inserted: 5175253 Conflicting Tuples: 0 -> Index Scan using idx_post_read_published_desc on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=47.762..39310.551 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.234 ms Trigger for constraint post_actions_person_id_fkey: time=118828.666 calls=5175253 Trigger for constraint post_actions_post_id_fkey: time=203098.355 calls=5175253 JIT: Functions: 6 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.448 ms, Inlining 0.000 ms, Optimization 0.201 ms, Emission 44.721 ms, Total 45.369 ms Execution Time: 491991.365 ms (15 rows) ## disabled triggers, keep pkey, on conflict: 167s Insert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=167261.176..167261.176 rows=0 loops=1) Conflict Resolution: UPDATE Conflict Arbiter Indexes: post_actions_pkey Tuples Inserted: 5175253 Conflicting Tuples: 0 -> Index Scan using idx_tmp_1 on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=5.604..59193.030 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.147 ms JIT: Functions: 6 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.490 ms, Inlining 0.000 ms, Optimization 0.197 ms, Emission 3.989 ms, Total 4.675 ms Execution Time: 167261.807 ms ## disabled triggers, with pkey, insert only: 91s Insert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=91820.768..91820.769 rows=0 loops=1) -> Index Scan using idx_tmp_1 on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=5.482..40066.185 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.098 ms JIT: Functions: 5 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.490 ms, Inlining 0.000 ms, Optimization 0.208 ms, Emission 3.894 ms, Total 4.592 ms Execution Time: 91821.724 ms ## disabled triggers, no pkey, insert only: 57s Insert on post_actions (cost=0.57..371215.69 rows=0 width=0) (actual time=56797.431..56797.432 rows=0 loops=1) -> Index Scan using idx_tmp_1 on post_read (cost=0.57..371215.69 rows=5190811 width=58) (actual time=4.827..27903.829 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.096 ms JIT: Functions: 5 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.390 ms, Inlining 0.000 ms, Optimization 0.232 ms, Emission 3.373 ms, Total 3.994 ms Execution Time: 56798.022 ms ## disabled triggers, merge instead of upsert: 77s Merge on post_actions pa (cost=34.06..280379.97 rows=0 width=0) (actual time=76988.823..76988.825 rows=0 loops=1) Tuples: inserted=5175253 -> Hash Left Join (cost=34.06..280379.97 rows=1098137 width=28) (actual time=8.109..12202.884 rows=5175253 loops=1) Hash Cond: ((post_read.person_id = pa.person_id) AND (post_read.post_id = pa.post_id)) -> Index Scan using idx_tmp_1 on post_read (cost=0.56..274581.25 rows=1098137 width=22) (actual time=8.094..11432.132 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) -> Hash (cost=19.40..19.40 rows=940 width=14) (actual time=0.003..0.004 rows=0 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 8kB -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=940 width=14) (actual time=0.003..0.003 rows=0 loops=1) Planning Time: 0.468 ms JIT: Functions: 17 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.897 ms, Inlining 0.000 ms, Optimization 0.399 ms, Emission 7.650 ms, Total 8.946 ms Execution Time: 76989.946 ms ## disabled triggers, merge, no pkey: 39s Merge on post_actions pa (cost=297488.30..303957.64 rows=0 width=0) (actual time=39009.474..39009.477 rows=0 loops=1) Tuples: inserted=5175253 -> Hash Right Join (cost=297488.30..303957.64 rows=1098137 width=28) (actual time=3412.832..5353.677 rows=5175253 loops=1) Hash Cond: ((pa.person_id = post_read.person_id) AND (pa.post_id = post_read.post_id)) -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=940 width=14) (actual time=0.004..0.005 rows=0 loops=1) -> Hash (cost=274581.25..274581.25 rows=1098137 width=22) (actual time=3412.178..3412.180 rows=5175253 loops=1) Buckets: 131072 (originally 131072) Batches: 64 (originally 16) Memory Usage: 7169kB -> Index Scan using idx_tmp_1 on post_read (cost=0.56..274581.25 rows=1098137 width=22) (actual time=8.495..2299.278 rows=5175253 loops=1) Index Cond: (published > (CURRENT_DATE - '6 mons'::interval)) Planning Time: 0.465 ms JIT: Functions: 17 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 0.988 ms, Inlining 0.000 ms, Optimization 0.350 ms, Emission 8.127 ms, Total 9.465 ms Execution Time: 39011.515 ms ## same as above, full table: 425s Merge on post_actions pa (cost=1478580.50..1520165.83 rows=0 width=0) (actual time=425751.243..425751.245 rows=0 loops=1) Tuples: inserted=33519660 -> Hash Right Join (cost=1478580.50..1520165.83 rows=7091220 width=28) (actual time=72968.237..120866.662 rows=33519660 loops=1) Hash Cond: ((pa.person_id = pr.person_id) AND (pa.post_id = pr.post_id)) -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=940 width=14) (actual time=0.004..0.004 rows=0 loops=1) -> Hash (cost=1330661.20..1330661.20 rows=7091220 width=22) (actual time=72967.590..72967.591 rows=33519660 loops=1) Buckets: 131072 (originally 131072) Batches: 256 (originally 64) Memory Usage: 7927kB -> Seq Scan on post_read pr (cost=0.00..1330661.20 rows=7091220 width=22) (actual time=103.545..51892.728 rows=33519660 loops=1) Planning Time: 0.393 ms JIT: Functions: 14 Options: Inlining true, Optimization true, Expressions true, Deforming true Timing: Generation 0.840 ms, Inlining 11.303 ms, Optimization 45.211 ms, Emission 40.003 ms, Total 97.357 ms Execution Time: 425753.438 ms ## disabled triggers, merge, with pkey, full table: 587s Merge on post_actions pa (cost=19.47..1367909.58 rows=0 width=0) (actual time=587295.757..587295.759 rows=0 loops=1) Tuples: inserted=33519660 -> Hash Left Join (cost=19.47..1367909.58 rows=7091220 width=28) (actual time=77.291..46496.679 rows=33519660 loops=1) Hash Cond: ((pr.person_id = pa.person_id) AND (pr.post_id = pa.post_id)) -> Seq Scan on post_read pr (cost=0.00..1330661.20 rows=7091220 width=22) (actual time=77.266..41178.528 rows=33519660 loops=1) -> Hash (cost=19.40..19.40 rows=5 width=14) (actual time=0.006..0.007 rows=0 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 8kB -> Seq Scan on post_actions pa (cost=0.00..19.40 rows=5 width=14) (actual time=0.006..0.006 rows=0 loops=1) Filter: (read IS NULL) Planning Time: 0.428 ms JIT: Functions: 16 Options: Inlining true, Optimization true, Expressions true, Deforming true Timing: Generation 0.922 ms, Inlining 6.324 ms, Optimization 37.862 ms, Emission 33.076 ms, Total 78.183 ms Execution Time: 587297.207 ms (15 rows) ## disabled triggers, merge, no pkey, full table: 359s ## disabled triggers, merge, no pkey, person_post_aggs after post_read: 1260s ## disabled triggers, no pkey, post_read + person_post_aggs union all with group by insert (no upsert or merge): 402s ### Merge example: ```sql EXPLAIN ANALYZE MERGE INTO post_actions pa USING post_read pr ON (pa.person_id = pr.person_id AND pa.post_id = pr.post_id ) WHEN MATCHED THEN UPDATE SET read = pr.published WHEN NOT MATCHED THEN INSERT (person_id, post_id, read) VALUES (pr.person_id, pr.post_id, pr.published); ``` ## comment aggregate bulk update: 3881s / 65m ================================================ FILE: scripts/query_testing/post_query_hot_rank.sh ================================================ #!/bin/bash sudo docker exec -i docker-postgres-1 psql -Ulemmy -c "EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) SELECT post.id, post.name, post.url, post.body, post.creator_id, post.community_id, post.removed, post.locked, post.published, post.updated, post.deleted, post.nsfw, post.embed_title, post.embed_description, post.embed_video_url, post.thumbnail_url, post.ap_id, post.local, post.language_id, post.featured_community, post.featured_local, person.id, person.name, person.display_name, person.avatar, person.banned, person.published, person.updated, person.actor_id, person.bio, person.local, person.banner, person.deleted, person.inbox_url, person.shared_inbox_url, person.matrix_user_id, person.admin, person.bot_account, person.ban_expires, person.instance_id, community.id, community.name, community.title, community.description, community.removed, community.published, community.updated, community.deleted, community.nsfw, community.actor_id, community.local, community.icon, community.banner, community.hidden, community.posting_restricted_to_mods, community.instance_id, community_person_ban.id, community_person_ban.community_id, community_person_ban.person_id, community_person_ban.published, community_person_ban.expires, post_aggregates.id, post_aggregates.post_id, post_aggregates.comments, post_aggregates.score, post_aggregates.upvotes, post_aggregates.downvotes, post_aggregates.published, post_aggregates.newest_comment_time_necro, post_aggregates.newest_comment_time, post_aggregates.featured_community, post_aggregates.featured_local, community_follower.id, community_follower.community_id, community_follower.person_id, community_follower.published, community_follower.pending, post_saved.id, post_saved.post_id, post_saved.person_id, post_saved.published, post_read.id, post_read.post_id, post_read.person_id, post_read.published, person_block.id, person_block.person_id, person_block.target_id, person_block.published, post_like.score, coalesce((post_aggregates.comments - person_post_aggregates.read_comments), post_aggregates.comments) FROM ((((((((((((post INNER JOIN person ON (post.creator_id = person.id)) INNER JOIN community ON (post.community_id = community.id)) LEFT OUTER JOIN community_person_ban ON (((post.community_id = community_person_ban.community_id) AND (community_person_ban.person_id = post.creator_id)) AND ((community_person_ban.expires IS NULL) OR (community_person_ban.expires > CURRENT_TIMESTAMP)))) INNER JOIN post_aggregates ON (post_aggregates.post_id = post.id)) LEFT OUTER JOIN community_follower ON ((post.community_id = community_follower.community_id) AND (community_follower.person_id = '33517'))) LEFT OUTER JOIN post_saved ON ((post.id = post_saved.post_id) AND (post_saved.person_id = '33517'))) LEFT OUTER JOIN post_read ON ((post.id = post_read.post_id) AND (post_read.person_id = '33517'))) LEFT OUTER JOIN person_block ON ((post.creator_id = person_block.target_id) AND (person_block.person_id = '33517'))) LEFT OUTER JOIN community_block ON ((community.id = community_block.community_id) AND (community_block.person_id = '33517'))) LEFT OUTER JOIN post_like ON ((post.id = post_like.post_id) AND (post_like.person_id = '33517'))) LEFT OUTER JOIN person_post_aggregates ON ((post.id = person_post_aggregates.post_id) AND (person_post_aggregates.person_id = '33517'))) LEFT OUTER JOIN local_user_language ON ((post.language_id = local_user_language.language_id) AND (local_user_language.local_user_id = '11402'))) WHERE ((((((((((community_follower.person_id IS NOT NULL) AND (post.nsfw = 'f')) AND (community.nsfw = 'f')) AND (local_user_language.language_id IS NOT NULL)) AND (community_block.person_id IS NULL)) AND (person_block.person_id IS NULL)) AND (post.removed = 'f')) AND (post.deleted = 'f')) AND (community.removed = 'f')) AND (community.deleted = 'f')) ORDER BY post_aggregates.featured_local DESC , post_aggregates.hot_rank DESC LIMIT '40' OFFSET '0';" >query_results.json ================================================ FILE: scripts/query_testing/views_old/generate_reports.sh ================================================ #!/usr/bin/env bash set -e # You can import these to http://tatiyants.com/pev/#/plans/new pushd reports # Do the views first PSQL_CMD="docker exec -i dev_postgres_1 psql -qAt -U lemmy" echo "explain (analyze, format json) select * from user_fast limit 100" >explain.sql cat explain.sql | $PSQL_CMD >user_fast.json echo "explain (analyze, format json) select * from post_view where user_id is null order by hot_rank desc, published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_view.json echo "explain (analyze, format json) select * from post_fast_view where user_id is null order by hot_rank desc, published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_fast_view.json echo "explain (analyze, format json) select * from comment_view where user_id is null limit 100" >explain.sql cat explain.sql | $PSQL_CMD >comment_view.json echo "explain (analyze, format json) select * from comment_fast_view where user_id is null limit 100" >explain.sql cat explain.sql | $PSQL_CMD >comment_fast_view.json echo "explain (analyze, format json) select * from community_view where user_id is null order by hot_rank desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >community_view.json echo "explain (analyze, format json) select * from community_fast_view where user_id is null order by hot_rank desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >community_fast_view.json echo "explain (analyze, format json) select * from site_view limit 100" >explain.sql cat explain.sql | $PSQL_CMD >site_view.json echo "explain (analyze, format json) select * from reply_fast_view where user_id = 34 and recipient_id = 34 limit 100" >explain.sql cat explain.sql | $PSQL_CMD >reply_fast_view.json echo "explain (analyze, format json) select * from user_mention_view where user_id = 34 and recipient_id = 34 limit 100" >explain.sql cat explain.sql | $PSQL_CMD >user_mention_view.json echo "explain (analyze, format json) select * from user_mention_fast_view where user_id = 34 and recipient_id = 34 limit 100" >explain.sql cat explain.sql | $PSQL_CMD >user_mention_fast_view.json grep "Execution Time" *.json >../timings-$(date +%Y-%m-%d_%H-%M-%S).out rm explain.sql popd ================================================ FILE: scripts/query_testing/views_old/timings-2021-01-05_21-06-37.out ================================================ comment_fast_view.json: "Execution Time": 93.165 comment_view.json: "Execution Time": 4513.485 community_fast_view.json: "Execution Time": 3.998 community_view.json: "Execution Time": 561.814 post_fast_view.json: "Execution Time": 1604.543 post_view.json: "Execution Time": 11630.471 reply_fast_view.json: "Execution Time": 85.708 site_view.json: "Execution Time": 27.264 user_fast.json: "Execution Time": 0.135 user_mention_fast_view.json: "Execution Time": 6.665 user_mention_view.json: "Execution Time": 4996.688 ================================================ FILE: scripts/query_testing/views_to_diesel_migration/generate_reports.sh ================================================ #!/usr/bin/env bash set -e # You can import these to http://tatiyants.com/pev/#/plans/new pushd reports PSQL_CMD="docker exec -i dev_postgres_1 psql -qAt -U lemmy" echo "explain (analyze, format json) select * from user_ limit 100" >explain.sql cat explain.sql | $PSQL_CMD >user_.json echo "explain (analyze, format json) select * from post p limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post.json echo "explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by hot_rank(pa.score, pa.published) desc, pa.published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_ordered_by_rank.json echo "explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.stickied desc, hot_rank(pa.score, pa.published) desc, pa.published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_ordered_by_stickied_then_rank.json echo "explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.score desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_ordered_by_score.json echo "explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.stickied desc, pa.score desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_ordered_by_stickied_then_score.json echo "explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_ordered_by_published.json echo "explain (analyze, format json) select * from post p, post_aggregates pa where p.id = pa.post_id order by pa.stickied desc, pa.published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >post_ordered_by_stickied_then_published.json echo "explain (analyze, format json) select * from comment limit 100" >explain.sql cat explain.sql | $PSQL_CMD >comment.json echo "explain (analyze, format json) select * from community limit 100" >explain.sql cat explain.sql | $PSQL_CMD >community.json echo "explain (analyze, format json) select * from community c, community_aggregates ca where c.id = ca.community_id order by hot_rank(ca.subscribers, ca.published) desc, ca.published desc limit 100" >explain.sql cat explain.sql | $PSQL_CMD >community_ordered_by_subscribers.json echo "explain (analyze, format json) select * from site s" >explain.sql cat explain.sql | $PSQL_CMD >site.json echo "explain (analyze, format json) select * from user_mention limit 100" >explain.sql cat explain.sql | $PSQL_CMD >user_mention.json echo "explain (analyze, format json) select * from private_message limit 100" >explain.sql cat explain.sql | $PSQL_CMD >private_message.json grep "Execution Time" *.json >../timings-$(date +%Y-%m-%d_%H-%M-%S).out rm explain.sql popd ================================================ FILE: scripts/query_testing/views_to_diesel_migration/timings-2021-01-05_21-32-54.out ================================================ comment.json: "Execution Time": 0.136 community.json: "Execution Time": 0.157 community_ordered_by_subscribers.json: "Execution Time": 16.036 post.json: "Execution Time": 0.129 post_ordered_by_rank.json: "Execution Time": 15.969 private_message.json: "Execution Time": 0.133 site.json: "Execution Time": 0.056 user_.json: "Execution Time": 0.300 user_mention.json: "Execution Time": 0.122 ================================================ FILE: scripts/release.bash ================================================ #!/bin/sh set -e #git checkout main # Creating the new tag new_tag="$1" third_semver=$(echo $new_tag | cut -d "." -f 3) # Goto the upper route CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" # The docker installs should only update for non release-candidates # IE, when the third semver is a number, not '2-rc' if [ ! -z "${third_semver##*[!0-9]*}" ]; then pushd docker sed -i "s/dessalines\/lemmy:.*/dessalines\/lemmy:$new_tag/" docker-compose.yml sed -i "s/dessalines\/lemmy-ui:.*/dessalines\/lemmy-ui:$new_tag/" docker-compose.yml sed -i "s/dessalines\/lemmy-ui:.*/dessalines\/lemmy-ui:$new_tag/" federation/docker-compose.yml git add docker-compose.yml git add federation/docker-compose.yml popd fi # Update crate versions old_tag=$(grep version Cargo.toml | head -1 | cut -d'"' -f 2) sed -i "s/{ version = \"=$old_tag\", path/{ version = \"=$new_tag\", path/g" Cargo.toml sed -i "s/version = \"$old_tag\"/version = \"$new_tag\"/g" Cargo.toml # Update the submodules git submodule update --remote # Run check to ensure translations are valid and lockfile is updated cargo check # The commit git add Cargo.toml Cargo.lock crates/email/translations git commit -m"Version $new_tag" git tag $new_tag # export COMPOSE_DOCKER_CLI_BUILD=1 # export DOCKER_BUILDKIT=1 # Push git push origin $new_tag git push # Pushing to any ansible deploys # cd ../../../lemmy-ansible || exit # ansible-playbook -i prod playbooks/site.yml --vault-password-file vault_pass ================================================ FILE: scripts/restore_db.sh ================================================ #!/usr/bin/env bash psql -U lemmy -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" cat docker/lemmy_dump_2021-01-29_16_13_40.sqldump | psql -U lemmy psql -U lemmy -c "alter user lemmy with password 'password'" ================================================ FILE: scripts/sql_format_check.sh ================================================ #!/usr/bin/env bash set -e # This check is only used for CI. CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" # Copy the files to a temp dir TMP_DIR=$(mktemp -d) cp -a migrations/. $TMP_DIR/migrations cp -a crates/diesel_utils/replaceable_schema/. $TMP_DIR/replaceable_schema # Format the new files find $TMP_DIR -type f -name '*.sql' -print0 | xargs -0 -P 10 -L 10 pg_format -i # Diff the directories diff -r migrations $TMP_DIR/migrations diff -r crates/diesel_utils/replaceable_schema $TMP_DIR/replaceable_schema ================================================ FILE: scripts/start_dev_db.sh ================================================ # This script is meant to be run with `source` so it can set environment variables. export PGDATA="$PWD/target/dev_pgdata" export PGHOST="$PWD/target" # Necessary to encode the dev db path into proper URL params export ENCODED_HOST=$(printf $PGHOST | jq -sRr @uri) export PGUSER=postgres export DATABASE_URL="postgresql://lemmy:password@$ENCODED_HOST/lemmy" export LEMMY_DATABASE_URL=$DATABASE_URL export PGDATABASE=lemmy # If cluster exists, stop the server and delete the cluster if [[ -d $PGDATA ]]; then # Only stop server if it is running pg_status_exit_code=0 (pg_ctl status >/dev/null) || pg_status_exit_code=$? if [[ ${pg_status_exit_code} -ne 3 ]]; then pg_ctl stop --silent fi rm -rf $PGDATA fi config_args=( # Only listen to socket in current directory -c listen_addresses= -c unix_socket_directories=$PGHOST # Write logs to a file in $PGDATA/log -c logging_collector=on # Allow auto_explain to be turned on -c session_preload_libraries=auto_explain # Include actual row amounts and run times for query plan nodes -c auto_explain.log_analyze=on # Don't log parameter values -c auto_explain.log_parameter_max_length=0 # Disable fsync, a feature that prevents corruption on crash (doesn't matter on a temporary test database) and slows things down, especially migration tests -c fsync=off ) # Create cluster pg_ctl init --silent --options="--username=postgres --auth=trust --no-instructions --no-sync" # Start server pg_ctl start --silent --options="${config_args[*]}" # Setup database PGDATABASE=postgres psql --quiet -c "CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;" PGDATABASE=postgres psql --quiet -c "CREATE DATABASE lemmy WITH OWNER lemmy;" ================================================ FILE: scripts/test-with-coverage.sh ================================================ #!/usr/bin/env bash set -e CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" PACKAGE="$1" echo "$PACKAGE" source scripts/start_dev_db.sh # tests are executed in working directory crates/api (or similar), # so to load the config we need to traverse to the repo root export LEMMY_CONFIG_LOCATION=$(pwd)/config/config.hjson export RUST_BACKTRACE=1 cargo install cargo-llvm-cov # Create lcov.info file, which is used by things like the Coverage Gutters extension for VS Code cargo llvm-cov --workspace --all-features --no-fail-fast --lcov --output-path target/lcov.info # Add this to do printlns: -- --nocapture pg_ctl stop --silent rm -rf $PGDATA ================================================ FILE: scripts/test.sh ================================================ #!/usr/bin/env bash set -e CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" PACKAGE="$1" TEST="$2" source scripts/start_dev_db.sh # tests are executed in working directory crates/api (or similar), # so to load the config we need to traverse to the repo root export LEMMY_CONFIG_LOCATION=$(pwd)/config/config.hjson export RUST_BACKTRACE=1 export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min if [ -n "$PACKAGE" ]; then cargo test -p $PACKAGE --features full $TEST else cargo test --workspace --features full fi # Add this to do printlns: -- --nocapture pg_ctl stop --silent rm -rf $PGDATA ================================================ FILE: scripts/update_config_defaults.sh ================================================ #!/usr/bin/env bash set -e dest=${1-config/defaults.hjson} cargo run --manifest-path crates/utils/Cargo.toml --features full >"$dest" ================================================ FILE: scripts/update_schema_file.sh ================================================ #!/usr/bin/env bash set -e CWD="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" cd "$CWD/../" source scripts/start_dev_db.sh cargo run --package lemmy_diesel_utils --features full diesel print-schema >crates/db_schema_file/src/schema.rs cargo +nightly fmt --package lemmy_db_schema_file pg_ctl stop rm -rf $PGDATA ================================================ FILE: scripts/update_translations.sh ================================================ #!/usr/bin/env bash set -e pushd ../../lemmy-translations git fetch weblate git merge weblate/main git push popd git submodule update --remote git add ../crates/utils/translations git commit -m"Updating translations." git push ================================================ FILE: scripts/upgrade_deps.sh ================================================ #!/usr/bin/env bash pushd ../ # Check unused deps cargo udeps --all-targets # Update deps first cargo update # Upgrade deps cargo upgrade # Run clippy cargo clippy popd