From ef5680c654b3288bdcdafd4408a9233fb5d46fde Mon Sep 17 00:00:00 2001 From: Austin Bennett Date: Thu, 23 Jul 2026 06:33:41 -0500 Subject: [PATCH] Move search and the front-page decisions out of the browser into Java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search downloaded every post to every visitor and filtered the list with String.includes, so it could only ever match what a summary carries — a phrase from the body of a post was unfindable, and the cost grew with each post published. It is now a query (/api/posts/search) that searches the body too. The home page picked 'the latest post' by taking element zero of that list, which was only correct for as long as the API happened to return posts in that order. /api/home now decides what leads, what counts as recent, and the tag counts, in one call. Tag counting moves out of the controller into TagService, shared by both. The LIKE escaping needed an explicit ESCAPE clause: there is no default escape character to rely on, and posts are markdown full of literal backslashes — escaping with a backslash turned a search for '%' into a search for backslashes and matched unrelated posts. Caught by the new tests. Also on platform 0.1.6 (contact-form header-injection fix). --- frontend/src/api.ts | 13 ++ frontend/src/pages/HomePage.tsx | 16 +-- frontend/src/pages/SearchPage.tsx | 20 ++-- pom.xml | 4 +- .../cog/repo/PostRepository.java | 15 +++ .../cog/service/SearchService.java | 52 ++++++++ .../cog/service/TagService.java | 29 +++++ .../cog/web/HomeController.java | 51 ++++++++ .../cog/web/PostController.java | 14 ++- .../cog/web/TagController.java | 12 +- .../cog/SearchAndHomeTest.java | 113 ++++++++++++++++++ 11 files changed, 309 insertions(+), 30 deletions(-) create mode 100644 src/main/java/net/reformedwitness/cog/service/SearchService.java create mode 100644 src/main/java/net/reformedwitness/cog/service/TagService.java create mode 100644 src/main/java/net/reformedwitness/cog/web/HomeController.java create mode 100644 src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a37cd90..50ab6bc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -20,6 +20,19 @@ export function getPosts(params: { tag?: string; author?: string } = {}): Promis return api.get(`/posts${q ? `?${q}` : ''}`); } export const getPost = (slug: string) => api.get(`/posts/${encodeURIComponent(slug)}`); + +/** Searching is done by the database, over post bodies too — not by filtering a downloaded list. */ +export const searchPosts = (q: string) => + api.get(`/posts/search?q=${encodeURIComponent(q)}`); + +export interface HomePage { + featured: PostSummary | null; + posts: PostSummary[]; + recent: PostSummary[]; + tags: TagCount[]; +} +/** One call for the whole front page; which post leads is the backend's decision. */ +export const getHome = () => api.get('/home'); export const getTags = () => api.get('/tags'); export const getAuthors = () => api.get('/authors'); export const getAuthor = (name: string) => api.get(`/authors/${encodeURIComponent(name)}`); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 2eec8ef..df23eff 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,14 +1,14 @@ import { Link } from 'react-router-dom'; -import { getPosts, getTags } from '../api'; +import { getHome } from '../api'; import { useAsync } from '../lib/useAsync'; import PostCard from '../components/PostCard'; import Sidebar from '../components/Sidebar'; export default function HomePage() { - const { data: posts, loading } = useAsync(() => getPosts(), []); - const { data: tags } = useAsync(() => getTags(), []); - const list = posts ?? []; - const featured = list[0]; + // One call. Which post leads, what counts as recent, and the tag counts are all decided by the + // backend — this page used to take element zero of the post list and call it "the latest". + const { data, loading } = useAsync(() => getHome(), []); + const featured = data?.featured ?? null; return (
@@ -53,9 +53,9 @@ export default function HomePage() {

Recent Posts

{loading &&

Loading…

}
- {list.slice(1).map((post) => )} + {(data?.posts ?? []).map((post) => )}
- {!loading && list.length === 0 &&

No posts yet.

} + {!loading && !featured &&

No posts yet.

}
@@ -64,7 +64,7 @@ export default function HomePage() {
- +
); diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 61bcb3c..91eef9e 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { getPosts } from '../api'; +import { searchPosts } from '../api'; import { useAsync } from '../lib/useAsync'; import PostCard from '../components/PostCard'; @@ -8,15 +8,11 @@ export default function SearchPage() { const [params, setParams] = useSearchParams(); const q = params.get('q') ?? ''; const [term, setTerm] = useState(q); - const { data: posts, loading } = useAsync(() => getPosts(), []); - const needle = q.trim().toLowerCase(); - const results = (posts ?? []).filter((p) => - !needle - || p.title.toLowerCase().includes(needle) - || p.excerpt.toLowerCase().includes(needle) - || p.author.toLowerCase().includes(needle) - || p.tags.some((t) => t.toLowerCase().includes(needle))); + // The database does the matching — including post bodies, which the old browser-side filter + // never had, and without shipping every post to every visitor who opens this page. + const { data: results, loading } = useAsync(() => searchPosts(q), [q]); + const found = results ?? []; return (
@@ -36,13 +32,13 @@ export default function SearchPage() { {loading &&

Loading…

} - {!loading && needle && ( + {!loading && q.trim() && (

- {results.length} {results.length === 1 ? 'result' : 'results'} for “{q}” + {found.length} {found.length === 1 ? 'result' : 'results'} for “{q}”

)}
- {results.map((post) => )} + {found.map((post) => )}
); diff --git a/pom.xml b/pom.xml index b186473..03cdd15 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ net.thebennett.platform platform-parent - 0.1.5 + 0.1.6 @@ -26,7 +26,7 @@ net.thebennett.platform platform-bom - 0.1.5 + 0.1.6 pom import diff --git a/src/main/java/net/reformedwitness/cog/repo/PostRepository.java b/src/main/java/net/reformedwitness/cog/repo/PostRepository.java index 31797f5..834f5b0 100644 --- a/src/main/java/net/reformedwitness/cog/repo/PostRepository.java +++ b/src/main/java/net/reformedwitness/cog/repo/PostRepository.java @@ -29,4 +29,19 @@ public interface PostRepository extends JpaRepository { /** [tag, count] for every tag used by a published post. */ @Query("select t, count(p) from Post p join p.tags t where p.published = true group by t order by t") List tagCounts(); + + /** + * Full-text-ish search across everything a reader would expect to match — including the post BODY, + * which the old browser-side search could not reach because it only ever had the summaries. + * + *

{@code distinct} because the tag join multiplies rows for a post matching on several tags. + */ + @Query("select distinct p from Post p left join p.tags t where p.published = true and (" + + "lower(p.title) like :q escape '#' or lower(p.excerpt) like :q escape '#' " + + "or lower(p.author) like :q escape '#' or lower(p.content) like :q escape '#' " + + "or lower(t) like :q escape '#') " + + "order by p.publishedOn desc, p.id desc") + List search(@Param("q") String lowercaseLikePattern); + + List findByPublishedTrueOrderByPublishedOnDescIdDesc(org.springframework.data.domain.Limit limit); } diff --git a/src/main/java/net/reformedwitness/cog/service/SearchService.java b/src/main/java/net/reformedwitness/cog/service/SearchService.java new file mode 100644 index 0000000..26c20cf --- /dev/null +++ b/src/main/java/net/reformedwitness/cog/service/SearchService.java @@ -0,0 +1,52 @@ +package net.reformedwitness.cog.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import net.reformedwitness.cog.domain.Post; +import net.reformedwitness.cog.repo.PostRepository; + +/** + * Site search. + * + *

This used to be done in the browser: every visitor downloaded the whole post list and filtered + * it with {@code String.includes}. That could only ever match what the summary carried — title, + * excerpt, author, tags — so searching for a phrase from the body of a post found nothing, and the + * cost grew with every post published. + */ +@Service +public class SearchService { + + /** Below this a search matches most of the site; treat it as no search at all. */ + private static final int MIN_TERM_LENGTH = 2; + + private final PostRepository posts; + + public SearchService(PostRepository posts) { + this.posts = posts; + } + + @Transactional(readOnly = true) + public List search(String query) { + String term = query == null ? "" : query.trim(); + if (term.length() < MIN_TERM_LENGTH) { + return List.of(); + } + return posts.search("%" + escapeLike(term.toLowerCase()) + "%"); + } + + /** + * A search for "100%" must look for a literal percent sign, not "anything". + * + *

Uses '#' with an explicit {@code ESCAPE} clause on the query rather than the backslash you'd + * expect: LIKE has no default escape character to rely on here, and the posts are markdown, which + * is full of literal backslashes — escaping with one turned a search for "%" into a search for + * backslashes and matched unrelated posts. + */ + private static String escapeLike(String raw) { + // The escape character itself first, or the escapes below get double-escaped. + return raw.replace("#", "##").replace("%", "#%").replace("_", "#_"); + } +} diff --git a/src/main/java/net/reformedwitness/cog/service/TagService.java b/src/main/java/net/reformedwitness/cog/service/TagService.java new file mode 100644 index 0000000..16568c0 --- /dev/null +++ b/src/main/java/net/reformedwitness/cog/service/TagService.java @@ -0,0 +1,29 @@ +package net.reformedwitness.cog.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import net.reformedwitness.cog.repo.PostRepository; + +/** Tag counts across published posts — used by the tag page and by the home sidebar. */ +@Service +public class TagService { + + /** tag -> how many published posts carry it. */ + public record TagCount(String tag, long count) {} + + private final PostRepository posts; + + public TagService(PostRepository posts) { + this.posts = posts; + } + + @Transactional(readOnly = true) + public List counts() { + return posts.tagCounts().stream() + .map(row -> new TagCount((String) row[0], ((Number) row[1]).longValue())) + .toList(); + } +} diff --git a/src/main/java/net/reformedwitness/cog/web/HomeController.java b/src/main/java/net/reformedwitness/cog/web/HomeController.java new file mode 100644 index 0000000..0f234a5 --- /dev/null +++ b/src/main/java/net/reformedwitness/cog/web/HomeController.java @@ -0,0 +1,51 @@ +package net.reformedwitness.cog.web; + +import java.util.List; + +import org.springframework.data.domain.Limit; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import net.reformedwitness.cog.domain.Post; +import net.reformedwitness.cog.repo.PostRepository; +import net.reformedwitness.cog.service.TagService; + +/** + * Everything the front page needs, in one call. + * + *

Which post is "the latest" was previously decided in the browser by taking element zero of the + * list — correct only for as long as the API happened to return posts in that order, and invisible + * from the backend if it ever changed. It's a decision, so it's made here. + */ +@RestController +public class HomeController { + + /** How many posts the sidebar lists. */ + private static final Limit RECENT = Limit.of(5); + + private final PostRepository posts; + private final TagService tags; + + public HomeController(PostRepository posts, TagService tags) { + this.posts = posts; + this.tags = tags; + } + + /** + * @param featured the post to lead with, null when nothing is published yet + * @param posts the rest of the published posts, newest first, excluding the featured one + */ + public record HomePage(Dto.PostSummary featured, List posts, + List recent, List tags) {} + + @GetMapping("/api/home") + public HomePage home() { + List published = posts.findByPublishedTrueOrderByPublishedOnDescIdDesc(); + Dto.PostSummary featured = published.isEmpty() ? null : Dto.summary(published.getFirst()); + List rest = published.stream().skip(1).map(Dto::summary).toList(); + List recent = posts.findByPublishedTrueOrderByPublishedOnDescIdDesc(RECENT) + .stream().map(Dto::summary).toList(); + return new HomePage(featured, rest, recent, + tags.counts().stream().map(t -> new Dto.TagCount(t.tag(), t.count())).toList()); + } +} diff --git a/src/main/java/net/reformedwitness/cog/web/PostController.java b/src/main/java/net/reformedwitness/cog/web/PostController.java index ae3604c..e4f641b 100644 --- a/src/main/java/net/reformedwitness/cog/web/PostController.java +++ b/src/main/java/net/reformedwitness/cog/web/PostController.java @@ -12,15 +12,18 @@ import org.springframework.web.server.ResponseStatusException; import net.reformedwitness.cog.domain.Post; import net.reformedwitness.cog.repo.PostRepository; +import net.reformedwitness.cog.service.SearchService; @RestController @RequestMapping("/api/posts") public class PostController { private final PostRepository posts; + private final SearchService search; - public PostController(PostRepository posts) { + public PostController(PostRepository posts, SearchService search) { this.posts = posts; + this.search = search; } @GetMapping @@ -37,6 +40,15 @@ public class PostController { return result.stream().map(Dto::summary).toList(); } + /** + * @param q what the reader typed; a term shorter than two characters returns nothing rather than + * the whole site + */ + @GetMapping("/search") + public List search(@RequestParam(required = false, defaultValue = "") String q) { + return search.search(q).stream().map(Dto::summary).toList(); + } + @GetMapping("/{slug}") public Dto.PostDetail get(@PathVariable String slug) { Post p = posts.findBySlug(slug) diff --git a/src/main/java/net/reformedwitness/cog/web/TagController.java b/src/main/java/net/reformedwitness/cog/web/TagController.java index 687a8e3..71a4678 100644 --- a/src/main/java/net/reformedwitness/cog/web/TagController.java +++ b/src/main/java/net/reformedwitness/cog/web/TagController.java @@ -6,23 +6,21 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import net.reformedwitness.cog.repo.PostRepository; +import net.reformedwitness.cog.service.TagService; @RestController @RequestMapping("/api/tags") public class TagController { - private final PostRepository posts; + private final TagService tags; - public TagController(PostRepository posts) { - this.posts = posts; + public TagController(TagService tags) { + this.tags = tags; } /** All tags used by published posts, with counts. */ @GetMapping public List list() { - return posts.tagCounts().stream() - .map(row -> new Dto.TagCount((String) row[0], ((Number) row[1]).longValue())) - .toList(); + return tags.counts().stream().map(t -> new Dto.TagCount(t.tag(), t.count())).toList(); } } diff --git a/src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java b/src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java new file mode 100644 index 0000000..d7c5fd3 --- /dev/null +++ b/src/test/java/net/reformedwitness/cog/SearchAndHomeTest.java @@ -0,0 +1,113 @@ +package net.reformedwitness.cog; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import net.reformedwitness.cog.domain.Post; +import net.reformedwitness.cog.service.SearchService; +import net.reformedwitness.cog.web.HomeController; +import net.reformedwitness.cog.web.PostController; + +/** Search and the front page, against the real seeded content. */ +@SpringBootTest(properties = { + "platform.storage.access-key=test", + "platform.storage.secret-key=test" +}) +@Testcontainers +class SearchAndHomeTest { + + @Container + @ServiceConnection + static PostgreSQLContainer postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + SearchService search; + + @Autowired + HomeController home; + + @Autowired + PostController postController; + + @Test + void findsAPostByAWordFromItsTitle() { + List hits = search.search("grace"); + assertThat(hits).isNotEmpty(); + } + + @Test + void searchesInsideThePostBodyNotJustTheSummary() { + // The whole point of moving this off the browser: the client only ever had summaries, so a + // phrase from the body of a post was unfindable. + Post seeded = search.search("grace").getFirst(); + String body = seeded.getContent(); + assertThat(body).isNotBlank(); + + // A distinctive long word from the body, that isn't also in the title/excerpt/tags. + String word = java.util.Arrays.stream(body.split("\\W+")) + .filter(w -> w.length() > 9) + .filter(w -> !seeded.getTitle().toLowerCase().contains(w.toLowerCase())) + .filter(w -> seeded.getExcerpt() == null || !seeded.getExcerpt().toLowerCase().contains(w.toLowerCase())) + .findFirst() + .orElse(null); + assertThat(word).as("seeded post should contain a body-only word to search for").isNotNull(); + + assertThat(search.search(word)) + .as("searching for '%s', which appears only in the body", word) + .extracting(Post::getSlug) + .contains(seeded.getSlug()); + } + + @Test + void ashortOrEmptyTermMatchesNothingRatherThanEverything() { + assertThat(search.search("")).isEmpty(); + assertThat(search.search(" ")).isEmpty(); + assertThat(search.search("a")).isEmpty(); + } + + @Test + void wildcardCharactersAreSearchedLiterally() { + int total = home.home().posts().size() + 1; // + the featured one + + // Unescaped, these are LIKE wildcards and would match every published post. + assertThat(search.search("%%")).extracting(Post::getSlug).as("posts matching a literal %%").isEmpty(); + // "__" does legitimately appear — markdown writes bold that way — so the check is that it + // matches those posts and not the whole site. + assertThat(search.search("__")).hasSizeLessThan(total); + } + + @Test + void searchIsCaseInsensitive() { + assertThat(search.search("GRACE")).hasSameSizeAs(search.search("grace")); + } + + @Test + void theHomePageLeadsWithTheNewestPostAndDoesNotRepeatIt() { + HomeController.HomePage page = home.home(); + + assertThat(page.featured()).isNotNull(); + assertThat(page.recent()).isNotEmpty(); + assertThat(page.tags()).isNotEmpty(); + assertThat(page.posts()) + .as("the featured post must not appear again in the list below it") + .extracting(net.reformedwitness.cog.web.Dto.PostSummary::slug) + .doesNotContain(page.featured().slug()); + } + + @Test + void theSearchPathIsNotSwallowedByThePostSlugRoute() { + // /api/posts/search and /api/posts/{slug} overlap; the literal path has to win. + assertThat(postController.search("grace")).isNotEmpty(); + } +}