Archived
Move search and the front-page decisions out of the browser into Java
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).
This commit is contained in:
@@ -20,6 +20,19 @@ export function getPosts(params: { tag?: string; author?: string } = {}): Promis
|
||||
return api.get<PostSummary[]>(`/posts${q ? `?${q}` : ''}`);
|
||||
}
|
||||
export const getPost = (slug: string) => api.get<PostDetail>(`/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<PostSummary[]>(`/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<HomePage>('/home');
|
||||
export const getTags = () => api.get<TagCount[]>('/tags');
|
||||
export const getAuthors = () => api.get<AuthorSummary[]>('/authors');
|
||||
export const getAuthor = (name: string) => api.get<AuthorPage>(`/authors/${encodeURIComponent(name)}`);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-8 md:flex-row">
|
||||
@@ -53,9 +53,9 @@ export default function HomePage() {
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">Recent Posts</h2>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{list.slice(1).map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
{(data?.posts ?? []).map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">No posts yet.</p>}
|
||||
{!loading && !featured && <p className="text-primary-500">No posts yet.</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
@@ -64,7 +64,7 @@ export default function HomePage() {
|
||||
</main>
|
||||
|
||||
<div className="md:w-1/3">
|
||||
<Sidebar recentPosts={list.slice(0, 5)} tags={tags ?? []} />
|
||||
<Sidebar recentPosts={data?.recent ?? []} tags={data?.tags ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
@@ -36,13 +32,13 @@ export default function SearchPage() {
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
{!loading && needle && (
|
||||
{!loading && q.trim() && (
|
||||
<p className="mb-6 text-primary-600">
|
||||
{results.length} {results.length === 1 ? 'result' : 'results'} for “{q}”
|
||||
{found.length} {found.length === 1 ? 'result' : 'results'} for “{q}”
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{results.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
{found.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>net.thebennett.platform</groupId>
|
||||
<artifactId>platform-parent</artifactId>
|
||||
<version>0.1.5</version>
|
||||
<version>0.1.6</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<dependency>
|
||||
<groupId>net.thebennett.platform</groupId>
|
||||
<artifactId>platform-bom</artifactId>
|
||||
<version>0.1.5</version>
|
||||
<version>0.1.6</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -29,4 +29,19 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
/** [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<Object[]> 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.
|
||||
*
|
||||
* <p>{@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<Post> search(@Param("q") String lowercaseLikePattern);
|
||||
|
||||
List<Post> findByPublishedTrueOrderByPublishedOnDescIdDesc(org.springframework.data.domain.Limit limit);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Post> 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".
|
||||
*
|
||||
* <p>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("_", "#_");
|
||||
}
|
||||
}
|
||||
@@ -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<TagCount> counts() {
|
||||
return posts.tagCounts().stream()
|
||||
.map(row -> new TagCount((String) row[0], ((Number) row[1]).longValue()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Dto.PostSummary> posts,
|
||||
List<Dto.PostSummary> recent, List<Dto.TagCount> tags) {}
|
||||
|
||||
@GetMapping("/api/home")
|
||||
public HomePage home() {
|
||||
List<Post> published = posts.findByPublishedTrueOrderByPublishedOnDescIdDesc();
|
||||
Dto.PostSummary featured = published.isEmpty() ? null : Dto.summary(published.getFirst());
|
||||
List<Dto.PostSummary> rest = published.stream().skip(1).map(Dto::summary).toList();
|
||||
List<Dto.PostSummary> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Dto.PostSummary> 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)
|
||||
|
||||
@@ -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<Dto.TagCount> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Post> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user