Security Updates and random improvement.

This commit is contained in:
Stefan Hardegger
2025-09-01 16:02:19 +02:00
parent 15708b5ab2
commit d1289bd616
10 changed files with 2581 additions and 34 deletions

View File

@@ -5,7 +5,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version> <version>3.5.5</version>
<relativePath/> <relativePath/>
</parent> </parent>
@@ -17,7 +17,7 @@
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
<testcontainers.version>1.19.3</testcontainers.version> <testcontainers.version>1.21.3</testcontainers.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -56,18 +56,18 @@
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId> <artifactId>jjwt-api</artifactId>
<version>0.12.3</version> <version>0.13.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId> <artifactId>jjwt-impl</artifactId>
<version>0.12.3</version> <version>0.13.0</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>io.jsonwebtoken</groupId> <groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version> <version>0.13.0</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -89,12 +89,13 @@ public class StoryController {
@GetMapping("/random") @GetMapping("/random")
public ResponseEntity<StorySummaryDto> getRandomStory( public ResponseEntity<StorySummaryDto> getRandomStory(
@RequestParam(required = false) String searchQuery, @RequestParam(required = false) String searchQuery,
@RequestParam(required = false) List<String> tags) { @RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Long seed) {
logger.info("Getting random story with filters - searchQuery: {}, tags: {}", logger.info("Getting random story with filters - searchQuery: {}, tags: {}, seed: {}",
searchQuery, tags); searchQuery, tags, seed);
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags); Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags, seed);
if (randomStory.isPresent()) { if (randomStory.isPresent()) {
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get()); StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());

View File

@@ -676,14 +676,31 @@ public class StoryService {
* Find a random story based on optional filters. * Find a random story based on optional filters.
* Uses Typesense for consistency with Library search functionality. * Uses Typesense for consistency with Library search functionality.
* Supports text search and multiple tags using the same logic as the Library view. * Supports text search and multiple tags using the same logic as the Library view.
* @param searchQuery Optional search query
* @param tags Optional list of tags to filter by
* @return Optional containing the random story if found
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) { public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
return findRandomStory(searchQuery, tags, null);
}
/**
* Find a random story based on optional filters with seed support.
* Uses Typesense for consistency with Library search functionality.
* Supports text search and multiple tags using the same logic as the Library view.
* @param searchQuery Optional search query
* @param tags Optional list of tags to filter by
* @param seed Optional seed for consistent randomization (null for truly random)
* @return Optional containing the random story if found
*/
@Transactional(readOnly = true)
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed) {
// Use Typesense if available for consistency with Library search // Use Typesense if available for consistency with Library search
if (typesenseService != null) { if (typesenseService != null) {
try { try {
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags); Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags, seed);
if (randomStoryId.isPresent()) { if (randomStoryId.isPresent()) {
return storyRepository.findById(randomStoryId.get()); return storyRepository.findById(randomStoryId.get());
} }

View File

@@ -402,12 +402,68 @@ public class TypesenseService {
/** /**
* Get a random story using the same search logic as the Library view. * Get a random story using the same search logic as the Library view.
* This ensures consistency between Library search results and Random Story functionality. * This ensures consistency between Library search results and Random Story functionality.
* Uses offset-based randomization since Typesense v0.25.0 doesn't support _rand() sorting. * Uses Typesense's native _rand() function for efficient randomization with optional seed support.
*/ */
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags) { public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed) {
try { try {
String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim(); String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim();
logger.debug("Getting random story with query: '{}', tags: {}, seed: {}", normalizedQuery, tags, seed);
// Try using Typesense's native _rand() function first
try {
// Build sort parameter with _rand() function
String sortBy = seed != null ? "_rand(" + seed + ")" : "_rand()";
// Search for a random story using Typesense's native _rand() function
SearchParameters searchParameters = new SearchParameters()
.q(normalizedQuery)
.queryBy("title,description,authorName,seriesName,tagNames")
.sortBy(sortBy)
.perPage(1); // Only need one random result
// Add tag filters if provided
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
searchParameters.filterBy(tagFilter);
}
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
.documents()
.search(searchParameters);
if (searchResult.getHits().isEmpty()) {
logger.debug("No stories found matching filters");
return Optional.empty();
}
SearchResultHit hit = searchResult.getHits().get(0);
String storyId = (String) hit.getDocument().get("id");
logger.debug("Found random story ID: {} using _rand() (seed: {})", storyId, seed);
return Optional.of(UUID.fromString(storyId));
} catch (Exception randException) {
logger.warn("Failed to use _rand() function, falling back to offset-based randomization: {}", randException.getMessage());
// Fallback to offset-based randomization if _rand() is not supported
return getRandomStoryIdFallback(normalizedQuery, tags, seed);
}
} catch (Exception e) {
logger.error("Failed to get random story with query: '{}', tags: {}", searchQuery, tags, e);
return Optional.empty();
}
}
/**
* Fallback method for random story selection using offset-based randomization with seed support.
* Used when Typesense's _rand() function is not available or supported.
*/
private Optional<UUID> getRandomStoryIdFallback(String normalizedQuery, List<String> tags, Long seed) {
try {
// First, get the total count of matching stories // First, get the total count of matching stories
SearchParameters countParameters = new SearchParameters() SearchParameters countParameters = new SearchParameters()
.q(normalizedQuery) .q(normalizedQuery)
@@ -422,21 +478,28 @@ public class TypesenseService {
countParameters.filterBy(tagFilter); countParameters.filterBy(tagFilter);
} }
logger.debug("Getting random story with query: '{}', tags: {}", normalizedQuery, tags);
SearchResult countResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()) SearchResult countResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
.documents() .documents()
.search(countParameters); .search(countParameters);
long totalHits = countResult.getFound(); long totalHits = countResult.getFound();
if (totalHits == 0) { if (totalHits == 0) {
logger.debug("No stories found matching filters"); logger.debug("No stories found matching filters in fallback method");
return Optional.empty(); return Optional.empty();
} }
// Generate random offset within the total hits // Generate random offset with seed support
long randomOffset = (long) (Math.random() * totalHits); long randomOffset;
logger.debug("Total hits: {}, using random offset: {}", totalHits, randomOffset); if (seed != null) {
// Use seed to create deterministic randomization
java.util.Random random = new java.util.Random(seed);
randomOffset = (long) (random.nextDouble() * totalHits);
} else {
// Use true randomization
randomOffset = (long) (Math.random() * totalHits);
}
logger.debug("Fallback: Total hits: {}, using random offset: {} (seed: {})", totalHits, randomOffset, seed);
// Now get the actual story at that offset // Now get the actual story at that offset
SearchParameters storyParameters = new SearchParameters() SearchParameters storyParameters = new SearchParameters()
@@ -459,7 +522,7 @@ public class TypesenseService {
.search(storyParameters); .search(storyParameters);
if (storyResult.getHits().isEmpty()) { if (storyResult.getHits().isEmpty()) {
logger.debug("No stories found in random offset query"); logger.debug("No stories found in fallback random offset query");
return Optional.empty(); return Optional.empty();
} }
@@ -469,13 +532,13 @@ public class TypesenseService {
SearchResultHit hit = storyResult.getHits().get(indexInPage); SearchResultHit hit = storyResult.getHits().get(indexInPage);
String storyId = (String) hit.getDocument().get("id"); String storyId = (String) hit.getDocument().get("id");
logger.debug("Found random story ID: {} at offset {} (page {}, index {})", logger.debug("Found random story ID: {} using fallback method at offset {} (seed: {})",
storyId, randomOffset, storyParameters.getPage(), indexInPage); storyId, randomOffset, seed);
return Optional.of(UUID.fromString(storyId)); return Optional.of(UUID.fromString(storyId));
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to get random story with query: '{}', tags: {}", searchQuery, tags, e); logger.error("Fallback random story method also failed: {}", e.getMessage());
return Optional.empty(); return Optional.empty();
} }
} }

View File

@@ -50,7 +50,7 @@ services:
- storycove-network - storycove-network
postgres: postgres:
image: postgres:15-alpine image: postgres:16-alpine
# No port mapping - only accessible within the Docker network # No port mapping - only accessible within the Docker network
environment: environment:
- POSTGRES_DB=storycove - POSTGRES_DB=storycove
@@ -62,7 +62,7 @@ services:
- storycove-network - storycove-network
typesense: typesense:
image: typesense/typesense:0.25.0 image: typesense/typesense:29.0
# No port mapping - only accessible within the Docker network # No port mapping - only accessible within the Docker network
environment: environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}

View File

@@ -10,9 +10,9 @@
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"axios": "^1.6.0", "axios": "^1.11.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"dompurify": "^3.0.5", "dompurify": "^3.2.6",
"next": "14.0.0", "next": "14.0.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"react": "^18", "react": "^18",
@@ -1372,13 +1372,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.10.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },

View File

@@ -12,9 +12,9 @@
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"axios": "^1.6.0", "axios": "^1.11.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"dompurify": "^3.0.5", "dompurify": "^3.2.6",
"next": "14.0.0", "next": "14.0.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"react": "^18", "react": "^18",

2467
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/claude-code": "^1.0.70", "@anthropic-ai/claude-code": "^1.0.70",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"g": "^2.0.1" "g": "^2.0.1",
"npm": "^11.5.2"
} }
} }

Binary file not shown.