diff --git a/.woodpecker.yml b/.woodpecker.yml index 8bbae613e..2eb7d277e 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -6,7 +6,7 @@ variables: # 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 - - &rust_image "rust:1.81" + - &rust_image "rust:1.83" - &rust_nightly_image "rustlang/rust:nightly" - &install_pnpm "corepack enable pnpm" - &install_binstall "wget -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -xvz -C /usr/local/cargo/bin" diff --git a/Cargo.lock b/Cargo.lock index a9eecc636..c15497270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,9 +435,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" dependencies = [ "backtrace", ] @@ -460,9 +460,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.12" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec134f64e2bc57411226dfc4e52dec859ddfc7e711fc5e07b612584f000e4aa" +checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" dependencies = [ "flate2", "futures-core", @@ -495,15 +495,15 @@ dependencies = [ [[package]] name = "atom_syndication" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a3a5ed3201df5658d1aa45060c5a57dc9dba8a8ada20d696d67cb0c479ee043" +checksum = "3ee79fb83c725eae67b55218870813d2fc39fd85e4f1583848ef9f4f823cfe7c" dependencies = [ "chrono", "derive_builder", "diligent-date-parser", "never", - "quick-xml 0.36.1", + "quick-xml 0.37.1", ] [[package]] @@ -514,21 +514,20 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "aws-lc-rs" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f95446d919226d587817a7d21379e6eb099b97b45110a7f272a444ca5c54070" +checksum = "f47bb8cc16b669d267eeccf585aea077d0882f4777b1c1f740217885d6e6e5a3" dependencies = [ "aws-lc-sys", - "mirai-annotations", "paste", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.21.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234314bd569802ec87011d653d6815c6d7b9ffb969e9fee5b8b20ef860e8dce9" +checksum = "a2101df3813227bbaaaa0b04cd61c534c7954b22bd68d399b440be937dc63ff7" dependencies = [ "bindgen", "cc", @@ -618,9 +617,9 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.69.4" +version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ "bitflags 2.6.0", "cexpr", @@ -798,9 +797,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", @@ -844,9 +843,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.21" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -854,9 +853,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.21" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -878,9 +877,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "clearurls" @@ -907,9 +906,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.51" +version = "0.1.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +checksum = "c682c223677e0e5b6b7f63a64b9351844c3f1b1678a68b7ee617e30fb082620e" dependencies = [ "cc", ] @@ -1374,9 +1373,9 @@ dependencies = [ [[package]] name = "diligent-date-parser" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cf7fe294274a222363f84bcb63cdea762979a0443b4cf1f4f8fd17c86b1182" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" dependencies = [ "chrono", ] @@ -1471,9 +1470,9 @@ dependencies = [ [[package]] name = "email-encoding" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f" +checksum = "ea3d894bbbab314476b265f9b2d46bf24b123a36dd0e96b06a1b49545b9d9dcc" dependencies = [ "base64 0.22.1", "memchr", @@ -1599,9 +1598,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" [[package]] name = "fdeflate" @@ -2686,8 +2685,10 @@ dependencies = [ "lemmy_utils", "pretty_assertions", "serde", + "serde_json", "serde_with", "serial_test", + "test-context", "tokio", "tracing", "ts-rs", @@ -2810,7 +2811,6 @@ dependencies = [ "lemmy_utils", "pretty_assertions", "prometheus", - "reqwest 0.12.8", "reqwest-middleware", "reqwest-tracing", "rustls 0.23.16", @@ -2901,12 +2901,12 @@ checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "libloading" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -3223,12 +3223,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "mirai-annotations" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9be0862c1b3f26a88803c4a49de6889c10e608b3ee9344e6ef5b45fb37ad3d1" - [[package]] name = "mockall" version = "0.13.0" @@ -3602,9 +3596,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "plist" @@ -3729,9 +3723,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.22" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", "syn 2.0.87", @@ -3825,16 +3819,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc" -dependencies = [ - "encoding_rs", - "memchr", -] - [[package]] name = "quick-xml" version = "0.37.1" @@ -4328,9 +4312,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" diff --git a/Cargo.toml b/Cargo.toml index b1553be5c..b03a01715 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,21 +103,21 @@ diesel-async = "0.5.1" serde = { version = "1.0.215", features = ["derive"] } serde_with = "3.9.0" actix-web = { version = "4.9.0", default-features = false, features = [ - "macros", - "rustls-0_23", "compress-brotli", "compress-gzip", "compress-zstd", "cookies", + "macros", + "rustls-0_23", ] } tracing = "0.1.40" tracing-actix-web = { version = "0.7.10", default-features = false } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } url = { version = "2.5.3", features = ["serde"] } reqwest = { version = "0.12.7", default-features = false, features = [ - "json", "blocking", "gzip", + "json", "rustls-tls", ] } reqwest-middleware = "0.3.3" @@ -126,17 +126,15 @@ clokwerk = "0.4.0" doku = { version = "0.21.1", features = ["url-2"] } bcrypt = "0.15.1" chrono = { version = "0.4.38", features = [ - "serde", "now", + "serde", ], default-features = false } serde_json = { version = "1.0.132", features = ["preserve_order"] } base64 = "0.22.1" uuid = { version = "1.11.0", features = ["serde"] } async-trait = "0.1.83" captcha = "0.0.9" -anyhow = { version = "1.0.93", features = [ - "backtrace", -] } # backtrace is on by default on nightly, but not stable rust +anyhow = { version = "1.0.93", features = ["backtrace"] } diesel_ltree = "0.3.1" serial_test = "3.2.0" tokio = { version = "1.41.1", features = ["full"] } @@ -149,7 +147,6 @@ futures = "0.3.31" http = "1.1" rosetta-i18n = "0.1.3" ts-rs = { version = "10.0.0", features = [ - "serde-compat", "chrono-impl", "no-serde-warnings", "url-impl", @@ -185,7 +182,6 @@ tracing = { workspace = true } tracing-actix-web = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } -reqwest = { workspace = true } reqwest-middleware = { workspace = true } reqwest-tracing = { workspace = true } clokwerk = { workspace = true } diff --git a/api_tests/prepare-drone-federation-test.sh b/api_tests/prepare-drone-federation-test.sh index e5a4bc604..c5151b7f5 100755 --- a/api_tests/prepare-drone-federation-test.sh +++ b/api_tests/prepare-drone-federation-test.sh @@ -15,7 +15,9 @@ export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queu # pictrs setup if [ ! -f "api_tests/pict-rs" ]; then - curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.16/pict-rs-linux-amd64" -o api_tests/pict-rs + # This one sometimes goes down + # curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.16/pict-rs-linux-amd64" -o api_tests/pict-rs + curl "https://codeberg.org/asonix/pict-rs/releases/download/v0.5.6/pict-rs-linux-amd64" -o api_tests/pict-rs chmod +x api_tests/pict-rs fi ./api_tests/pict-rs \ diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts index 37381d302..52f86e8ef 100644 --- a/api_tests/src/post.spec.ts +++ b/api_tests/src/post.spec.ts @@ -698,7 +698,7 @@ test("Report a post", async () => { () => listReports(beta).then(p => p.reports.find(r => { - return checkReportName(r, gammaReport); + return checkPostReportName(r, gammaReport); }), ), res => !!res, @@ -718,15 +718,7 @@ test("Report a post", async () => { () => listReports(alpha).then(p => p.reports.find(r => { - switch (r.type_) { - case "Post": - return ( - r.post_report.original_post_name === - gammaReport.original_post_name - ); - default: - return false; - } + return checkPostReportName(r, gammaReport); }), ), res => !!res, @@ -833,7 +825,7 @@ test("Rewrite markdown links", async () => { ); }); -function checkReportName(rcv: ReportCombinedView, report: PostReport) { +function checkPostReportName(rcv: ReportCombinedView, report: PostReport) { switch (rcv.type_) { case "Post": return rcv.post_report.original_post_name === report.original_post_name; diff --git a/crates/api/src/local_user/reset_password.rs b/crates/api/src/local_user/reset_password.rs index e0f63d2e6..20707950c 100644 --- a/crates/api/src/local_user/reset_password.rs +++ b/crates/api/src/local_user/reset_password.rs @@ -6,23 +6,31 @@ use lemmy_api_common::{ SuccessResponse, }; use lemmy_db_views::structs::{LocalUserView, SiteView}; -use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; +use lemmy_utils::error::LemmyResult; +use tracing::error; #[tracing::instrument(skip(context))] pub async fn reset_password( data: Json, context: Data, ) -> LemmyResult> { - // Fetch that email let email = data.email.to_lowercase(); - let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email) - .await - .with_lemmy_type(LemmyErrorType::IncorrectLogin)?; - - let site_view = SiteView::read_local(&mut context.pool()).await?; - check_email_verified(&local_user_view, &site_view)?; - - // Email the pure token to the user. - send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await?; + // 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?; + 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(()) +} diff --git a/crates/api_common/Cargo.toml b/crates/api_common/Cargo.toml index 74a0390ca..b9e8a5a76 100644 --- a/crates/api_common/Cargo.toml +++ b/crates/api_common/Cargo.toml @@ -67,9 +67,9 @@ urlencoding = { workspace = true } mime = { version = "0.3.17", optional = true } mime_guess = "2.0.5" infer = "0.16.0" -webpage = { version = "2.0", default-features = false, features = [ +webpage = { version = "2.0", default-features = false, optional = true, features = [ "serde", -], optional = true } +] } encoding_rs = { version = "0.8.35", optional = true } jsonwebtoken = { version = "9.3.0", optional = true } diff --git a/crates/api_common/src/post.rs b/crates/api_common/src/post.rs index 81cd7363b..590ff05eb 100644 --- a/crates/api_common/src/post.rs +++ b/crates/api_common/src/post.rs @@ -1,5 +1,5 @@ use lemmy_db_schema::{ - newtypes::{CommentId, CommunityId, DbUrl, LanguageId, PostId}, + newtypes::{CommentId, CommunityId, DbUrl, LanguageId, PostId, TagId}, ListingType, PostFeatureType, PostSortType, @@ -37,6 +37,8 @@ pub struct CreatePost { /// Instead of fetching a thumbnail, use a custom one. #[cfg_attr(feature = "full", ts(optional))] pub custom_thumbnail: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub tags: Option>, /// Time when this post should be scheduled. Null means publish immediately. #[cfg_attr(feature = "full", ts(optional))] pub scheduled_publish_time: Option, @@ -164,6 +166,8 @@ pub struct EditPost { /// Instead of fetching a thumbnail, use a custom one. #[cfg_attr(feature = "full", ts(optional))] pub custom_thumbnail: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub tags: Option>, /// Time when this post should be scheduled. Null means publish immediately. #[cfg_attr(feature = "full", ts(optional))] pub scheduled_publish_time: Option, diff --git a/crates/api_common/src/request.rs b/crates/api_common/src/request.rs index c6f86b806..02e889872 100644 --- a/crates/api_common/src/request.rs +++ b/crates/api_common/src/request.rs @@ -51,9 +51,11 @@ pub fn client_builder(settings: &Settings) -> ClientBuilder { #[tracing::instrument(skip_all)] pub async fn fetch_link_metadata(url: &Url, context: &LemmyContext) -> LemmyResult { info!("Fetching site metadata for url: {}", url); - // We only fetch the first 64kB of data in order to not waste bandwidth especially for large - // binary files - let bytes_to_fetch = 64 * 1024; + // 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()) diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index 804734f96..9ffde778a 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -552,7 +552,9 @@ pub async fn get_url_blocklist(context: &LemmyContext) -> LemmyResult let urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?; // The urls are already validated on saving, so just escape them. - let regexes = urls.iter().map(|url| escape(&url.url)); + // 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) diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index 948a7617e..452144faa 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -107,7 +107,7 @@ pub async fn create_post( let scheduled_publish_time = convert_published_time(data.scheduled_publish_time, &local_user_view, &context).await?; let post_form = PostInsertForm { - url: url.map(Into::into), + url, body, alt_text: data.alt_text.clone(), nsfw: data.nsfw, diff --git a/crates/api_crud/src/site/update.rs b/crates/api_crud/src/site/update.rs index d2585ea43..8b0dfe0c5 100644 --- a/crates/api_crud/src/site/update.rs +++ b/crates/api_crud/src/site/update.rs @@ -151,6 +151,8 @@ pub async fn update_site( .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?; } diff --git a/crates/apub/src/activities/block/block_user.rs b/crates/apub/src/activities/block/block_user.rs index 64c402482..14b9f9adc 100644 --- a/crates/apub/src/activities/block/block_user.rs +++ b/crates/apub/src/activities/block/block_user.rs @@ -152,7 +152,7 @@ impl ActivityHandler for BlockUser { #[tracing::instrument(skip_all)] async fn receive(self, context: &Data) -> LemmyResult<()> { insert_received_activity(&self.id, context).await?; - let expires = self.end_time.map(Into::into); + let expires = self.end_time; let mod_person = self.actor.dereference(context).await?; let blocked_person = self.object.dereference(context).await?; let target = self.target.dereference(context).await?; diff --git a/crates/apub/src/activities/block/undo_block_user.rs b/crates/apub/src/activities/block/undo_block_user.rs index 122eae429..55715fd30 100644 --- a/crates/apub/src/activities/block/undo_block_user.rs +++ b/crates/apub/src/activities/block/undo_block_user.rs @@ -100,7 +100,7 @@ impl ActivityHandler for UndoBlockUser { #[tracing::instrument(skip_all)] async fn receive(self, context: &Data) -> LemmyResult<()> { insert_received_activity(&self.id, context).await?; - let expires = self.object.end_time.map(Into::into); + let expires = self.object.end_time; let mod_person = self.actor.dereference(context).await?; let blocked_person = self.object.object.dereference(context).await?; match self.object.target.dereference(context).await? { diff --git a/crates/apub/src/activities/community/update.rs b/crates/apub/src/activities/community/update.rs index fadf918bd..b6bc50ca0 100644 --- a/crates/apub/src/activities/community/update.rs +++ b/crates/apub/src/activities/community/update.rs @@ -98,8 +98,8 @@ impl ActivityHandler for UpdateCommunity { &None, &self.object.source, )), - published: self.object.published.map(Into::into), - updated: Some(self.object.updated.map(Into::into)), + published: self.object.published, + updated: Some(self.object.updated), nsfw: Some(self.object.sensitive.unwrap_or(false)), actor_id: Some(self.object.id.into()), public_key: Some(self.object.public_key.public_key_pem), diff --git a/crates/apub/src/api/read_person.rs b/crates/apub/src/api/read_person.rs index 7f719fe71..b79871b93 100644 --- a/crates/apub/src/api/read_person.rs +++ b/crates/apub/src/api/read_person.rs @@ -35,7 +35,6 @@ pub async fn read_person( .map(|l| is_admin(l).is_ok()) .unwrap_or_default(); let person_view = PersonView::read(&mut context.pool(), person_details_id, is_admin).await?; - let moderates = CommunityModeratorView::for_person( &mut context.pool(), person_details_id, diff --git a/crates/apub/src/fetcher/markdown_links.rs b/crates/apub/src/fetcher/markdown_links.rs index d83aae515..a5e51caa7 100644 --- a/crates/apub/src/fetcher/markdown_links.rs +++ b/crates/apub/src/fetcher/markdown_links.rs @@ -42,7 +42,8 @@ pub async fn markdown_rewrite_remote_links( let mut local_url = local_url.to_string(); // restore title if let Some(extra) = extra { - local_url = format!("{local_url} {extra}"); + local_url.push(' '); + local_url.push_str(extra); } src.replace_range(start..end, local_url.as_str()); } diff --git a/crates/apub/src/objects/comment.rs b/crates/apub/src/objects/comment.rs index 2c8ed9f9d..ed9a9e1a2 100644 --- a/crates/apub/src/objects/comment.rs +++ b/crates/apub/src/objects/comment.rs @@ -194,8 +194,8 @@ impl Object for ApubComment { post_id: post.id, content, removed: None, - published: note.published.map(Into::into), - updated: note.updated.map(Into::into), + published: note.published, + updated: note.updated, deleted: Some(false), ap_id: Some(note.id.into()), distinguished: note.distinguished, diff --git a/crates/apub/src/objects/person.rs b/crates/apub/src/objects/person.rs index 97b83c194..50f8e8563 100644 --- a/crates/apub/src/objects/person.rs +++ b/crates/apub/src/objects/person.rs @@ -167,8 +167,8 @@ impl Object for ApubPerson { deleted: Some(false), avatar, banner, - published: person.published.map(Into::into), - updated: person.updated.map(Into::into), + published: person.published, + updated: person.updated, actor_id: Some(person.id.into()), bio, local: Some(false), diff --git a/crates/apub/src/objects/post.rs b/crates/apub/src/objects/post.rs index bcd1dbf8e..0dd9201c2 100644 --- a/crates/apub/src/objects/post.rs +++ b/crates/apub/src/objects/post.rs @@ -249,8 +249,8 @@ impl Object for ApubPost { url: url.map(Into::into), body, alt_text, - published: page.published.map(Into::into), - updated: page.updated.map(Into::into), + published: page.published, + updated: page.updated, deleted: Some(false), nsfw: page.sensitive, ap_id: Some(page.id.clone().into()), diff --git a/crates/apub/src/objects/private_message.rs b/crates/apub/src/objects/private_message.rs index ec3e16fac..521419c82 100644 --- a/crates/apub/src/objects/private_message.rs +++ b/crates/apub/src/objects/private_message.rs @@ -166,8 +166,8 @@ impl Object for ApubPrivateMessage { creator_id: creator.id, recipient_id: recipient.id, content, - published: note.published.map(Into::into), - updated: note.updated.map(Into::into), + published: note.published, + updated: note.updated, deleted: Some(false), read: None, ap_id: Some(note.id.into()), diff --git a/crates/db_schema/Cargo.toml b/crates/db_schema/Cargo.toml index eac9d6ddd..a511508f8 100644 --- a/crates/db_schema/Cargo.toml +++ b/crates/db_schema/Cargo.toml @@ -52,8 +52,8 @@ activitypub_federation = { workspace = true, optional = true } lemmy_utils = { workspace = true, optional = true } bcrypt = { workspace = true, optional = true } diesel = { workspace = true, features = [ - "postgres", "chrono", + "postgres", "serde_json", "uuid", ], optional = true } @@ -61,14 +61,14 @@ diesel-derive-newtype = { workspace = true, optional = true } diesel-derive-enum = { workspace = true, optional = true } diesel_migrations = { workspace = true, optional = true } diesel-async = { workspace = true, features = [ - "postgres", "deadpool", + "postgres", ], optional = true } regex = { workspace = true, optional = true } diesel_ltree = { workspace = true, optional = true } async-trait = { workspace = true } tracing = { workspace = true } -deadpool = { version = "0.12.1", features = ["rt_tokio_1"], optional = true } +deadpool = { version = "0.12.1", optional = true, features = ["rt_tokio_1"] } ts-rs = { workspace = true, optional = true } futures-util = { workspace = true } tokio = { workspace = true, optional = true } diff --git a/crates/db_schema/replaceable_schema/triggers.sql b/crates/db_schema/replaceable_schema/triggers.sql index b7eb0209b..40f740da8 100644 --- a/crates/db_schema/replaceable_schema/triggers.sql +++ b/crates/db_schema/replaceable_schema/triggers.sql @@ -731,12 +731,12 @@ BEGIN AND p.thing_id = OLD.thing_id; ELSIF (TG_OP = 'INSERT') THEN IF NEW.saved IS NOT NULL THEN - INSERT INTO person_saved_combined (published, person_id, thing_id) + INSERT INTO person_saved_combined (saved, person_id, thing_id) VALUES (NEW.saved, NEW.person_id, NEW.thing_id); END IF; ELSIF (TG_OP = 'UPDATE') THEN IF NEW.saved IS NOT NULL THEN - INSERT INTO person_saved_combined (published, person_id, thing_id) + INSERT INTO person_saved_combined (saved, person_id, thing_id) VALUES (NEW.saved, NEW.person_id, NEW.thing_id); -- If saved gets set as null, delete the row ELSE diff --git a/crates/db_schema/replaceable_schema/utils.sql b/crates/db_schema/replaceable_schema/utils.sql index 0c7f42ff2..a9b32f3dd 100644 --- a/crates/db_schema/replaceable_schema/utils.sql +++ b/crates/db_schema/replaceable_schema/utils.sql @@ -186,26 +186,26 @@ BEGIN AND pe.bot_account = FALSE UNION SELECT - pl.person_id, + pa.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 + post_actions pa + INNER JOIN post p ON pa.post_id = p.id + INNER JOIN person pe ON pa.person_id = pe.id WHERE - pl.published > ('now'::timestamp - i::interval) + pa.liked > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE UNION SELECT - cl.person_id, + ca.person_id, p.community_id FROM - comment_like cl - INNER JOIN comment c ON cl.comment_id = c.id + 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 cl.person_id = pe.id + INNER JOIN person pe ON ca.person_id = pe.id WHERE - cl.published > ('now'::timestamp - i::interval) + ca.liked > ('now'::timestamp - i::interval) AND pe.bot_account = FALSE) a GROUP BY community_id; @@ -244,22 +244,22 @@ BEGIN AND pe.bot_account = FALSE UNION SELECT - pl.person_id + pa.person_id FROM - post_like pl - INNER JOIN person pe ON pl.person_id = pe.id + post_actions pa + INNER JOIN person pe ON pa.person_id = pe.id WHERE - pl.published > ('now'::timestamp - i::interval) + pa.liked > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE UNION SELECT - cl.person_id + ca.person_id FROM - comment_like cl - INNER JOIN person pe ON cl.person_id = pe.id + comment_actions ca + INNER JOIN person pe ON ca.person_id = pe.id WHERE - cl.published > ('now'::timestamp - i::interval) + ca.liked > ('now'::timestamp - i::interval) AND pe.local = TRUE AND pe.bot_account = FALSE) a; RETURN count_; diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index d4ea47800..2d7a16c2c 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -35,4 +35,5 @@ pub mod private_message_report; pub mod registration_application; pub mod secret; pub mod site; +pub mod tag; pub mod tagline; diff --git a/crates/db_schema/src/impls/oauth_provider.rs b/crates/db_schema/src/impls/oauth_provider.rs index 9d7e791e7..7665ba050 100644 --- a/crates/db_schema/src/impls/oauth_provider.rs +++ b/crates/db_schema/src/impls/oauth_provider.rs @@ -55,13 +55,11 @@ impl OAuthProvider { pub fn convert_providers_to_public( oauth_providers: Vec, ) -> Vec { - let mut result = Vec::::new(); - for oauth_provider in &oauth_providers { - if oauth_provider.enabled { - result.push(PublicOAuthProvider(oauth_provider.clone())); - } - } - result + oauth_providers + .into_iter() + .filter(|x| x.enabled) + .map(PublicOAuthProvider) + .collect() } pub async fn get_all_public(pool: &mut DbPool<'_>) -> Result, Error> { diff --git a/crates/db_schema/src/impls/tag.rs b/crates/db_schema/src/impls/tag.rs new file mode 100644 index 000000000..c0171e04c --- /dev/null +++ b/crates/db_schema/src/impls/tag.rs @@ -0,0 +1,53 @@ +use crate::{ + newtypes::TagId, + schema::{post_tag, tag}, + source::tag::{PostTagInsertForm, Tag, TagInsertForm}, + traits::Crud, + utils::{get_conn, DbPool}, +}; +use diesel::{insert_into, result::Error, QueryDsl}; +use diesel_async::RunQueryDsl; +use lemmy_utils::error::LemmyResult; + +#[async_trait] +impl Crud for Tag { + type InsertForm = TagInsertForm; + + type UpdateForm = TagInsertForm; + + type IdType = TagId; + + async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> Result { + let conn = &mut get_conn(pool).await?; + insert_into(tag::table) + .values(form) + .get_result::(conn) + .await + } + + async fn update( + pool: &mut DbPool<'_>, + pid: TagId, + form: &Self::UpdateForm, + ) -> Result { + let conn = &mut get_conn(pool).await?; + diesel::update(tag::table.find(pid)) + .set(form) + .get_result::(conn) + .await + } +} + +impl PostTagInsertForm { + pub async fn insert_tag_associations( + pool: &mut DbPool<'_>, + tags: &[PostTagInsertForm], + ) -> LemmyResult<()> { + let conn = &mut get_conn(pool).await?; + insert_into(post_tag::table) + .values(tags) + .execute(conn) + .await?; + Ok(()) + } +} diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index 228de75be..06e030101 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -179,20 +179,18 @@ pub struct LtreeDef(pub String); #[cfg_attr(feature = "full", ts(export))] pub struct DbUrl(pub(crate) Box); -#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, 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, TS))] -#[cfg_attr(feature = "full", ts(export))] +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, 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, TS))] -#[cfg_attr(feature = "full", ts(export))] +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default)] +#[cfg_attr(feature = "full", derive(DieselNewType))] /// The person saved combined id pub struct PersonSavedCombinedId(i32); @@ -389,3 +387,9 @@ impl InstanceId { self.0 } } + +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(DieselNewType, TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The internal tag id. +pub struct TagId(pub i32); diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 431e249df..fffb48f10 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -775,7 +775,7 @@ diesel::table! { diesel::table! { person_saved_combined (id) { id -> Int4, - published -> Timestamptz, + saved -> Timestamptz, person_id -> Int4, post_id -> Nullable, comment_id -> Nullable, @@ -869,6 +869,14 @@ diesel::table! { } } +diesel::table! { + post_tag (post_id, tag_id) { + post_id -> Int4, + tag_id -> Int4, + published -> Timestamptz, + } +} + diesel::table! { private_message (id) { id -> Int4, @@ -1004,6 +1012,18 @@ diesel::table! { } } +diesel::table! { + tag (id) { + id -> Int4, + ap_id -> Text, + name -> Text, + community_id -> Int4, + published -> Timestamptz, + updated -> Nullable, + deleted -> Bool, + } +} + diesel::table! { tagline (id) { id -> Int4, @@ -1107,6 +1127,8 @@ diesel::joinable!(post_aggregates -> instance (instance_id)); diesel::joinable!(post_aggregates -> person (creator_id)); diesel::joinable!(post_aggregates -> post (post_id)); diesel::joinable!(post_report -> post (post_id)); +diesel::joinable!(post_tag -> post (post_id)); +diesel::joinable!(post_tag -> tag (tag_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)); @@ -1117,6 +1139,7 @@ diesel::joinable!(site -> instance (instance_id)); diesel::joinable!(site_aggregates -> site (site_id)); diesel::joinable!(site_language -> language (language_id)); diesel::joinable!(site_language -> site (site_id)); +diesel::joinable!(tag -> community (community_id)); diesel::allow_tables_to_appear_in_same_query!( admin_allow_instance, @@ -1179,6 +1202,7 @@ diesel::allow_tables_to_appear_in_same_query!( post_actions, post_aggregates, post_report, + post_tag, private_message, private_message_report, received_activity, @@ -1190,5 +1214,6 @@ diesel::allow_tables_to_appear_in_same_query!( site, site_aggregates, site_language, + tag, tagline, ); diff --git a/crates/db_schema/src/source/combined/person_content.rs b/crates/db_schema/src/source/combined/person_content.rs index ed83401c0..05f8c1a46 100644 --- a/crates/db_schema/src/source/combined/person_content.rs +++ b/crates/db_schema/src/source/combined/person_content.rs @@ -4,11 +4,8 @@ use crate::schema::person_content_combined; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; -#[skip_serializing_none] -#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) diff --git a/crates/db_schema/src/source/combined/person_saved.rs b/crates/db_schema/src/source/combined/person_saved.rs index afd91594d..bee11e8b8 100644 --- a/crates/db_schema/src/source/combined/person_saved.rs +++ b/crates/db_schema/src/source/combined/person_saved.rs @@ -4,11 +4,8 @@ use crate::schema::person_saved_combined; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; -#[skip_serializing_none] -#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) @@ -19,7 +16,7 @@ use serde_with::skip_serializing_none; /// A combined person_saved table. pub struct PersonSavedCombined { pub id: PersonSavedCombinedId, - pub published: DateTime, + pub saved: DateTime, pub person_id: PersonId, pub post_id: Option, pub comment_id: Option, diff --git a/crates/db_schema/src/source/combined/report.rs b/crates/db_schema/src/source/combined/report.rs index 5ea825b83..2902c5548 100644 --- a/crates/db_schema/src/source/combined/report.rs +++ b/crates/db_schema/src/source/combined/report.rs @@ -4,11 +4,8 @@ use crate::schema::report_combined; use chrono::{DateTime, Utc}; #[cfg(feature = "full")] use i_love_jesus::CursorKeysModule; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; -#[skip_serializing_none] -#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone)] #[cfg_attr( feature = "full", derive(Identifiable, Queryable, Selectable, CursorKeysModule) diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index ea5f2c9d3..2ac2692b4 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -41,6 +41,7 @@ pub mod private_message_report; pub mod registration_application; pub mod secret; pub mod site; +pub mod tag; pub mod tagline; /// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip). diff --git a/crates/db_schema/src/source/tag.rs b/crates/db_schema/src/source/tag.rs new file mode 100644 index 000000000..265d864c3 --- /dev/null +++ b/crates/db_schema/src/source/tag.rs @@ -0,0 +1,57 @@ +use crate::newtypes::{CommunityId, DbUrl, PostId, TagId}; +#[cfg(feature = "full")] +use crate::schema::{post_tag, tag}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +#[cfg(feature = "full")] +use ts_rs::TS; + +/// A tag that can be assigned to a post within a community. +/// The tag object is created by the community moderators. +/// The assignment happens by the post creator and can be updated by the community moderators. +/// +/// A tag is a federatable object that gives additional context to another object, which can be +/// displayed and filtered on currently, we only have community post tags, which is a tag that is +/// created by post authors as well as mods of a community, to categorize a post. in the future we +/// may add more tag types, depending on the requirements, this will lead to either expansion of +/// this table (community_id optional, addition of tag_type enum) or split of this table / creation +/// of new tables. +#[skip_serializing_none] +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS, Queryable, Selectable, Identifiable))] +#[cfg_attr(feature = "full", diesel(table_name = tag))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +pub struct Tag { + pub id: TagId, + pub ap_id: DbUrl, + pub name: String, + /// the community that owns this tag + pub community_id: CommunityId, + pub published: DateTime, + #[cfg_attr(feature = "full", ts(optional))] + pub updated: Option>, + pub deleted: bool, +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = tag))] +pub struct TagInsertForm { + pub ap_id: DbUrl, + pub name: String, + pub community_id: CommunityId, + // default now + pub published: Option>, + pub updated: Option>, + pub deleted: bool, +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = post_tag))] +pub struct PostTagInsertForm { + pub post_id: PostId, + pub tag_id: TagId, +} diff --git a/crates/db_schema/src/utils.rs b/crates/db_schema/src/utils.rs index aa213887e..5bbf007ae 100644 --- a/crates/db_schema/src/utils.rs +++ b/crates/db_schema/src/utils.rs @@ -547,6 +547,11 @@ pub mod functions { // 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 + } } pub const DELETED_REPLACEMENT_TEXT: &str = "*Permanently Deleted*"; diff --git a/crates/db_views/Cargo.toml b/crates/db_views/Cargo.toml index 20dca5139..f7d4b1d7a 100644 --- a/crates/db_views/Cargo.toml +++ b/crates/db_views/Cargo.toml @@ -35,6 +35,7 @@ diesel-async = { workspace = true, optional = true } diesel_ltree = { workspace = true, optional = true } serde = { workspace = true } serde_with = { workspace = true } +serde_json = { workspace = true } tracing = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true } actix-web = { workspace = true, optional = true } @@ -47,3 +48,4 @@ serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } url = { workspace = true } +test-context = "0.3.0" diff --git a/crates/db_views/src/comment_view.rs b/crates/db_views/src/comment_view.rs index 0067d0807..776cab8e2 100644 --- a/crates/db_views/src/comment_view.rs +++ b/crates/db_views/src/comment_view.rs @@ -141,28 +141,25 @@ fn queries<'a>() -> Queries< query.first(&mut conn).await }; - let list = move |mut conn: DbConn<'a>, (options, site): (CommentQuery<'a>, &'a Site)| async move { + let list = move |mut conn: DbConn<'a>, (o, site): (CommentQuery<'a>, &'a Site)| async move { // The left join below will return None in this case - let local_user_id_join = options - .local_user - .local_user_id() - .unwrap_or(LocalUserId(-1)); + let local_user_id_join = o.local_user.local_user_id().unwrap_or(LocalUserId(-1)); - let mut query = all_joins(comment::table.into_boxed(), options.local_user.person_id()); + let mut query = all_joins(comment::table.into_boxed(), o.local_user.person_id()); - if let Some(creator_id) = options.creator_id { + if let Some(creator_id) = o.creator_id { query = query.filter(comment::creator_id.eq(creator_id)); }; - if let Some(post_id) = options.post_id { + if let Some(post_id) = o.post_id { query = query.filter(comment::post_id.eq(post_id)); }; - if let Some(parent_path) = options.parent_path.as_ref() { + if let Some(parent_path) = o.parent_path.as_ref() { query = query.filter(comment::path.contained_by(parent_path)); }; //filtering out removed and deleted comments from search - if let Some(search_term) = options.search_term { + if let Some(search_term) = o.search_term { query = query.filter( comment::content .ilike(fuzzy_search(&search_term)) @@ -170,13 +167,13 @@ fn queries<'a>() -> Queries< ); }; - if let Some(community_id) = options.community_id { + if let Some(community_id) = o.community_id { query = query.filter(post::community_id.eq(community_id)); } let is_subscribed = community_actions::followed.is_not_null(); - match options.listing_type.unwrap_or_default() { + match o.listing_type.unwrap_or_default() { ListingType::Subscribed => query = query.filter(is_subscribed), /* TODO could be this: and(community_follower::person_id.eq(person_id_join)), */ ListingType::Local => { query = query @@ -189,26 +186,24 @@ fn queries<'a>() -> Queries< } } - if let Some(my_id) = options.local_user.person_id() { + if let Some(my_id) = o.local_user.person_id() { let not_creator_filter = comment::creator_id.ne(my_id); - if options.liked_only.unwrap_or_default() { + if o.liked_only.unwrap_or_default() { query = query .filter(not_creator_filter) .filter(comment_actions::like_score.eq(1)); - } else if options.disliked_only.unwrap_or_default() { + } else if o.disliked_only.unwrap_or_default() { query = query .filter(not_creator_filter) .filter(comment_actions::like_score.eq(-1)); } } - if !options.local_user.show_bot_accounts() { + if !o.local_user.show_bot_accounts() { query = query.filter(person::bot_account.eq(false)); }; - if options.local_user.is_some() - && options.listing_type.unwrap_or_default() != ListingType::ModeratorView - { + 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( @@ -225,15 +220,15 @@ fn queries<'a>() -> Queries< .filter(person_actions::blocked.is_null()); }; - if !options.local_user.show_nsfw(site) { + if !o.local_user.show_nsfw(site) { query = query .filter(post::nsfw.eq(false)) .filter(community::nsfw.eq(false)); }; - query = options.local_user.visible_communities_only(query); + query = o.local_user.visible_communities_only(query); - if !options.local_user.is_admin() { + if !o.local_user.is_admin() { query = query.filter( community::visibility .ne(CommunityVisibility::Private) @@ -242,8 +237,8 @@ fn queries<'a>() -> Queries< } // A Max depth given means its a tree fetch - let (limit, offset) = if let Some(max_depth) = options.max_depth { - let depth_limit = if let Some(parent_path) = options.parent_path.as_ref() { + let (limit, offset) = if let Some(max_depth) = o.max_depth { + let depth_limit = if let Some(parent_path) = o.parent_path.as_ref() { parent_path.0.split('.').count() as i32 + max_depth // Add one because of root "0" } else { @@ -254,7 +249,7 @@ fn queries<'a>() -> 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 options.post_id.is_some() || options.parent_path.is_some() { + if o.post_id.is_some() || o.parent_path.is_some() { // Always order by the parent path first query = query.then_order_by(subpath(comment::path, 0, -1)); } @@ -271,16 +266,16 @@ fn queries<'a>() -> Queries< // (i64::MAX, 0) (300, 0) } else { - // limit_and_offset_unlimited(options.page, options.limit) - limit_and_offset(options.page, options.limit)? + // limit_and_offset_unlimited(o.page, o.limit) + limit_and_offset(o.page, o.limit)? }; // distinguished comments should go first when viewing post - if options.post_id.is_some() || options.parent_path.is_some() { + if o.post_id.is_some() || o.parent_path.is_some() { query = query.then_order_by(comment::distinguished.desc()); } - query = match options.sort.unwrap_or(CommentSortType::Hot) { + query = match o.sort.unwrap_or(CommentSortType::Hot) { CommentSortType::Hot => query .then_order_by(comment_aggregates::hot_rank.desc()) .then_order_by(comment_aggregates::score.desc()), diff --git a/crates/db_views/src/lib.rs b/crates/db_views/src/lib.rs index 6b95b9efe..829870c0a 100644 --- a/crates/db_views/src/lib.rs +++ b/crates/db_views/src/lib.rs @@ -18,6 +18,8 @@ pub mod person_saved_combined_view; #[cfg(feature = "full")] pub mod post_report_view; #[cfg(feature = "full")] +pub mod post_tags_view; +#[cfg(feature = "full")] pub mod post_view; #[cfg(feature = "full")] pub mod private_message_report_view; diff --git a/crates/db_views/src/person_content_combined_view.rs b/crates/db_views/src/person_content_combined_view.rs index 0dca66885..e7a152219 100644 --- a/crates/db_views/src/person_content_combined_view.rs +++ b/crates/db_views/src/person_content_combined_view.rs @@ -34,6 +34,8 @@ use lemmy_db_schema::{ post, post_actions, post_aggregates, + post_tag, + tag, }, source::{ combined::person_content::{person_content_combined_keys as key, PersonContentCombined}, @@ -96,6 +98,15 @@ impl PersonContentCombinedQuery { let conn = &mut get_conn(pool).await?; + let post_tags = post_tag::table + .inner_join(tag::table) + .select(diesel::dsl::sql::( + "json_agg(tag.*)", + )) + .filter(post_tag::post_id.eq(post::id)) + .filter(tag::deleted.eq(false)) + .single_value(); + // 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: @@ -170,6 +181,7 @@ impl PersonContentCombinedQuery { post_actions::hidden.nullable().is_not_null(), post_actions::like_score.nullable(), image_details::all_columns.nullable(), + post_tags, // Comment-specific comment::all_columns.nullable(), comment_aggregates::all_columns.nullable(), @@ -260,6 +272,7 @@ impl InternalToCombinedView for PersonContentViewInternal { my_vote: v.my_post_vote, image_details: v.image_details, banned_from_community: v.banned_from_community, + tags: v.post_tags, })) } } diff --git a/crates/db_views/src/person_saved_combined_view.rs b/crates/db_views/src/person_saved_combined_view.rs index 9c800c016..27ed02386 100644 --- a/crates/db_views/src/person_saved_combined_view.rs +++ b/crates/db_views/src/person_saved_combined_view.rs @@ -31,6 +31,8 @@ use lemmy_db_schema::{ post, post_actions, post_aggregates, + post_tag, + tag, }, source::{ combined::person_saved::{person_saved_combined_keys as key, PersonSavedCombined}, @@ -90,6 +92,15 @@ impl PersonSavedCombinedQuery { let conn = &mut get_conn(pool).await?; + let post_tags = post_tag::table + .inner_join(tag::table) + .select(diesel::dsl::sql::( + "json_agg(tag.*)", + )) + .filter(post_tag::post_id.eq(post::id)) + .filter(tag::deleted.eq(false)) + .single_value(); + // 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: @@ -172,6 +183,7 @@ impl PersonSavedCombinedQuery { post_actions::hidden.nullable().is_not_null(), post_actions::like_score.nullable(), image_details::all_columns.nullable(), + post_tags, // Comment-specific comment::all_columns.nullable(), comment_aggregates::all_columns.nullable(), @@ -206,9 +218,9 @@ impl PersonSavedCombinedQuery { query = query.after(page_after); } - // Sorting by published + // Sorting by saved desc query = query - .then_desc(key::published) + .then_desc(key::saved) // Tie breaker .then_desc(key::id); diff --git a/crates/db_views/src/post_tags_view.rs b/crates/db_views/src/post_tags_view.rs new file mode 100644 index 000000000..5d1492567 --- /dev/null +++ b/crates/db_views/src/post_tags_view.rs @@ -0,0 +1,30 @@ +//! see post_view.rs for the reason for this json decoding +use crate::structs::PostTags; +use diesel::{ + deserialize::FromSql, + pg::{Pg, PgValue}, + serialize::ToSql, + sql_types::{self, Nullable}, +}; + +impl FromSql, Pg> for PostTags { + 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 { tags: vec![] }), + } + } +} + +impl ToSql, Pg> for PostTags { + fn to_sql(&self, out: &mut diesel::serialize::Output) -> diesel::serialize::Result { + let value = serde_json::to_value(self)?; + >::to_sql(&value, &mut out.reborrow()) + } +} diff --git a/crates/db_views/src/post_view.rs b/crates/db_views/src/post_view.rs index e4a65721e..a8f1259ad 100644 --- a/crates/db_views/src/post_view.rs +++ b/crates/db_views/src/post_view.rs @@ -32,6 +32,8 @@ use lemmy_db_schema::{ post, post_actions, post_aggregates, + post_tag, + tag, }, source::{ community::{CommunityFollower, CommunityFollowerState}, @@ -80,6 +82,28 @@ fn queries<'a>() -> Queries< // TODO maybe this should go to localuser also let all_joins = move |query: post_aggregates::BoxedQuery<'a, Pg>, my_person_id: Option| { + // We fetch post tags by letting postgresql aggregate them internally in a subquery into JSON. + // This is a simple way to join m rows into n rows without duplicating the data and getting + // complex diesel types. In pure SQL you would usually do this either using a LEFT JOIN + then + // aggregating the results in the application code. But this results in a lot of duplicate + // data transferred (since each post will be returned once per tag that it has) and more + // complicated application code. The diesel docs suggest doing three separate sequential queries + // in this case (see https://diesel.rs/guides/relations.html#many-to-many-or-mn ): First fetch + // the posts, then fetch all relevant post-tag-association tuples from the db, and then fetch + // all the relevant tag objects. + // + // If we want to filter by post tag we will have to add + // separate logic below since this subquery can't affect filtering, but it is simple (`WHERE + // exists (select 1 from post_community_post_tags where community_post_tag_id in (1,2,3,4)`). + let post_tags = post_tag::table + .inner_join(tag::table) + .select(diesel::dsl::sql::( + "json_agg(tag.*)", + )) + .filter(post_tag::post_id.eq(post_aggregates::post_id)) + .filter(tag::deleted.eq(false)) + .single_value(); + query .inner_join(person::table) .inner_join(community::table) @@ -136,6 +160,7 @@ fn queries<'a>() -> Queries< post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), post_aggregates::comments, ), + post_tags, )) }; @@ -196,23 +221,20 @@ fn queries<'a>() -> Queries< .await }; - let list = move |mut conn: DbConn<'a>, (options, site): (PostQuery<'a>, &'a Site)| async move { + let list = move |mut conn: DbConn<'a>, (o, site): (PostQuery<'a>, &'a Site)| async move { // The left join below will return None in this case - let local_user_id_join = options - .local_user - .local_user_id() - .unwrap_or(LocalUserId(-1)); + let local_user_id_join = o.local_user.local_user_id().unwrap_or(LocalUserId(-1)); let mut query = all_joins( post_aggregates::table.into_boxed(), - options.local_user.person_id(), + o.local_user.person_id(), ); // 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) = options.local_user.person_id() { + if let Some(person_id) = o.local_user.person_id() { query = query.filter(post::deleted.eq(false).or(post::creator_id.eq(person_id))); query = query.filter( post::scheduled_publish_time @@ -226,21 +248,21 @@ fn queries<'a>() -> Queries< } // only show removed posts to admin when viewing user profile - if !(options.creator_id.is_some() && options.local_user.is_admin()) { + if !(o.creator_id.is_some() && o.local_user.is_admin()) { query = query .filter(community::removed.eq(false)) .filter(post::removed.eq(false)); } - if let Some(community_id) = options.community_id { + if let Some(community_id) = o.community_id { query = query.filter(post_aggregates::community_id.eq(community_id)); } - if let Some(creator_id) = options.creator_id { + if let Some(creator_id) = o.creator_id { query = query.filter(post_aggregates::creator_id.eq(creator_id)); } let is_subscribed = community_actions::followed.is_not_null(); - match options.listing_type.unwrap_or_default() { + match o.listing_type.unwrap_or_default() { ListingType::Subscribed => query = query.filter(is_subscribed), ListingType::Local => { query = query @@ -253,14 +275,14 @@ fn queries<'a>() -> Queries< } } - if let Some(search_term) = &options.search_term { - if options.url_only.unwrap_or_default() { + if let Some(search_term) = &o.search_term { + if o.url_only.unwrap_or_default() { query = query.filter(post::url.eq(search_term)); } else { let searcher = fuzzy_search(search_term); let name_filter = post::name.ilike(searcher.clone()); let body_filter = post::body.ilike(searcher.clone()); - query = if options.title_only.unwrap_or_default() { + query = if o.title_only.unwrap_or_default() { query.filter(name_filter) } else { query.filter(name_filter.or(body_filter)) @@ -269,56 +291,50 @@ fn queries<'a>() -> Queries< } } - if !options - .show_nsfw - .unwrap_or(options.local_user.show_nsfw(site)) - { + 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 !options.local_user.show_bot_accounts() { + if !o.local_user.show_bot_accounts() { query = query.filter(person::bot_account.eq(false)); }; // Filter to show only posts with no comments - if options.no_comments_only.unwrap_or_default() { + if o.no_comments_only.unwrap_or_default() { query = query.filter(post_aggregates::comments.eq(0)); }; - if !options - .show_read - .unwrap_or(options.local_user.show_read_posts()) - { + if !o.show_read.unwrap_or(o.local_user.show_read_posts()) { // Do not hide read posts when it is a user profile view // Or, only hide read posts on non-profile views - if options.creator_id.is_none() { + if o.creator_id.is_none() { query = query.filter(post_actions::read.is_null()); } } // If a creator id isn't given (IE its on home or community pages), hide the hidden posts - if !options.show_hidden.unwrap_or_default() && options.creator_id.is_none() { + if !o.show_hidden.unwrap_or_default() && o.creator_id.is_none() { query = query.filter(post_actions::hidden.is_null()); } - if let Some(my_id) = options.local_user.person_id() { + if let Some(my_id) = o.local_user.person_id() { let not_creator_filter = post_aggregates::creator_id.ne(my_id); - if options.liked_only.unwrap_or_default() { + if o.liked_only.unwrap_or_default() { query = query .filter(not_creator_filter) .filter(post_actions::like_score.eq(1)); - } else if options.disliked_only.unwrap_or_default() { + } else if o.disliked_only.unwrap_or_default() { query = query .filter(not_creator_filter) .filter(post_actions::like_score.eq(-1)); } }; - query = options.local_user.visible_communities_only(query); + query = o.local_user.visible_communities_only(query); - if !options.local_user.is_admin() { + if !o.local_user.is_admin() { query = query.filter( community::visibility .ne(CommunityVisibility::Private) @@ -327,9 +343,9 @@ fn queries<'a>() -> Queries< } // Dont filter blocks or missing languages for moderator view type - if options.listing_type.unwrap_or_default() != ListingType::ModeratorView { + if o.listing_type.unwrap_or_default() != ListingType::ModeratorView { // Filter out the rows with missing languages if user is logged in - if options.local_user.is_some() { + if o.local_user.is_some() { query = query.filter(exists( local_user_language::table.filter( post::language_id @@ -345,15 +361,15 @@ fn queries<'a>() -> Queries< query = query.filter(person_actions::blocked.is_null()); } - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query = query.limit(limit).offset(offset); let mut query = PaginatedQueryBuilder::new(query); - let page_after = options.page_after.map(|c| c.0); - let page_before_or_equal = options.page_before_or_equal.map(|c| c.0); + let page_after = o.page_after.map(|c| c.0); + let page_before_or_equal = o.page_before_or_equal.map(|c| c.0); - if options.page_back.unwrap_or_default() { + if o.page_back.unwrap_or_default() { query = query .before(page_after) .after_or_equal(page_before_or_equal) @@ -365,7 +381,7 @@ fn queries<'a>() -> Queries< } // featured posts first - query = if options.community_id.is_none() || options.community_id_just_for_prefetch { + query = if o.community_id.is_none() || o.community_id_just_for_prefetch { query.then_desc(key::featured_local) } else { query.then_desc(key::featured_community) @@ -374,7 +390,7 @@ fn queries<'a>() -> Queries< let time = |interval| post_aggregates::published.gt(now() - interval); // then use the main sort - query = match options.sort.unwrap_or(Hot) { + query = match o.sort.unwrap_or(Hot) { Active => query.then_desc(key::hot_rank_active), Hot => query.then_desc(key::hot_rank), Scaled => query.then_desc(key::scaled_rank), @@ -398,7 +414,7 @@ fn queries<'a>() -> Queries< // 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 - query = match options.sort.unwrap_or(Hot) { + query = match o.sort.unwrap_or(Hot) { // A second time-based sort would not be very useful New | Old | NewComments => query, _ => query.then_desc(key::published), @@ -416,7 +432,7 @@ fn queries<'a>() -> Queries< .text("PostQuery::list") .text_if( "getting upper bound for next query", - options.community_id_just_for_prefetch, + o.community_id_just_for_prefetch, ) .load::(&mut conn) .await @@ -594,11 +610,13 @@ impl<'a> PostQuery<'a> { } } +#[allow(clippy::indexing_slicing)] +#[expect(clippy::expect_used)] #[cfg(test)] mod tests { use crate::{ post_view::{PaginationCursorData, PostQuery, PostView}, - structs::LocalUserView, + structs::{LocalUserView, PostTags}, }; use chrono::Utc; use diesel_async::SimpleAsyncConnection; @@ -640,29 +658,33 @@ mod tests { PostUpdateForm, }, site::Site, + tag::{PostTagInsertForm, Tag, TagInsertForm}, }, traits::{Bannable, Blockable, Crud, Followable, Joinable, Likeable}, - utils::{build_db_pool, build_db_pool_for_tests, get_conn, uplete, DbPool, RANK_DEFAULT}, + utils::{build_db_pool, get_conn, uplete, ActualDbPool, DbPool, RANK_DEFAULT}, CommunityVisibility, PostSortType, SubscribedType, }; - use lemmy_utils::error::LemmyResult; + use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use pretty_assertions::assert_eq; use serial_test::serial; use std::time::{Duration, Instant}; + use test_context::{test_context, AsyncTestContext}; use url::Url; const POST_WITH_ANOTHER_TITLE: &str = "Another title"; 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"; fn names(post_views: &[PostView]) -> Vec<&str> { post_views.iter().map(|i| i.post.name.as_str()).collect() } struct Data { + pool: ActualDbPool, inserted_instance: Instance, local_user_view: LocalUserView, blocked_local_user_view: LocalUserView, @@ -670,10 +692,19 @@ mod tests { inserted_community: Community, inserted_post: Post, inserted_bot_post: Post, + inserted_post_with_tags: Post, + tag_1: Tag, + tag_2: Tag, 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), @@ -681,129 +712,206 @@ mod tests { ..Default::default() } } - } - async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { - let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; + async fn setup() -> LemmyResult { + let actual_pool = build_db_pool()?; + let pool = &mut (&actual_pool).into(); + let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; - let new_person = PersonInsertForm::test_form(inserted_instance.id, "tegan"); + let new_person = PersonInsertForm::test_form(inserted_instance.id, "tegan"); - let inserted_person = Person::create(pool, &new_person).await?; + let inserted_person = Person::create(pool, &new_person).await?; - let local_user_form = LocalUserInsertForm { - admin: Some(true), - ..LocalUserInsertForm::test_form(inserted_person.id) - }; - let inserted_local_user = LocalUser::create(pool, &local_user_form, vec![]).await?; + let local_user_form = LocalUserInsertForm { + admin: Some(true), + ..LocalUserInsertForm::test_form(inserted_person.id) + }; + let inserted_local_user = LocalUser::create(pool, &local_user_form, vec![]).await?; - let new_bot = PersonInsertForm { - bot_account: Some(true), - ..PersonInsertForm::test_form(inserted_instance.id, "mybot") - }; + let new_bot = PersonInsertForm { + bot_account: Some(true), + ..PersonInsertForm::test_form(inserted_instance.id, "mybot") + }; - let inserted_bot = Person::create(pool, &new_bot).await?; + let inserted_bot = Person::create(pool, &new_bot).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_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?; - // Test a person block, make sure the post query doesn't include their post - let blocked_person = PersonInsertForm::test_form(inserted_instance.id, "john"); + // Test a person block, make sure the post query doesn't include their post + let blocked_person = PersonInsertForm::test_form(inserted_instance.id, "john"); - let inserted_blocked_person = Person::create(pool, &blocked_person).await?; + let inserted_blocked_person = Person::create(pool, &blocked_person).await?; - let inserted_blocked_local_user = LocalUser::create( - pool, - &LocalUserInsertForm::test_form(inserted_blocked_person.id), - vec![], - ) - .await?; - - let post_from_blocked_person = PostInsertForm { - language_id: Some(LanguageId(1)), - ..PostInsertForm::new( - POST_BY_BLOCKED_PERSON.to_string(), - inserted_blocked_person.id, - inserted_community.id, + let inserted_blocked_local_user = LocalUser::create( + pool, + &LocalUserInsertForm::test_form(inserted_blocked_person.id), + vec![], ) - }; - Post::create(pool, &post_from_blocked_person).await?; + .await?; - // block that person - let person_block = PersonBlockForm { - person_id: inserted_person.id, - target_id: inserted_blocked_person.id, - }; + let post_from_blocked_person = PostInsertForm { + language_id: Some(LanguageId(1)), + ..PostInsertForm::new( + POST_BY_BLOCKED_PERSON.to_string(), + inserted_blocked_person.id, + inserted_community.id, + ) + }; + Post::create(pool, &post_from_blocked_person).await?; - PersonBlock::block(pool, &person_block).await?; + // block that person + let person_block = PersonBlockForm { + person_id: inserted_person.id, + target_id: inserted_blocked_person.id, + }; - // A sample post - let new_post = PostInsertForm { - language_id: Some(LanguageId(47)), - ..PostInsertForm::new(POST.to_string(), inserted_person.id, inserted_community.id) - }; - let inserted_post = Post::create(pool, &new_post).await?; + PersonBlock::block(pool, &person_block).await?; - let new_bot_post = PostInsertForm::new( - POST_BY_BOT.to_string(), - inserted_bot.id, - inserted_community.id, - ); - let inserted_bot_post = Post::create(pool, &new_bot_post).await?; + // Two community post tags + let tag_1 = Tag::create( + pool, + &TagInsertForm { + ap_id: Url::parse(&format!("{}/tags/test_tag1", inserted_community.actor_id))?.into(), + name: "Test Tag 1".into(), + community_id: inserted_community.id, + published: None, + updated: None, + deleted: false, + }, + ) + .await?; + let tag_2 = Tag::create( + pool, + &TagInsertForm { + ap_id: Url::parse(&format!("{}/tags/test_tag2", inserted_community.actor_id))?.into(), + name: "Test Tag 2".into(), + community_id: inserted_community.id, + published: None, + updated: None, + deleted: false, + }, + ) + .await?; - let local_user_view = LocalUserView { - local_user: inserted_local_user, - local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), - person: inserted_person, - counts: Default::default(), - }; - let blocked_local_user_view = LocalUserView { - local_user: inserted_blocked_local_user, - local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), - person: inserted_blocked_person, - counts: Default::default(), - }; + // A sample post + let new_post = PostInsertForm { + language_id: Some(LanguageId(47)), + ..PostInsertForm::new(POST.to_string(), inserted_person.id, inserted_community.id) + }; - let site = Site { - id: Default::default(), - name: String::new(), - sidebar: None, - published: Default::default(), - updated: None, - icon: None, - banner: None, - description: None, - actor_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, - }; + let inserted_post = Post::create(pool, &new_post).await?; - Ok(Data { - inserted_instance, - local_user_view, - blocked_local_user_view, - inserted_bot, - inserted_community, - inserted_post, - inserted_bot_post, - site, - }) + let new_bot_post = PostInsertForm::new( + POST_BY_BOT.to_string(), + inserted_bot.id, + inserted_community.id, + ); + let inserted_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_person.id, + inserted_community.id, + ) + }; + + let inserted_post_with_tags = Post::create(pool, &new_post).await?; + let inserted_tags = vec![ + PostTagInsertForm { + post_id: inserted_post_with_tags.id, + tag_id: tag_1.id, + }, + PostTagInsertForm { + post_id: inserted_post_with_tags.id, + tag_id: tag_2.id, + }, + ]; + PostTagInsertForm::insert_tag_associations(pool, &inserted_tags).await?; + + let local_user_view = LocalUserView { + local_user: inserted_local_user, + local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), + person: inserted_person, + counts: Default::default(), + }; + let blocked_local_user_view = LocalUserView { + local_user: inserted_blocked_local_user, + local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), + person: inserted_blocked_person, + counts: Default::default(), + }; + + let site = Site { + id: Default::default(), + name: String::new(), + sidebar: None, + published: Default::default(), + updated: None, + icon: None, + banner: None, + description: None, + actor_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, + }; + + Ok(Data { + pool: actual_pool, + inserted_instance, + local_user_view, + blocked_local_user_view, + inserted_bot, + inserted_community, + inserted_post, + inserted_bot_post, + inserted_post_with_tags, + tag_1, + tag_2, + site, + }) + } + async fn teardown(data: Data) -> LemmyResult<()> { + let pool = &mut data.pool2(); + // let pool = &mut (&pool).into(); + let num_deleted = Post::delete(pool, data.inserted_post.id).await?; + Community::delete(pool, data.inserted_community.id).await?; + Person::delete(pool, data.local_user_view.person.id).await?; + Person::delete(pool, data.inserted_bot.id).await?; + Person::delete(pool, data.blocked_local_user_view.person.id).await?; + Instance::delete(pool, data.inserted_instance.id).await?; + assert_eq!(1, num_deleted); + + Ok(()) + } + } + impl AsyncTestContext for Data { + async fn setup() -> Self { + Data::setup().await.expect("setup failed") + } + async fn teardown(self) { + Data::teardown(self).await.expect("teardown failed") + } } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_with_person() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_with_person(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let mut data = init_data(pool).await?; let local_user_form = LocalUserUpdateForm { show_bot_accounts: Some(false), @@ -812,12 +920,14 @@ mod tests { LocalUser::update(pool, data.local_user_view.local_user.id, &local_user_form).await?; data.local_user_view.local_user.show_bot_accounts = false; - let read_post_listing = PostQuery { + let mut read_post_listing = PostQuery { community_id: Some(data.inserted_community.id), ..data.default_post_query() } .list(&data.site, pool) .await?; + // remove tags post + read_post_listing.remove(0); let post_listing_single_with_person = PostView::read( pool, @@ -827,7 +937,7 @@ mod tests { ) .await?; - let expected_post_listing_with_user = expected_post_view(&data, pool).await?; + let expected_post_listing_with_user = expected_post_view(data, pool).await?; // Should be only one person, IE the bot post, and blocked should be missing assert_eq!( @@ -853,17 +963,19 @@ mod tests { .list(&data.site, pool) .await?; // should include bot post which has "undetermined" language - assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_with_bots)); - - cleanup(data, pool).await + 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() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_no_person(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; let read_post_listing_multiple_no_person = PostQuery { community_id: Some(data.inserted_community.id), @@ -876,32 +988,31 @@ mod tests { let read_post_listing_single_no_person = PostView::read(pool, data.inserted_post.id, None, false).await?; - let expected_post_listing_no_person = expected_post_view(&data, pool).await?; + let expected_post_listing_no_person = expected_post_view(data, pool).await?; // Should be 2 posts, with the bot post, and the blocked assert_eq!( - vec![POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON], + vec![POST_WITH_TAGS, POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON], names(&read_post_listing_multiple_no_person) ); assert_eq!( Some(&expected_post_listing_no_person), - read_post_listing_multiple_no_person.get(1) + read_post_listing_multiple_no_person.get(2) ); assert_eq!( expected_post_listing_no_person, read_post_listing_single_no_person ); - - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_title_only() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_title_only(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // A post which contains the search them 'Post' not in the title (but in the body) let new_post = PostInsertForm { @@ -939,6 +1050,7 @@ mod tests { assert_eq!( vec![ POST_WITH_ANOTHER_TITLE, + POST_WITH_TAGS, POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON @@ -948,19 +1060,19 @@ mod tests { // Should be 3 posts when we search for title only assert_eq!( - vec![POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON], + vec![POST_WITH_TAGS, POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON], names(&read_post_listing_by_title_only) ); Post::delete(pool, inserted_post.id).await?; - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_block_community() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_block_community(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; let community_block = CommunityBlockForm { person_id: data.local_user_view.person.id, @@ -978,15 +1090,15 @@ mod tests { assert_eq!(read_post_listings_with_person_after_block, vec![]); CommunityBlock::unblock(pool, &community_block).await?; - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_like() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_like(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let mut data = init_data(pool).await?; let post_like_form = PostLikeForm::new(data.inserted_post.id, data.local_user_view.person.id, 1); @@ -1009,7 +1121,7 @@ mod tests { ) .await?; - let mut expected_post_with_upvote = expected_post_view(&data, pool).await?; + let mut expected_post_with_upvote = expected_post_view(data, pool).await?; expected_post_with_upvote.my_vote = Some(1); expected_post_with_upvote.counts.score = 1; expected_post_with_upvote.counts.upvotes = 1; @@ -1022,26 +1134,27 @@ mod tests { LocalUser::update(pool, data.local_user_view.local_user.id, &local_user_form).await?; data.local_user_view.local_user.show_bot_accounts = false; - let read_post_listing = PostQuery { + let mut read_post_listing = PostQuery { community_id: Some(data.inserted_community.id), ..data.default_post_query() } .list(&data.site, pool) .await?; + read_post_listing.remove(0); assert_eq!(vec![expected_post_with_upvote], read_post_listing); let like_removed = PostLike::remove(pool, data.local_user_view.person.id, data.inserted_post.id).await?; assert_eq!(uplete::Count::only_deleted(1), like_removed); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_liked_only() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_liked_only(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Like both the bot post, and your own // The liked_only should not show your own post @@ -1076,15 +1189,15 @@ mod tests { // Should be no posts assert_eq!(read_disliked_post_listing, vec![]); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn creator_info() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn creator_info(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Make one of the inserted persons a moderator let person_id = data.local_user_view.person.id; @@ -1106,23 +1219,24 @@ mod tests { .collect::>(); let expected_post_listing = vec![ + ("tegan".to_owned(), true, true), ("mybot".to_owned(), false, false), ("tegan".to_owned(), true, true), ]; assert_eq!(expected_post_listing, post_listing); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_person_language() -> LemmyResult<()> { + async fn post_listing_person_language(data: &mut Data) -> LemmyResult<()> { const EL_POSTO: &str = "el posto"; - let pool = &build_db_pool()?; + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; let spanish_id = Language::read_id_from_code(pool, "es").await?; @@ -1141,17 +1255,23 @@ mod tests { 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_BY_BOT, POST], names(&post_listings_all)); + assert_eq!( + vec![EL_POSTO, POST_WITH_TAGS, POST_BY_BOT, POST], + names(&post_listings_all) + ); LocalUserLanguage::update(pool, vec![french_id], data.local_user_view.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_BY_BOT, POST], names(&post_listing_french)); + assert_eq!( + vec![POST_WITH_TAGS, POST_BY_BOT, POST], + names(&post_listing_french) + ); assert_eq!( Some(french_id), - post_listing_french.get(1).map(|p| p.post.language_id) + post_listing_french.get(2).map(|p| p.post.language_id) ); LocalUserLanguage::update( @@ -1168,6 +1288,7 @@ mod tests { .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), ]; @@ -1175,15 +1296,15 @@ mod tests { // french post and undetermined language post should be returned assert_eq!(expected_post_listings_french_und, post_listings_french_und); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listings_removed() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_removed(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let mut data = init_data(pool).await?; // Remove the post Post::update( @@ -1198,7 +1319,7 @@ mod tests { // Make sure you don't see the removed post in the results let post_listings_no_admin = data.default_post_query().list(&data.site, pool).await?; - assert_eq!(vec![POST], names(&post_listings_no_admin)); + assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_no_admin)); // Removed bot post is shown to admins on its profile page data.local_user_view.local_user.admin = true; @@ -1210,15 +1331,15 @@ mod tests { .await?; assert_eq!(vec![POST_BY_BOT], names(&post_listings_is_admin)); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listings_deleted() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_deleted(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Delete the post Post::update( @@ -1249,15 +1370,15 @@ mod tests { assert_eq!(expect_contains_deleted, contains_deleted); } - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listings_hidden_community() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_hidden_community(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; Community::update( pool, @@ -1285,17 +1406,17 @@ mod tests { let posts = data.default_post_query().list(&data.site, pool).await?; assert!(!posts.is_empty()); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_instance_block() -> LemmyResult<()> { + async fn post_listing_instance_block(data: &mut Data) -> LemmyResult<()> { const POST_FROM_BLOCKED_INSTANCE: &str = "post on blocked instance"; - let pool = &build_db_pool()?; + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; let blocked_instance = Instance::read_or_create(pool, "another_domain.tld".to_string()).await?; @@ -1320,7 +1441,12 @@ mod tests { // no instance block, should return all posts let post_listings_all = data.default_post_query().list(&data.site, pool).await?; assert_eq!( - vec![POST_FROM_BLOCKED_INSTANCE, POST_BY_BOT, POST], + vec![ + POST_FROM_BLOCKED_INSTANCE, + POST_WITH_TAGS, + POST_BY_BOT, + POST + ], names(&post_listings_all) ); @@ -1333,7 +1459,10 @@ mod tests { // 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![POST_BY_BOT, POST], names(&post_listings_blocked)); + assert_eq!( + vec![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)); @@ -1342,20 +1471,25 @@ mod tests { InstanceBlock::unblock(pool, &block_form).await?; let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; assert_eq!( - vec![POST_FROM_BLOCKED_INSTANCE, POST_BY_BOT, POST], + vec![ + POST_FROM_BLOCKED_INSTANCE, + POST_WITH_TAGS, + POST_BY_BOT, + POST + ], names(&post_listings_blocked) ); Instance::delete(pool, blocked_instance.id).await?; - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn pagination_includes_each_post_once() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn pagination_includes_each_post_once(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; let community_form = CommunityInsertForm::new( data.inserted_instance.id, @@ -1457,15 +1591,15 @@ mod tests { assert_eq!(inserted_post_ids, listed_post_ids); Community::delete(pool, inserted_community.id).await?; - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listings_hide_read() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_hide_read(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let mut data = init_data(pool).await?; // Make sure local user hides read posts let local_user_form = LocalUserUpdateForm { @@ -1481,7 +1615,7 @@ mod tests { // 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], names(&post_listings_hide_read)); + 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 { @@ -1491,7 +1625,7 @@ mod tests { .list(&data.site, pool) .await?; assert_eq!( - vec![POST_BY_BOT, POST], + vec![POST_WITH_TAGS, POST_BY_BOT, POST], names(&post_listings_show_read_true) ); @@ -1502,16 +1636,19 @@ mod tests { } .list(&data.site, pool) .await?; - assert_eq!(vec![POST], names(&post_listings_show_read_false)); - cleanup(data, 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() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_hide_hidden(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Mark a post as hidden PostHide::hide( @@ -1523,7 +1660,10 @@ mod tests { // 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], names(&post_listings_hide_hidden)); + 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 { @@ -1534,20 +1674,23 @@ mod tests { } .list(&data.site, pool) .await?; - assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_show_hidden)); + 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.first().is_some_and(|p| p.hidden)); + assert!(&post_listings_show_hidden.get(1).is_some_and(|p| p.hidden)); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listings_hide_nsfw() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_hide_nsfw(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Mark a post as nsfw let update_form = PostUpdateForm { @@ -1555,11 +1698,11 @@ mod tests { ..Default::default() }; - Post::update(pool, data.inserted_bot_post.id, &update_form).await?; + Post::update(pool, data.inserted_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], names(&post_listings_hide_nsfw)); + 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 { @@ -1570,22 +1713,19 @@ mod tests { } .list(&data.site, pool) .await?; - assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_show_nsfw)); + 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().is_some_and(|p| p.post.nsfw)); - - cleanup(data, pool).await - } - - async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { - let num_deleted = Post::delete(pool, data.inserted_post.id).await?; - Community::delete(pool, data.inserted_community.id).await?; - Person::delete(pool, data.local_user_view.person.id).await?; - Person::delete(pool, data.inserted_bot.id).await?; - Person::delete(pool, data.blocked_local_user_view.person.id).await?; - Instance::delete(pool, data.inserted_instance.id).await?; - assert_eq!(1, num_deleted); + assert!( + &post_listings_show_nsfw + .first() + .ok_or(LemmyErrorType::NotFound)? + .post + .nsfw + ); Ok(()) } @@ -1707,15 +1847,16 @@ mod tests { hidden: false, saved: false, creator_blocked: false, + tags: PostTags::default(), }) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn local_only_instance() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); + async fn local_only_instance(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; Community::update( pool, @@ -1740,7 +1881,7 @@ mod tests { } .list(&data.site, pool) .await?; - assert_eq!(2, authenticated_query.len()); + assert_eq!(3, authenticated_query.len()); let unauthenticated_post = PostView::read(pool, data.inserted_post.id, None, false).await; assert!(unauthenticated_post.is_err()); @@ -1754,16 +1895,15 @@ mod tests { .await; assert!(authenticated_post.is_ok()); - cleanup(data, pool).await?; Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_local_user_banned_from_community() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_local_user_banned_from_community(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Test that post view shows if local user is blocked from community let banned_from_comm_person = PersonInsertForm::test_form(data.inserted_instance.id, "jill"); @@ -1798,15 +1938,15 @@ mod tests { assert!(post_view.banned_from_community); Person::delete(pool, inserted_banned_from_comm_person.id).await?; - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_local_user_not_banned_from_community() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_local_user_not_banned_from_community(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; let post_view = PostView::read( pool, @@ -1818,15 +1958,15 @@ mod tests { assert!(!post_view.banned_from_community); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn speed_check() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn speed_check(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Make sure the post_view query is less than this time let duration_max = Duration::from_millis(80); @@ -1874,15 +2014,15 @@ mod tests { duration_max ); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listings_no_comments_only() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listings_no_comments_only(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let data = init_data(pool).await?; // Create a comment for a post let comment_form = CommentInsertForm::new( @@ -1902,17 +2042,20 @@ mod tests { .list(&data.site, pool) .await?; - assert_eq!(vec![POST_BY_BOT], names(&post_listings_no_comments)); + assert_eq!( + vec![POST_WITH_TAGS, POST_BY_BOT], + names(&post_listings_no_comments) + ); - cleanup(data, pool).await + Ok(()) } + #[test_context(Data)] #[tokio::test] #[serial] - async fn post_listing_private_community() -> LemmyResult<()> { - let pool = &build_db_pool()?; + async fn post_listing_private_community(data: &mut Data) -> LemmyResult<()> { + let pool = &data.pool(); let pool = &mut pool.into(); - let mut data = init_data(pool).await?; // Mark community as private Community::update( @@ -1964,7 +2107,7 @@ mod tests { } .list(&data.site, pool) .await?; - assert_eq!(2, read_post_listing.len()); + assert_eq!(3, read_post_listing.len()); let post_view = PostView::read( pool, data.inserted_post.id, @@ -1991,7 +2134,7 @@ mod tests { } .list(&data.site, pool) .await?; - assert_eq!(2, read_post_listing.len()); + assert_eq!(3, read_post_listing.len()); let post_view = PostView::read( pool, data.inserted_post.id, @@ -2001,6 +2144,33 @@ mod tests { .await; assert!(post_view.is_ok()); - cleanup(data, pool).await + 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.inserted_post_with_tags.id, + Some(&data.local_user_view.local_user), + false, + ) + .await?; + + assert_eq!(2, post_view.tags.tags.len()); + assert_eq!(data.tag_1.name, post_view.tags.tags[0].name); + assert_eq!(data.tag_2.name, post_view.tags.tags[1].name); + + let all_posts = data.default_post_query().list(&data.site, pool).await?; + assert_eq!(2, all_posts[0].tags.tags.len()); // post with tags + assert_eq!(0, all_posts[1].tags.tags.len()); // bot post + assert_eq!(0, all_posts[2].tags.tags.len()); // normal post + + Ok(()) } } diff --git a/crates/db_views/src/private_message_view.rs b/crates/db_views/src/private_message_view.rs index 2286b7dc6..346dab49a 100644 --- a/crates/db_views/src/private_message_view.rs +++ b/crates/db_views/src/private_message_view.rs @@ -53,8 +53,7 @@ fn queries<'a>() -> Queries< .await }; - let list = move |mut conn: DbConn<'a>, - (options, recipient_id): (PrivateMessageQuery, PersonId)| async move { + let list = move |mut conn: DbConn<'a>, (o, recipient_id): (PrivateMessageQuery, PersonId)| async move { let mut query = all_joins(private_message::table.into_boxed()) .select(selection) // Dont show replies from blocked users @@ -63,9 +62,9 @@ fn queries<'a>() -> Queries< .filter(instance_actions::blocked.is_null()); // If its unread, I only want the ones to me - if options.unread_only { + if o.unread_only { query = query.filter(private_message::read.eq(false)); - if let Some(i) = options.creator_id { + if let Some(i) = o.creator_id { query = query.filter(private_message::creator_id.eq(i)) } query = query.filter(private_message::recipient_id.eq(recipient_id)); @@ -77,7 +76,7 @@ fn queries<'a>() -> Queries< .eq(recipient_id) .or(private_message::creator_id.eq(recipient_id)), ); - if let Some(i) = options.creator_id { + if let Some(i) = o.creator_id { query = query.filter( private_message::creator_id .eq(i) @@ -86,7 +85,7 @@ fn queries<'a>() -> Queries< } } - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query = query .filter(private_message::deleted.eq(false)) diff --git a/crates/db_views/src/registration_application_view.rs b/crates/db_views/src/registration_application_view.rs index 0fa0a5d7e..72329b978 100644 --- a/crates/db_views/src/registration_application_view.rs +++ b/crates/db_views/src/registration_application_view.rs @@ -53,12 +53,12 @@ fn queries<'a>() -> Queries< query.first(&mut conn).await }; - let list = move |mut conn: DbConn<'a>, options: RegistrationApplicationQuery| async move { + let list = move |mut conn: DbConn<'a>, o: RegistrationApplicationQuery| async move { let mut query = all_joins(registration_application::table.into_boxed()); // If viewing all applications, order by newest, but if viewing unresolved only, show the oldest // first (FIFO) - if options.unread_only { + if o.unread_only { query = query .filter(registration_application::admin_id.is_null()) .order_by(registration_application::published.asc()); @@ -66,11 +66,11 @@ fn queries<'a>() -> Queries< query = query.order_by(registration_application::published.desc()); } - if options.verified_email_only { + if o.verified_email_only { query = query.filter(local_user::email_verified.eq(true)) } - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query = query.limit(limit).offset(offset); diff --git a/crates/db_views/src/report_combined_view.rs b/crates/db_views/src/report_combined_view.rs index 0a103cfc2..eaf233985 100644 --- a/crates/db_views/src/report_combined_view.rs +++ b/crates/db_views/src/report_combined_view.rs @@ -576,7 +576,7 @@ mod tests { }; CommentReport::report(pool, &sara_report_comment_form).await?; - // Timmy creates a private message report + // Timmy creates a private message let pm_form = PrivateMessageInsertForm::new( data.timmy.id, data.sara.id, diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index 709615672..e5f3e0464 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -1,5 +1,7 @@ #[cfg(feature = "full")] use diesel::Queryable; +#[cfg(feature = "full")] +use diesel::{deserialize::FromSqlRow, expression::AsExpression, sql_types}; use lemmy_db_schema::{ aggregates::structs::{CommentAggregates, PersonAggregates, PostAggregates, SiteAggregates}, source::{ @@ -20,6 +22,7 @@ use lemmy_db_schema::{ private_message_report::PrivateMessageReport, registration_application::RegistrationApplication, site::Site, + tag::Tag, }, SubscribedType, }; @@ -169,6 +172,7 @@ pub struct PostView { #[cfg_attr(feature = "full", ts(optional))] pub my_vote: Option, pub unread_comments: i64, + pub tags: PostTags, } #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] @@ -315,6 +319,7 @@ pub struct PersonContentViewInternal { pub post_hidden: bool, pub my_post_vote: Option, pub image_details: Option, + pub post_tags: PostTags, // Comment-specific pub comment: Option, pub comment_counts: Option, @@ -341,3 +346,12 @@ pub enum PersonContentCombinedView { Post(PostView), Comment(CommentView), } + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)] +#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))] +#[serde(transparent)] +#[cfg_attr(feature = "full", diesel(sql_type = Nullable))] +/// we wrap this in a struct so we can implement FromSqlRow for it +pub struct PostTags { + pub tags: Vec, +} diff --git a/crates/db_views_actor/Cargo.toml b/crates/db_views_actor/Cargo.toml index 18a79826b..00f8bdcaf 100644 --- a/crates/db_views_actor/Cargo.toml +++ b/crates/db_views_actor/Cargo.toml @@ -26,13 +26,13 @@ full = [ [dependencies] lemmy_db_schema = { workspace = true } diesel = { workspace = true, features = [ - "postgres", "chrono", + "postgres", "serde_json", ], optional = true } diesel-async = { workspace = true, features = [ - "postgres", "deadpool", + "postgres", ], optional = true } serde = { workspace = true } serde_with = { workspace = true } diff --git a/crates/db_views_actor/src/comment_reply_view.rs b/crates/db_views_actor/src/comment_reply_view.rs index 6c5442e6a..75f8ed4e2 100644 --- a/crates/db_views_actor/src/comment_reply_view.rs +++ b/crates/db_views_actor/src/comment_reply_view.rs @@ -113,24 +113,24 @@ fn queries<'a>() -> Queries< .await }; - let list = move |mut conn: DbConn<'a>, options: CommentReplyQuery| async move { + let list = move |mut conn: DbConn<'a>, o: CommentReplyQuery| async move { // These filters need to be kept in sync with the filters in // CommentReplyView::get_unread_replies() - let mut query = all_joins(comment_reply::table.into_boxed(), options.my_person_id); + let mut query = all_joins(comment_reply::table.into_boxed(), o.my_person_id); - if let Some(recipient_id) = options.recipient_id { + if let Some(recipient_id) = o.recipient_id { query = query.filter(comment_reply::recipient_id.eq(recipient_id)); } - if options.unread_only { + if o.unread_only { query = query.filter(comment_reply::read.eq(false)); } - if !options.show_bot_accounts { + if !o.show_bot_accounts { query = query.filter(not(person::bot_account)); }; - query = match options.sort.unwrap_or(CommentSortType::New) { + query = match o.sort.unwrap_or(CommentSortType::New) { CommentSortType::Hot => query.then_order_by(comment_aggregates::hot_rank.desc()), CommentSortType::Controversial => { query.then_order_by(comment_aggregates::controversy_rank.desc()) @@ -143,7 +143,7 @@ fn queries<'a>() -> Queries< // Don't show replies from blocked persons query = query.filter(person_actions::blocked.is_null()); - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query .limit(limit) diff --git a/crates/db_views_actor/src/community_view.rs b/crates/db_views_actor/src/community_view.rs index 8bcf50ba3..1a8e3c4cd 100644 --- a/crates/db_views_actor/src/community_view.rs +++ b/crates/db_views_actor/src/community_view.rs @@ -90,17 +90,17 @@ fn queries<'a>() -> Queries< query.first(&mut conn).await }; - let list = move |mut conn: DbConn<'a>, (options, site): (CommunityQuery<'a>, &'a Site)| async move { + let list = move |mut conn: DbConn<'a>, (o, site): (CommunityQuery<'a>, &'a Site)| async move { use CommunitySortType::*; - let mut query = all_joins(community::table.into_boxed(), options.local_user).select(selection); + let mut query = all_joins(community::table.into_boxed(), o.local_user).select(selection); - if let Some(search_term) = options.search_term { + if let Some(search_term) = o.search_term { let searcher = fuzzy_search(&search_term); let name_filter = community::name.ilike(searcher.clone()); let title_filter = community::title.ilike(searcher.clone()); let description_filter = community::description.ilike(searcher.clone()); - query = if options.title_only.unwrap_or_default() { + query = if o.title_only.unwrap_or_default() { query.filter(name_filter.or(title_filter)) } else { query.filter(name_filter.or(title_filter.or(description_filter))) @@ -108,7 +108,7 @@ fn queries<'a>() -> Queries< } // Hide deleted and removed for non-admins or mods - if !options.is_mod_or_admin { + if !o.is_mod_or_admin { query = query.filter(not_removed_or_deleted).filter( community::hidden .eq(false) @@ -116,7 +116,7 @@ fn queries<'a>() -> Queries< ); } - match options.sort.unwrap_or(Hot) { + match o.sort.unwrap_or(Hot) { Hot | Active | Scaled => query = query.order_by(community_aggregates::hot_rank.desc()), NewComments | TopDay | TopTwelveHour | TopSixHour | TopHour => { query = query.order_by(community_aggregates::users_active_day.desc()) @@ -137,7 +137,7 @@ fn queries<'a>() -> Queries< NameDesc => query = query.order_by(lower(community::name).desc()), }; - if let Some(listing_type) = options.listing_type { + if let Some(listing_type) = o.listing_type { query = match listing_type { ListingType::Subscribed => { query.filter(community_actions::follow_state.eq(Some(CommunityFollowerState::Accepted))) @@ -151,13 +151,13 @@ fn queries<'a>() -> Queries< // also hidden (based on profile setting) query = query.filter(instance_actions::blocked.is_null()); query = query.filter(community_actions::blocked.is_null()); - if !(options.local_user.show_nsfw(site) || options.show_nsfw) { + if !(o.local_user.show_nsfw(site) || o.show_nsfw) { query = query.filter(community::nsfw.eq(false)); } - query = options.local_user.visible_communities_only(query); + query = o.local_user.visible_communities_only(query); - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query .limit(limit) .offset(offset) diff --git a/crates/db_views_actor/src/person_mention_view.rs b/crates/db_views_actor/src/person_mention_view.rs index 08be67a82..b3d6235d4 100644 --- a/crates/db_views_actor/src/person_mention_view.rs +++ b/crates/db_views_actor/src/person_mention_view.rs @@ -113,24 +113,24 @@ fn queries<'a>() -> Queries< .await }; - let list = move |mut conn: DbConn<'a>, options: PersonMentionQuery| async move { + let list = move |mut conn: DbConn<'a>, o: PersonMentionQuery| async move { // These filters need to be kept in sync with the filters in // PersonMentionView::get_unread_mentions() - let mut query = all_joins(person_mention::table.into_boxed(), options.my_person_id); + let mut query = all_joins(person_mention::table.into_boxed(), o.my_person_id); - if let Some(recipient_id) = options.recipient_id { + if let Some(recipient_id) = o.recipient_id { query = query.filter(person_mention::recipient_id.eq(recipient_id)); } - if options.unread_only { + if o.unread_only { query = query.filter(person_mention::read.eq(false)); } - if !options.show_bot_accounts { + if !o.show_bot_accounts { query = query.filter(not(person::bot_account)); }; - query = match options.sort.unwrap_or(CommentSortType::Hot) { + query = match o.sort.unwrap_or(CommentSortType::Hot) { CommentSortType::Hot => query.then_order_by(comment_aggregates::hot_rank.desc()), CommentSortType::Controversial => { query.then_order_by(comment_aggregates::controversy_rank.desc()) @@ -143,7 +143,7 @@ fn queries<'a>() -> Queries< // Don't show mentions from blocked persons query = query.filter(person_actions::blocked.is_null()); - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query .limit(limit) diff --git a/crates/db_views_actor/src/person_view.rs b/crates/db_views_actor/src/person_view.rs index b90ab7811..bc12e6559 100644 --- a/crates/db_views_actor/src/person_view.rs +++ b/crates/db_views_actor/src/person_view.rs @@ -99,15 +99,15 @@ fn queries<'a>( ) .filter(person::deleted.eq(false)); } - ListMode::Query(options) => { - if let Some(search_term) = options.search_term { + ListMode::Query(o) => { + if let Some(search_term) = o.search_term { let searcher = fuzzy_search(&search_term); query = query .filter(person::name.ilike(searcher.clone())) .or_filter(person::display_name.ilike(searcher)); } - let sort = options.sort.map(post_to_person_sort_type); + let sort = o.sort.map(post_to_person_sort_type); query = match sort.unwrap_or(PersonSortType::CommentScore) { PersonSortType::New => query.order_by(person::published.desc()), PersonSortType::Old => query.order_by(person::published.asc()), @@ -117,10 +117,10 @@ fn queries<'a>( PersonSortType::PostCount => query.order_by(person_aggregates::post_count.desc()), }; - let (limit, offset) = limit_and_offset(options.page, options.limit)?; + let (limit, offset) = limit_and_offset(o.page, o.limit)?; query = query.limit(limit).offset(offset); - if let Some(listing_type) = options.listing_type { + if let Some(listing_type) = o.listing_type { query = match listing_type { // return nothing as its not possible to follow users ListingType::Subscribed => query.limit(0), diff --git a/crates/db_views_moderator/Cargo.toml b/crates/db_views_moderator/Cargo.toml index 9e0185e4b..a7257c4f1 100644 --- a/crates/db_views_moderator/Cargo.toml +++ b/crates/db_views_moderator/Cargo.toml @@ -29,13 +29,13 @@ lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } diesel = { workspace = true, features = [ - "postgres", "chrono", + "postgres", "serde_json", ], optional = true } diesel-async = { workspace = true, features = [ - "postgres", "deadpool", + "postgres", ], optional = true } serde = { workspace = true } serde_with = { workspace = true } diff --git a/crates/federate/Cargo.toml b/crates/federate/Cargo.toml index 5d7454276..bdfc00678 100644 --- a/crates/federate/Cargo.toml +++ b/crates/federate/Cargo.toml @@ -25,7 +25,7 @@ activitypub_federation.workspace = true anyhow.workspace = true futures.workspace = true chrono.workspace = true -diesel = { workspace = true, features = ["postgres", "chrono", "serde_json"] } +diesel = { workspace = true, features = ["chrono", "postgres", "serde_json"] } diesel-async = { workspace = true, features = ["deadpool", "postgres"] } reqwest.workspace = true serde_json.workspace = true diff --git a/crates/routes/src/feeds.rs b/crates/routes/src/feeds.rs index 55e9cc7f3..cd1ca3e98 100644 --- a/crates/routes/src/feeds.rs +++ b/crates/routes/src/feeds.rs @@ -454,7 +454,6 @@ fn build_item( protocol_and_hostname: &str, ) -> LemmyResult { // TODO add images - let author_url = format!("{protocol_and_hostname}/u/{creator_name}"); let guid = Some(Guid { permalink: true, value: url.to_owned(), @@ -464,7 +463,8 @@ fn build_item( Ok(Item { title: Some(format!("Reply from {creator_name}")), author: Some(format!( - "/u/{creator_name} (link)" + "/u/{creator_name} (link)", + format_args!("{protocol_and_hostname}/u/{creator_name}") )), pub_date: Some(published.to_rfc2822()), comments: Some(url.to_owned()), diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 7ed4c0476..ded84132c 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -65,10 +65,10 @@ anyhow = { workspace = true, optional = true } reqwest-middleware = { workspace = true, optional = true } strum = { workspace = true } futures = { workspace = true, optional = true } -diesel = { workspace = true, features = ["chrono"], optional = true } +diesel = { workspace = true, optional = true, features = ["chrono"] } http = { workspace = true, optional = true } doku = { workspace = true, features = ["url-2"], optional = true } -uuid = { workspace = true, features = ["serde", "v4"], optional = true } +uuid = { workspace = true, optional = true, features = ["v4"] } rosetta-i18n = { workspace = true, optional = true } tokio = { workspace = true, optional = true } urlencoding = { workspace = true, optional = true } @@ -77,9 +77,8 @@ deser-hjson = { version = "2.2.4", optional = true } smart-default = { version = "0.7.1", optional = true } lettre = { version = "0.11.10", default-features = false, features = [ "builder", - "tokio1", - "tokio1-rustls-tls", "smtp-transport", + "tokio1-rustls-tls", ], optional = true } markdown-it = { version = "0.6.1", optional = true } ts-rs = { workspace = true, optional = true } diff --git a/crates/utils/src/utils/markdown/image_links.rs b/crates/utils/src/utils/markdown/image_links.rs index 0990b1bc7..7914452ff 100644 --- a/crates/utils/src/utils/markdown/image_links.rs +++ b/crates/utils/src/utils/markdown/image_links.rs @@ -24,7 +24,8 @@ pub fn markdown_rewrite_image_links(mut src: String) -> (String, Vec) { ); // restore custom emoji format if let Some(extra) = extra { - proxied = format!("{proxied} {extra}"); + proxied.push(' '); + proxied.push_str(extra); } src.replace_range(start..end, &proxied); } diff --git a/crates/utils/src/utils/markdown/mod.rs b/crates/utils/src/utils/markdown/mod.rs index ba509596e..25ac0ffd6 100644 --- a/crates/utils/src/utils/markdown/mod.rs +++ b/crates/utils/src/utils/markdown/mod.rs @@ -47,8 +47,10 @@ pub fn markdown_check_for_blocked_urls(text: &str, blocklist: &RegexSet) -> Lemm mod tests { use super::*; + use crate::utils::validation::check_urls_are_valid; use image_links::markdown_rewrite_image_links; use pretty_assertions::assert_eq; + use regex::escape; #[test] fn test_basic_markdown() { @@ -191,9 +193,20 @@ mod tests { }); } + // 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 = RegexSet::new(vec![r"(https://)?example\.com/?"])?; + 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() @@ -221,37 +234,42 @@ mod tests { ) .is_err()); - let set = RegexSet::new(vec![r"(https://)?example\.com/spam\.jpg"])?; - assert!(markdown_check_for_blocked_urls( - &String::from("![](https://example.com/spam.jpg)"), - &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 = RegexSet::new(vec![ - r"(https://)?quo\.example\.com/?", - r"(https://)?foo\.example\.com/?", - r"(https://)?bar\.example\.com/?", + 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(&String::from("https://baz.example.com"), &set).is_ok() - ); + assert!(markdown_check_for_blocked_urls("https://baz.example.com", &set).is_ok()); - assert!( - markdown_check_for_blocked_urls(&String::from("https://bar.example.com"), &set).is_err() - ); + assert!(markdown_check_for_blocked_urls("https://bar.example.com", &set).is_err()); - let set = RegexSet::new(vec![r"(https://)?example\.com/banned_page"])?; + let set = create_url_blocklist_test_regex_set(vec!["example.com/banned_page"])?; - assert!( - markdown_check_for_blocked_urls(&String::from("https://example.com/page"), &set).is_ok() - ); + assert!(markdown_check_for_blocked_urls("https://example.com/page", &set).is_ok()); - let set = RegexSet::new(vec![r"(https://)?ex\.mple\.com/?"])?; + 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(()) } diff --git a/docker/Dockerfile b/docker/Dockerfile index 93f17bb95..5bb39555a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.10 +# syntax=docker/dockerfile:1.12 ARG RUST_VERSION=1.81 ARG CARGO_BUILD_FEATURES=default ARG RUST_RELEASE_MODE=debug diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index cb438af3a..dc978244e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -53,7 +53,7 @@ services: lemmy-ui: # use "image" to pull down an already compiled lemmy-ui. make sure to comment out "build". - image: dessalines/lemmy-ui:0.19.6 + image: dessalines/lemmy-ui:0.19.8 # 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 diff --git a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql index cbef85ecc..805d2ca94 100644 --- a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql +++ b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql @@ -12,8 +12,6 @@ CREATE TABLE person_content_combined ( CREATE INDEX idx_person_content_combined_published ON person_content_combined (published DESC, id DESC); -CREATE INDEX idx_person_content_combined_published_asc ON person_content_combined (reverse_timestamp_sort (published) DESC, id DESC); - -- Updating the history INSERT INTO person_content_combined (published, post_id, comment_id) SELECT @@ -33,7 +31,7 @@ FROM -- This one is special, because you use the saved date, not the ordinary published CREATE TABLE person_saved_combined ( id serial PRIMARY KEY, - published timestamptz NOT NULL, + saved timestamptz NOT NULL, person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, post_id int UNIQUE REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, comment_id int UNIQUE REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, @@ -41,14 +39,12 @@ CREATE TABLE person_saved_combined ( CHECK (num_nonnulls (post_id, comment_id) = 1) ); -CREATE INDEX idx_person_saved_combined_published ON person_saved_combined (published DESC, id DESC); - -CREATE INDEX idx_person_saved_combined_published_asc ON person_saved_combined (reverse_timestamp_sort (published) DESC, id DESC); +CREATE INDEX idx_person_saved_combined_published ON person_saved_combined (saved DESC, id DESC); CREATE INDEX idx_person_saved_combined ON person_saved_combined (person_id); -- Updating the history -INSERT INTO person_saved_combined (published, person_id, post_id, comment_id) +INSERT INTO person_saved_combined (saved, person_id, post_id, comment_id) SELECT saved, person_id, diff --git a/migrations/2024-12-17-144959_community-post-tags/down.sql b/migrations/2024-12-17-144959_community-post-tags/down.sql new file mode 100644 index 000000000..9e6e2299f --- /dev/null +++ b/migrations/2024-12-17-144959_community-post-tags/down.sql @@ -0,0 +1,4 @@ +DROP TABLE post_tag; + +DROP TABLE tag; + diff --git a/migrations/2024-12-17-144959_community-post-tags/up.sql b/migrations/2024-12-17-144959_community-post-tags/up.sql new file mode 100644 index 000000000..f0c596e09 --- /dev/null +++ b/migrations/2024-12-17-144959_community-post-tags/up.sql @@ -0,0 +1,23 @@ +-- 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 text NOT NULL, + community_id int NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE, + published timestamptz NOT NULL DEFAULT now(), + updated timestamptz, + 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) +); + diff --git a/migrations/2024-12-20-090225_update-replaceable-schema/down.sql b/migrations/2024-12-20-090225_update-replaceable-schema/down.sql new file mode 100644 index 000000000..deb75def2 --- /dev/null +++ b/migrations/2024-12-20-090225_update-replaceable-schema/down.sql @@ -0,0 +1,3 @@ +SELECT + 1; + diff --git a/migrations/2024-12-20-090225_update-replaceable-schema/up.sql b/migrations/2024-12-20-090225_update-replaceable-schema/up.sql new file mode 100644 index 000000000..deb75def2 --- /dev/null +++ b/migrations/2024-12-20-090225_update-replaceable-schema/up.sql @@ -0,0 +1,3 @@ +SELECT + 1; + diff --git a/src/lib.rs b/src/lib.rs index 5586b6159..f344f2927 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -370,3 +370,18 @@ fn cors_config(settings: &Settings) -> Cors { _ => cors_default, } } + +#[cfg(test)] +pub mod tests { + use activitypub_federation::config::Data; + use lemmy_api_common::context::LemmyContext; + use std::env::set_current_dir; + + pub async fn test_context() -> Data { + // hack, necessary so that config file can be loaded from hardcoded, relative path. + // Ignore errors as this gets called once for every test (so changing dir again would fail). + set_current_dir("crates/utils").ok(); + + LemmyContext::init_test_context().await + } +} diff --git a/src/scheduled_tasks.rs b/src/scheduled_tasks.rs index 3406bf694..b656ac36f 100644 --- a/src/scheduled_tasks.rs +++ b/src/scheduled_tasks.rs @@ -40,16 +40,19 @@ use lemmy_db_schema::{ utils::{find_action, functions::coalesce, get_conn, now, DbPool, DELETED_REPLACEMENT_TEXT}, }; use lemmy_routes::nodeinfo::{NodeInfo, NodeInfoWellKnown}; -use lemmy_utils::error::LemmyResult; +use lemmy_utils::error::{LemmyErrorType, LemmyResult}; use reqwest_middleware::ClientWithMiddleware; use std::time::Duration; -use tracing::{error, info, warn}; +use tracing::{info, warn}; /// Schedules various cleanup tasks for lemmy in a background thread pub async fn setup(context: Data) -> LemmyResult<()> { // Setup the connections let mut scheduler = AsyncScheduler::new(); - startup_jobs(&mut context.pool()).await; + startup_jobs(&mut context.pool()) + .await + .inspect_err(|e| warn!("Failed to run startup tasks: {e}")) + .ok(); let context_1 = context.clone(); // Update active counts expired bans and unpublished posts every hour @@ -57,9 +60,18 @@ pub async fn setup(context: Data) -> LemmyResult<()> { let context = context_1.clone(); async move { - active_counts(&mut context.pool()).await; - update_banned_when_expired(&mut context.pool()).await; - delete_instance_block_when_expired(&mut context.pool()).await; + active_counts(&mut context.pool()) + .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(); } }); @@ -69,9 +81,18 @@ pub async fn setup(context: Data) -> LemmyResult<()> { let context = context_1.reset_request_count(); async move { - update_hot_ranks(&mut context.pool()).await; - delete_expired_captcha_answers(&mut context.pool()).await; - publish_scheduled_posts(&context).await; + update_hot_ranks(&mut context.pool()) + .await + .inspect_err(|e| warn!("Failed to update hot ranks: {e}")) + .ok(); + delete_expired_captcha_answers(&mut context.pool()) + .await + .inspect_err(|e| warn!("Failed to delete expired captcha answers: {e}")) + .ok(); + publish_scheduled_posts(&context) + .await + .inspect_err(|e| warn!("Failed to publish scheduled posts: {e}")) + .ok(); } }); @@ -81,7 +102,10 @@ pub async fn setup(context: Data) -> LemmyResult<()> { let context = context_1.clone(); async move { - clear_old_activities(&mut context.pool()).await; + clear_old_activities(&mut context.pool()) + .await + .inspect_err(|e| warn!("Failed to clear old activities: {e}")) + .ok(); } }); @@ -94,8 +118,14 @@ pub async fn setup(context: Data) -> LemmyResult<()> { let context = context_1.clone(); async move { - overwrite_deleted_posts_and_comments(&mut context.pool()).await; - delete_old_denied_users(&mut context.pool()).await; + 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}")) @@ -111,49 +141,44 @@ pub async fn setup(context: Data) -> LemmyResult<()> { } /// Run these on server startup -async fn startup_jobs(pool: &mut DbPool<'_>) { - active_counts(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; +async fn startup_jobs(pool: &mut DbPool<'_>) -> LemmyResult<()> { + active_counts(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?; + Ok(()) } /// 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<'_>) { +async fn update_hot_ranks(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Updating hot ranks for all history..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - process_post_aggregates_ranks_in_batches(&mut conn).await; + process_post_aggregates_ranks_in_batches(&mut conn).await?; - process_ranks_in_batches( - &mut conn, - "comment", - "a.hot_rank != 0", - "SET hot_rank = r.hot_rank(a.score, a.published)", - ) - .await; + process_ranks_in_batches( + &mut conn, + "comment", + "a.hot_rank != 0", + "SET hot_rank = r.hot_rank(a.score, a.published)", + ) + .await?; - process_ranks_in_batches( - &mut conn, - "community", - "a.hot_rank != 0", - "SET hot_rank = r.hot_rank(a.subscribers, a.published)", - ) - .await; + process_ranks_in_batches( + &mut conn, + "community", + "a.hot_rank != 0", + "SET hot_rank = r.hot_rank(a.subscribers, a.published)", + ) + .await?; - info!("Finished hot ranks update!"); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } - } + info!("Finished hot ranks update!"); + Ok(()) } #[derive(QueryableByName)] @@ -171,7 +196,7 @@ async fn process_ranks_in_batches( 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 update_batch_size = 1000; // Bigger batches than this tend to cause seq scans @@ -180,7 +205,7 @@ async fn process_ranks_in_batches( 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 result = sql_query(format!( + let updated_rows = sql_query(format!( r#"WITH batch AS (SELECT a.{id_column} FROM {aggregates_table} a WHERE a.published > $1 AND ({where_clause}) @@ -190,43 +215,37 @@ async fn process_ranks_in_batches( UPDATE {aggregates_table} a {set_clause} FROM batch WHERE a.{id_column} = batch.{id_column} RETURNING a.published; "#, - id_column = format!("{table_name}_id"), - aggregates_table = format!("{table_name}_aggregates"), - set_clause = set_clause, - where_clause = where_clause + id_column = format_args!("{table_name}_id"), + aggregates_table = format_args!("{table_name}_aggregates"), )) .bind::(previous_batch_last_published) .bind::(update_batch_size) .get_results::(conn) - .await; + .await + .map_err(|e| { + LemmyErrorType::Unknown(format!("Failed to update {} hot_ranks: {}", table_name, e)) + })?; - match result { - Ok(updated_rows) => { - processed_rows_count += updated_rows.len(); - previous_batch_result = updated_rows.last().map(|row| row.published); - } - Err(e) => { - error!("Failed to update {} hot_ranks: {}", table_name, e); - break; - } - } + processed_rows_count += updated_rows.len(); + previous_batch_result = updated_rows.last().map(|row| row.published); } 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) { +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 update_batch_size = 1000; // Bigger batches than this tend to cause seq scans 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 result = sql_query( + let updated_rows = sql_query( r#"WITH batch AS (SELECT pa.post_id FROM post_aggregates pa WHERE pa.published > $1 @@ -245,283 +264,190 @@ async fn process_post_aggregates_ranks_in_batches(conn: &mut AsyncPgConnection) .bind::(previous_batch_last_published) .bind::(update_batch_size) .get_results::(conn) - .await; + .await.map_err(|e| LemmyErrorType::Unknown(format!("Failed to update {} hot_ranks: {}", "post_aggregates", e)))?; - match result { - Ok(updated_rows) => { - processed_rows_count += updated_rows.len(); - previous_batch_result = updated_rows.last().map(|row| row.published); - } - Err(e) => { - error!("Failed to update {} hot_ranks: {}", "post_aggregates", e); - break; - } - } + processed_rows_count += updated_rows.len(); + previous_batch_result = updated_rows.last().map(|row| row.published); } info!( "Finished process_hot_ranks_in_batches execution for {} (processed {} rows)", "post_aggregates", processed_rows_count ); + Ok(()) } -async fn delete_expired_captcha_answers(pool: &mut DbPool<'_>) { - let conn = get_conn(pool).await; +async fn delete_expired_captcha_answers(pool: &mut DbPool<'_>) -> LemmyResult<()> { + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - diesel::delete( - captcha_answer::table - .filter(captcha_answer::published.lt(now() - IntervalDsl::minutes(10))), - ) - .execute(&mut conn) - .await - .map(|_| { - info!("Done."); - }) - .inspect_err(|e| error!("Failed to clear old captcha answers: {e}")) - .ok(); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } - } + diesel::delete( + captcha_answer::table.filter(captcha_answer::published.lt(now() - IntervalDsl::minutes(10))), + ) + .execute(&mut conn) + .await?; + info!("Done."); + + Ok(()) } /// Clear old activities (this table gets very large) -async fn clear_old_activities(pool: &mut DbPool<'_>) { +async fn clear_old_activities(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Clearing old activities..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - diesel::delete( - sent_activity::table.filter(sent_activity::published.lt(now() - IntervalDsl::days(7))), - ) - .execute(&mut conn) - .await - .inspect_err(|e| error!("Failed to clear old sent activities: {e}")) - .ok(); + diesel::delete( + sent_activity::table.filter(sent_activity::published.lt(now() - IntervalDsl::days(7))), + ) + .execute(&mut conn) + .await?; - diesel::delete( - received_activity::table - .filter(received_activity::published.lt(now() - IntervalDsl::days(7))), - ) - .execute(&mut conn) - .await - .map(|_| info!("Done.")) - .inspect_err(|e| error!("Failed to clear old received activities: {e}")) - .ok(); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } - } + diesel::delete( + received_activity::table.filter(received_activity::published.lt(now() - IntervalDsl::days(7))), + ) + .execute(&mut conn) + .await?; + info!("Done."); + Ok(()) } -async fn delete_old_denied_users(pool: &mut DbPool<'_>) { - LocalUser::delete_old_denied_local_users(pool) - .await - .map(|_| { - info!("Done."); - }) - .inspect_err(|e| error!("Failed to deleted old denied users: {e}")) - .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<'_>) { +async fn overwrite_deleted_posts_and_comments(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Overwriting deleted posts..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - diesel::update( - post::table - .filter(post::deleted.eq(true)) - .filter(post::updated.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(&mut conn) - .await - .map(|_| { - info!("Done."); - }) - .inspect_err(|e| error!("Failed to overwrite deleted posts: {e}")) - .ok(); + diesel::update( + post::table + .filter(post::deleted.eq(true)) + .filter(post::updated.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(&mut conn) + .await?; - info!("Overwriting deleted comments..."); - diesel::update( - comment::table - .filter(comment::deleted.eq(true)) - .filter(comment::updated.lt(now().nullable() - 1.months())) - .filter(comment::content.ne(DELETED_REPLACEMENT_TEXT)), - ) - .set(comment::content.eq(DELETED_REPLACEMENT_TEXT)) - .execute(&mut conn) - .await - .map(|_| { - info!("Done."); - }) - .inspect_err(|e| error!("Failed to overwrite deleted comments: {e}")) - .ok(); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } - } + info!("Overwriting deleted comments..."); + diesel::update( + comment::table + .filter(comment::deleted.eq(true)) + .filter(comment::updated.lt(now().nullable() - 1.months())) + .filter(comment::content.ne(DELETED_REPLACEMENT_TEXT)), + ) + .set(comment::content.eq(DELETED_REPLACEMENT_TEXT)) + .execute(&mut conn) + .await?; + info!("Done."); + Ok(()) } /// Re-calculate the site and community active counts every 12 hours -async fn active_counts(pool: &mut DbPool<'_>) { +async fn active_counts(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Updating active site and community aggregates ..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - let intervals = vec![ - ("1 day", "day"), - ("1 week", "week"), - ("1 month", "month"), - ("6 months", "half_year"), - ]; + let intervals = vec![ + ("1 day", "day"), + ("1 week", "week"), + ("1 month", "month"), + ("6 months", "half_year"), + ]; - for (full_form, abbr) in &intervals { - let update_site_stmt = format!( + for (full_form, abbr) in &intervals { + let update_site_stmt = format!( "update site_aggregates set users_active_{} = (select * from r.site_aggregates_activity('{}')) where site_id = 1", abbr, full_form ); - sql_query(update_site_stmt) - .execute(&mut conn) - .await - .inspect_err(|e| error!("Failed to update site stats: {e}")) - .ok(); + sql_query(update_site_stmt).execute(&mut conn).await?; - let update_community_stmt = format!("update community_aggregates ca set users_active_{} = mv.count_ from r.community_aggregates_activity('{}') mv where ca.community_id = mv.community_id_", abbr, full_form); - sql_query(update_community_stmt) - .execute(&mut conn) - .await - .inspect_err(|e| error!("Failed to update community stats: {e}")) - .ok(); - } - - info!("Done."); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } + let update_community_stmt = format!("update community_aggregates ca set users_active_{} = mv.count_ from r.community_aggregates_activity('{}') mv where ca.community_id = mv.community_id_", abbr, full_form); + sql_query(update_community_stmt).execute(&mut conn).await?; } + + info!("Done."); + Ok(()) } /// Set banned to false after ban expires -async fn update_banned_when_expired(pool: &mut DbPool<'_>) { +async fn update_banned_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Updating banned column if it expires ..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - diesel::update( - person::table - .filter(person::banned.eq(true)) - .filter(person::ban_expires.lt(now().nullable())), - ) - .set(person::banned.eq(false)) - .execute(&mut conn) - .await - .inspect_err(|e| error!("Failed to update person.banned when expires: {e}")) - .ok(); + diesel::update( + person::table + .filter(person::banned.eq(true)) + .filter(person::ban_expires.lt(now().nullable())), + ) + .set(person::banned.eq(false)) + .execute(&mut conn) + .await?; - diesel::delete( - community_actions::table.filter(community_actions::ban_expires.lt(now().nullable())), - ) - .execute(&mut conn) - .await - .inspect_err(|e| error!("Failed to remove community_ban expired rows: {e}")) - .ok(); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } - } + diesel::delete( + community_actions::table.filter(community_actions::ban_expires.lt(now().nullable())), + ) + .execute(&mut conn) + .await?; + Ok(()) } /// Set banned to false after ban expires -async fn delete_instance_block_when_expired(pool: &mut DbPool<'_>) { +async fn delete_instance_block_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> { info!("Delete instance blocks when expired ..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - diesel::delete( - federation_blocklist::table.filter(federation_blocklist::expires.lt(now().nullable())), - ) - .execute(&mut conn) - .await - .inspect_err(|e| error!("Failed to remove federation_blocklist expired rows: {e}")) - .ok(); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } - } + diesel::delete( + federation_blocklist::table.filter(federation_blocklist::expires.lt(now().nullable())), + ) + .execute(&mut conn) + .await?; + Ok(()) } /// Find all unpublished posts with scheduled date in the future, and publish them. -async fn publish_scheduled_posts(context: &Data) { +async fn publish_scheduled_posts(context: &Data) -> LemmyResult<()> { let pool = &mut context.pool(); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - 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.is_not_null()) - .filter(coalesce(post::scheduled_publish_time, now()).lt(now())) - // make sure the post, person and community are still around - .filter(not(post::deleted.or(post::removed))) - .filter(not(person::banned.or(person::deleted))) - .filter(not(community::removed.or(community::deleted))) - // ensure that user isnt banned from community - .filter(not(exists(find_action( - community_actions::received_ban, - (person::id, community::id), - )))) - .select((post::all_columns, community::all_columns)) - .get_results::<(Post, Community)>(&mut conn) - .await - .inspect_err(|e| error!("Failed to read unpublished posts: {e}")) - .ok() - .unwrap_or_default(); + 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.is_not_null()) + .filter(coalesce(post::scheduled_publish_time, now()).lt(now())) + // make sure the post, person and community are still around + .filter(not(post::deleted.or(post::removed))) + .filter(not(person::banned.or(person::deleted))) + .filter(not(community::removed.or(community::deleted))) + // ensure that user isnt banned from community + .filter(not(exists(find_action( + community_actions::received_ban, + (person::id, community::id), + )))) + .select((post::all_columns, community::all_columns)) + .get_results::<(Post, Community)>(&mut conn) + .await?; - for (post, community) in scheduled_posts { - // mark post as published in db - let form = PostUpdateForm { - scheduled_publish_time: Some(None), - ..Default::default() - }; - Post::update(&mut context.pool(), post.id, &form) - .await - .inspect_err(|e| error!("Failed update scheduled post: {e}")) - .ok(); + for (post, community) in scheduled_posts { + // mark post as published in db + let form = PostUpdateForm { + scheduled_publish_time: 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) - .inspect_err(|e| error!("Failed federate scheduled post: {e}")) - .ok(); - send_webmention(post, community); - } - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); - } + // 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. @@ -535,23 +461,16 @@ async fn update_instance_software( client: &ClientWithMiddleware, ) -> LemmyResult<()> { info!("Updating instances software and versions..."); - let conn = get_conn(pool).await; + let mut conn = get_conn(pool).await?; - match conn { - Ok(mut conn) => { - let instances = instance::table.get_results::(&mut conn).await?; + let instances = instance::table.get_results::(&mut 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..."); - } - Err(e) => { - error!("Failed to get connection from pool: {e}"); + 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(()) } @@ -623,7 +542,8 @@ async fn build_update_instance_form( #[cfg(test)] mod tests { - use crate::scheduled_tasks::build_update_instance_form; + use super::*; + use crate::{scheduled_tasks::build_update_instance_form, tests::test_context}; use lemmy_api_common::request::client_builder; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, @@ -634,7 +554,6 @@ mod tests { use serial_test::serial; #[tokio::test] - #[serial] 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) @@ -645,7 +564,6 @@ mod tests { } #[tokio::test] - #[serial] 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) @@ -654,4 +572,16 @@ mod tests { assert_eq!(form.software.ok_or(LemmyErrorType::NotFound)?, "mastodon"); Ok(()) } + + #[tokio::test] + #[serial] + async fn test_scheduled_tasks_no_errors() -> LemmyResult<()> { + let context = test_context().await; + + startup_jobs(&mut context.pool()).await?; + update_instance_software(&mut context.pool(), context.client()).await?; + delete_expired_captcha_answers(&mut context.pool()).await?; + publish_scheduled_posts(&context).await?; + Ok(()) + } } diff --git a/src/session_middleware.rs b/src/session_middleware.rs index b495bdbb9..7e0f38a4e 100644 --- a/src/session_middleware.rs +++ b/src/session_middleware.rs @@ -99,7 +99,7 @@ where #[cfg(test)] mod tests { - use super::*; + use crate::tests::test_context; use actix_web::test::TestRequest; use lemmy_api_common::claims::Claims; use lemmy_db_schema::{ @@ -107,45 +107,29 @@ mod tests { instance::Instance, local_user::{LocalUser, LocalUserInsertForm}, person::{Person, PersonInsertForm}, - secret::Secret, }, traits::Crud, - utils::build_db_pool_for_tests, }; - use lemmy_utils::{error::LemmyResult, rate_limit::RateLimitCell}; + use lemmy_utils::error::LemmyResult; use pretty_assertions::assert_eq; - use reqwest::Client; - use reqwest_middleware::ClientBuilder; use serial_test::serial; - use std::env::set_current_dir; #[tokio::test] #[serial] async fn test_session_auth() -> LemmyResult<()> { - // hack, necessary so that config file can be loaded from hardcoded, relative path - set_current_dir("crates/utils")?; + let context = test_context().await; - let pool_ = build_db_pool_for_tests(); - let pool = &mut (&pool_).into(); - - let secret = Secret::init(pool).await?; - - let context = LemmyContext::create( - pool_.clone(), - ClientBuilder::new(Client::default()).build(), - secret, - RateLimitCell::with_test_config(), - ); - - let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; + let inserted_instance = + Instance::read_or_create(&mut context.pool(), "my_domain.tld".to_string()).await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "Gerry9812"); - let inserted_person = Person::create(pool, &new_person).await?; + 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(pool, &local_user_form, vec![]).await?; + 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, req, &context).await?; @@ -153,7 +137,7 @@ mod tests { let valid = Claims::validate(&jwt, &context).await; assert!(valid.is_ok()); - let num_deleted = Person::delete(pool, inserted_person.id).await?; + let num_deleted = Person::delete(&mut context.pool(), inserted_person.id).await?; assert_eq!(1, num_deleted); Ok(())