From 51339dfdd764dfeb0e13c92be2834c9467db3fa5 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:06:56 -0700 Subject: [PATCH 1/6] :bug: Fix dropped `JobScheduler` since i was not capturing the scheduer, it was just immediately dropped lol --- apps/server/src/http_server.rs | 4 ++-- core/src/job/scheduler.rs | 1 + core/src/lib.rs | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server/src/http_server.rs b/apps/server/src/http_server.rs index 6a00a23bfe..77c5dca222 100644 --- a/apps/server/src/http_server.rs +++ b/apps/server/src/http_server.rs @@ -57,8 +57,8 @@ pub async fn run_http_server(config: StumpConfig) -> ServerResult<()> { .await .map_err(|e| ServerError::ServerStartError(e.to_string()))?; - // Initialize the scheduler - core.init_scheduler() + let _scheduler = core + .init_scheduler() .await .map_err(|e| ServerError::ServerStartError(e.to_string()))?; diff --git a/core/src/job/scheduler.rs b/core/src/job/scheduler.rs index 16c103f5f9..24b94e4361 100644 --- a/core/src/job/scheduler.rs +++ b/core/src/job/scheduler.rs @@ -12,6 +12,7 @@ use crate::job::stump_job::StumpJob; use crate::{CoreError, CoreResult, Ctx}; /// A scheduler that loads cron-based jobs and spawns them accordingly +#[must_use = "dropping the JobScheduler aborts all scheduled job loops"] pub struct JobScheduler { handles: Vec>, } diff --git a/core/src/lib.rs b/core/src/lib.rs index e8bf2aedea..927d67ddea 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -293,10 +293,10 @@ impl StumpCore { } } - pub async fn init_scheduler(&self) -> Result, CoreError> { + pub async fn init_scheduler(&self) -> Result { let ctx = self.ctx.arced(); let scheduler = JobScheduler::init(ctx).await?; - Ok(Arc::new(scheduler)) + Ok(scheduler) } pub async fn init_library_watcher(&self) -> CoreResult<()> { From d4562b5696978ca55e549d4df1a3925c6bfb4a05 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:20:41 -0700 Subject: [PATCH 2/6] :adhesive_bandage: Fix edge-case string filters with wildcards --- crates/graphql/src/filter/keyword.rs | 60 ++++++++++++++++++++ crates/graphql/src/filter/mod.rs | 82 +++++++++++++++++++++++++--- 2 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 crates/graphql/src/filter/keyword.rs diff --git a/crates/graphql/src/filter/keyword.rs b/crates/graphql/src/filter/keyword.rs new file mode 100644 index 0000000000..8537ddda75 --- /dev/null +++ b/crates/graphql/src/filter/keyword.rs @@ -0,0 +1,60 @@ +//! LIKE-pattern escaping helpers for string filters + +use sea_orm::sea_query::LikeExpr; + +/// The escape character paired with every generated `LIKE` pattern +const LIKE_ESCAPE_CHAR: char = '\\'; + +/// Escapes the LIKE metacharacters `%` and `_`, plus the escape character +/// itself, so e.g., a search for `50%` is correctly understood to be literal +/// and not a pattern +pub fn escape_like_fragment(input: &str) -> String { + let mut escaped = String::with_capacity(input.len()); + for ch in input.chars() { + if ch == LIKE_ESCAPE_CHAR || ch == '%' || ch == '_' { + escaped.push(LIKE_ESCAPE_CHAR); + } + escaped.push(ch); + } + escaped +} + +/// `%value%`, with `value` lowercased and escaped +pub(crate) fn like_contains(value: &str) -> LikeExpr { + LikeExpr::new(format!("%{}%", escape_like_fragment(&value.to_lowercase()))) + .escape(LIKE_ESCAPE_CHAR) +} + +/// `value%`, with `value` lowercased and escaped +pub(crate) fn like_starts_with(value: &str) -> LikeExpr { + LikeExpr::new(format!("{}%", escape_like_fragment(&value.to_lowercase()))) + .escape(LIKE_ESCAPE_CHAR) +} + +/// `%value`, with `value` lowercased and escaped +pub(crate) fn like_ends_with(value: &str) -> LikeExpr { + LikeExpr::new(format!("%{}", escape_like_fragment(&value.to_lowercase()))) + .escape(LIKE_ESCAPE_CHAR) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_like_fragment_handles_wildcards() { + assert_eq!(escape_like_fragment("50%"), r"50\%"); + assert_eq!(escape_like_fragment("file_name"), r"file\_name"); + assert_eq!(escape_like_fragment("100% true"), r"100\% true"); + assert_eq!( + escape_like_fragment(r"already\escaped"), + r"already\\escaped" + ); + } + + #[test] + fn escape_like_fragment_noop_for_safe_strings() { + assert_eq!(escape_like_fragment("normal"), "normal"); + assert_eq!(escape_like_fragment("with spaces"), "with spaces"); + } +} diff --git a/crates/graphql/src/filter/mod.rs b/crates/graphql/src/filter/mod.rs index 834ccebcc1..078d00fb02 100644 --- a/crates/graphql/src/filter/mod.rs +++ b/crates/graphql/src/filter/mod.rs @@ -8,6 +8,7 @@ use sea_orm::{ }; use serde::{Deserialize, Serialize}; +pub mod keyword; pub mod library; pub mod log; pub mod media; @@ -15,6 +16,8 @@ pub mod media_metadata; pub mod series; pub mod series_metadata; +use keyword::{like_contains, like_ends_with, like_starts_with}; + // TODO: This probably needs a rewrite to make it more compatible with async-graphql. The big issue is generics // with input objects. Look at and yoink from seaography for how they are doing things @@ -77,28 +80,28 @@ where let v: String = value.into(); Condition::all().add( QExpr::expr(Func::lower(QExpr::col(column.as_column_ref()))) - .like(format!("%{}%", v.to_lowercase())), + .like(like_contains(&v)), ) }, StringLikeFilter::Excludes(value) => { let v: String = value.into(); Condition::all().add( QExpr::expr(Func::lower(QExpr::col(column.as_column_ref()))) - .not_like(format!("%{}%", v.to_lowercase())), + .not_like(like_contains(&v)), ) }, StringLikeFilter::StartsWith(value) => { let v: String = value.into(); Condition::all().add( QExpr::expr(Func::lower(QExpr::col(column.as_column_ref()))) - .like(format!("{}%", v.to_lowercase())), + .like(like_starts_with(&v)), ) }, StringLikeFilter::EndsWith(value) => { let v: String = value.into(); Condition::all().add( QExpr::expr(Func::lower(QExpr::col(column.as_column_ref()))) - .like(format!("%{}", v.to_lowercase())), + .like(like_ends_with(&v)), ) }, StringLikeFilter::LikeAnyOf(values) => { @@ -106,7 +109,7 @@ where let v: String = value.into(); acc.add( QExpr::expr(Func::lower(QExpr::col(column.as_column_ref()))) - .like(format!("%{}%", v.to_lowercase())), + .like(like_contains(&v)), ) }) }, @@ -116,7 +119,7 @@ where let v: String = value.into(); acc.add( QExpr::expr(Func::lower(QExpr::col(column.as_column_ref()))) - .like(format!("%{}%", v.to_lowercase())), + .like(like_contains(&v)), ) }) .not(), @@ -226,7 +229,7 @@ mod tests { assert_eq!( sql, - r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%test%' OR LOWER("media"."name") LIKE '%example%'"# + r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%test%' ESCAPE '\' OR LOWER("media"."name") LIKE '%example%' ESCAPE '\'"# ); } @@ -243,7 +246,70 @@ mod tests { assert_eq!( sql, - r#"SELECT FROM "media" WHERE NOT (LOWER("media"."name") LIKE '%test%' OR LOWER("media"."name") LIKE '%example%')"# + r#"SELECT FROM "media" WHERE NOT (LOWER("media"."name") LIKE '%test%' ESCAPE '\' OR LOWER("media"."name") LIKE '%example%' ESCAPE '\')"# + ); + } + + #[test] + fn test_contains_escapes_wildcards_in_the_pattern() { + let sql = media::Entity::find() + .filter(apply_string_filter( + media::Column::Name, + StringLikeFilter::Contains("50%".to_string()), + )) + .select_only() + .into_query() + .to_string(SqliteQueryBuilder); + + assert_eq!( + sql, + r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%50\%%' ESCAPE '\'"# + ); + } + + #[test] + fn test_starts_and_ends_with_escape_wildcards() { + let starts = media::Entity::find() + .filter(apply_string_filter( + media::Column::Name, + StringLikeFilter::StartsWith("file_".to_string()), + )) + .select_only() + .into_query() + .to_string(SqliteQueryBuilder); + assert_eq!( + starts, + r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE 'file\_%' ESCAPE '\'"# + ); + + let ends = media::Entity::find() + .filter(apply_string_filter( + media::Column::Name, + StringLikeFilter::EndsWith("_v1".to_string()), + )) + .select_only() + .into_query() + .to_string(SqliteQueryBuilder); + assert_eq!( + ends, + r#"SELECT FROM "media" WHERE LOWER("media"."name") LIKE '%\_v1' ESCAPE '\'"# + ); + } + + #[test] + fn test_excludes_is_a_substring_negation() { + let sql = media::Entity::find() + .filter(apply_string_filter( + media::Column::Name, + StringLikeFilter::Excludes("annual".to_string()), + )) + .select_only() + .into_query() + .to_string(SqliteQueryBuilder); + + assert_eq!( + sql, + r#"SELECT FROM "media" WHERE LOWER("media"."name") NOT LIKE '%annual%' ESCAPE '\'"# ); } } From c9d378177e2b714eb4ccda8e2ad2cde0b3bfa239 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:25:54 -0700 Subject: [PATCH 3/6] :lock: Scope author queries to user-visible books --- crates/graphql/src/query/author.rs | 34 ++++++++++++------------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/crates/graphql/src/query/author.rs b/crates/graphql/src/query/author.rs index ddb4825b68..6085256b8f 100644 --- a/crates/graphql/src/query/author.rs +++ b/crates/graphql/src/query/author.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use async_graphql::{Context, Object, Result}; -use models::entity::{media, media_metadata, series}; +use models::entity::{media, media_metadata, series, user::AuthUser}; use sea_orm::{prelude::*, sea_query::Query, QuerySelect}; use crate::{ - data::CoreContext, + data::{AuthContext, CoreContext}, object::author::{Author, AuthorSeries}, pagination::{ OffsetPaginationInfo, PaginatedResponse, Pagination, PaginationValidator, @@ -30,23 +30,19 @@ fn series_in_library_subquery(library_id: String) -> sea_orm::sea_query::SelectS .to_owned() } -/// Fetches all unique author names from the database, optionally scoped to a library. +/// Fetches all unique author names from the database, optionally scoped to a library, +/// and scoped to whatever the given user is allowed to see. +/// /// Returns a HashMap with lowercase name as key and original casing as value. async fn fetch_all_authors( conn: &DatabaseConnection, library_id: Option, + auth_user: &AuthUser, ) -> Result> { - let mut query = media_metadata::Entity::find() + let mut query = media::Entity::find_for_user(auth_user) .select_only() .column(media_metadata::Column::Writers) .distinct() - .join_rev( - sea_orm::JoinType::InnerJoin, - media::Entity::belongs_to(media_metadata::Entity) - .from(media::Column::Id) - .to(media_metadata::Column::MediaId) - .into(), - ) .filter(media_metadata::Column::Writers.is_not_null()); if let Some(lib_id) = library_id { @@ -82,9 +78,10 @@ impl AuthorQuery { #[graphql(desc = "Optional library ID to scope the author search")] library_id: Option, ) -> Result> { + let AuthContext { user, .. } = ctx.data::()?; let conn = ctx.data::()?.conn.as_ref(); - let authors = fetch_all_authors(conn, library_id.clone()).await?; + let authors = fetch_all_authors(conn, library_id.clone(), user).await?; let search_key = name.to_lowercase(); Ok(authors.get(&search_key).map(|original_name| Author { @@ -106,9 +103,10 @@ impl AuthorQuery { #[graphql(default, validator(custom = "PaginationValidator"))] pagination: Pagination, ) -> Result> { + let AuthContext { user, .. } = ctx.data::()?; let conn = ctx.data::()?.conn.as_ref(); - let all_authors = fetch_all_authors(conn, library_id.clone()).await?; + let all_authors = fetch_all_authors(conn, library_id.clone(), user).await?; let filtered: Vec = if let Some(ref search_term) = search { let search_lower = search_term.to_lowercase(); @@ -178,19 +176,13 @@ impl AuthorQuery { #[graphql(desc = "Optional library ID to scope the series search")] library_id: Option, ) -> Result> { + let AuthContext { user, .. } = ctx.data::()?; let conn = ctx.data::()?.conn.as_ref(); - let mut query = media_metadata::Entity::find() + let mut query = media::Entity::find_for_user(user) .select_only() .column(media_metadata::Column::Series) .distinct() - .join_rev( - sea_orm::JoinType::InnerJoin, - media::Entity::belongs_to(media_metadata::Entity) - .from(media::Column::Id) - .to(media_metadata::Column::MediaId) - .into(), - ) .filter(media_metadata::Column::Series.is_not_null()); if let Some(ref lib_id) = library_id { From 30aa6f95debfa97cb7473a0b6ceebf1563d5c8e0 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:30:53 -0700 Subject: [PATCH 4/6] :adhesive_bandage: Add no-store to error responses --- apps/server/src/errors.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/errors.rs b/apps/server/src/errors.rs index a3505586b9..5ac716a87a 100644 --- a/apps/server/src/errors.rs +++ b/apps/server/src/errors.rs @@ -324,7 +324,9 @@ impl IntoResponse for APIErrorResponse { let mut builder = Response::builder() .status(self.status) - .header("Content-Type", "application/json"); + .header("Content-Type", "application/json") + // do not cache error responses + .header("Cache-Control", "no-store"); // if the status is 401, we want to encourage the client to delete their // session cookie From 80ba41ae21c6fa935a235468a1dc2a196057f890 Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:50:23 -0700 Subject: [PATCH 5/6] :adhesive_bandage: (expo): Fix style regression for completed books seems to have just been from the SDK upgrade, nothing changed there for 4 months --- .../listLayout/grid/GridImageItem.tsx | 49 +++++++++---------- apps/expo/package.json | 2 +- yarn.lock | 8 +-- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/apps/expo/components/listLayout/grid/GridImageItem.tsx b/apps/expo/components/listLayout/grid/GridImageItem.tsx index 6d1a95b8d3..0744515d9a 100644 --- a/apps/expo/components/listLayout/grid/GridImageItem.tsx +++ b/apps/expo/components/listLayout/grid/GridImageItem.tsx @@ -91,37 +91,36 @@ export default function GridImageItem({ )} {hasCompleted && ( - - - {showNumber && ( - - {numberOfReads} - - )} + + + {showNumber && ( + + {numberOfReads} + + )} - - - + + + + )} diff --git a/apps/expo/package.json b/apps/expo/package.json index f7c53b3603..bc284648da 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -62,7 +62,7 @@ "expo-application": "~56.0.3", "expo-asset": "~56.0.17", "expo-auth-session": "~56.0.14", - "expo-blur": "~56.0.3", + "expo-blur": "~56.0.4", "expo-brightness": "~56.0.5", "expo-build-properties": "~56.0.19", "expo-constants": "~56.0.18", diff --git a/yarn.lock b/yarn.lock index b1fb7305ca..fbe72253d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13931,10 +13931,10 @@ expo-auth-session@~56.0.14: expo-web-browser "~56.0.5" invariant "^2.2.4" -expo-blur@~56.0.3: - version "56.0.3" - resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-56.0.3.tgz#4420700813be7ed47298b9280d83fdfb32d04cdb" - integrity sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ== +expo-blur@~56.0.4: + version "56.0.4" + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-56.0.4.tgz#7706b6348c7a12fd423651667b2de34a40959471" + integrity sha512-/rNpe2NDTmMbSktVJb5z7HoX6apYtq8KVKJNjWOQV7NG01+LvctWzFeMNxWSZVWfIKTndqdqABwqcpYmiCRxxg== expo-brightness@~56.0.5: version "56.0.5" From a8fbdc0574138b11a462deaafc32e583720e066d Mon Sep 17 00:00:00 2001 From: Aaron Leopold <36278431+aaronleopold@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:51:36 -0700 Subject: [PATCH 6/6] :adhesive_bandage: (expo): Invalidate more queries after book progression mutation --- apps/expo/components/book/overview/BookMenu.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/expo/components/book/overview/BookMenu.tsx b/apps/expo/components/book/overview/BookMenu.tsx index 038e653d6a..e0242c1862 100644 --- a/apps/expo/components/book/overview/BookMenu.tsx +++ b/apps/expo/components/book/overview/BookMenu.tsx @@ -118,6 +118,13 @@ export default function BookMenu({ data }: Props) { client.refetchQueries({ queryKey: ['onDeck'], exact: false }), client.refetchQueries({ queryKey: ['recentlyAddedBooks'], exact: false }), client.refetchQueries({ queryKey: ['recentlyAddedSeries'], exact: false }), + // TODO: would be better to have a little bit smarter cache invalidation here, + // im casting a wide net because i don't want to have to figure out where i am + // in the router (e.g., did i come from books? a series? etc) + client.invalidateQueries({ queryKey: ['seriesById', book.series.id], exact: false }), // stats + client.invalidateQueries({ queryKey: ['seriesBooks', book.series.id], exact: false }), + client.invalidateQueries({ queryKey: ['booksStats', serverID], exact: false }), // stats + client.invalidateQueries({ queryKey: ['books', serverID], exact: false }), // server books ]) }