Various Improvements.

- Testing Coverage
- Image Handling
- Session Handling
- Library Switching
This commit is contained in:
Stefan Hardegger
2025-10-20 08:24:29 +02:00
parent 20d0652c85
commit 30c0132a92
26 changed files with 5810 additions and 75 deletions

View File

@@ -40,6 +40,8 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
// Public endpoints
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/auth/refresh").permitAll() // Allow refresh without access token
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/files/images/**").permitAll() // Public image serving
.requestMatchers("/api/config/**").permitAll() // Public configuration endpoints

View File

@@ -45,6 +45,7 @@ public class SolrProperties {
public static class Cores {
private String stories = "storycove_stories";
private String authors = "storycove_authors";
private String collections = "storycove_collections";
// Getters and setters
public String getStories() { return stories; }
@@ -52,6 +53,9 @@ public class SolrProperties {
public String getAuthors() { return authors; }
public void setAuthors(String authors) { this.authors = authors; }
public String getCollections() { return collections; }
public void setCollections(String collections) { this.collections = collections; }
}
public static class Connection {

View File

@@ -0,0 +1,102 @@
package com.storycove.config;
import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.Story;
import com.storycove.repository.AuthorRepository;
import com.storycove.repository.CollectionRepository;
import com.storycove.repository.StoryRepository;
import com.storycove.service.SearchServiceAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Automatically performs bulk reindexing of all entities on application startup.
* This ensures that the search index is always in sync with the database,
* especially after Solr volume recreation during deployment.
*/
@Component
public class StartupIndexingRunner implements ApplicationRunner {
private static final Logger logger = LoggerFactory.getLogger(StartupIndexingRunner.class);
@Autowired
private SearchServiceAdapter searchServiceAdapter;
@Autowired
private StoryRepository storyRepository;
@Autowired
private AuthorRepository authorRepository;
@Autowired
private CollectionRepository collectionRepository;
@Override
public void run(ApplicationArguments args) throws Exception {
logger.info("========================================");
logger.info("Starting automatic bulk reindexing...");
logger.info("========================================");
try {
// Check if search service is available
if (!searchServiceAdapter.isSearchServiceAvailable()) {
logger.warn("Search service (Solr) is not available. Skipping bulk reindexing.");
logger.warn("Make sure Solr is running and accessible.");
return;
}
long startTime = System.currentTimeMillis();
// Index all stories
logger.info("📚 Indexing stories...");
List<Story> stories = storyRepository.findAllWithAssociations();
if (!stories.isEmpty()) {
searchServiceAdapter.bulkIndexStories(stories);
logger.info("✅ Indexed {} stories", stories.size());
} else {
logger.info(" No stories to index");
}
// Index all authors
logger.info("👤 Indexing authors...");
List<Author> authors = authorRepository.findAll();
if (!authors.isEmpty()) {
searchServiceAdapter.bulkIndexAuthors(authors);
logger.info("✅ Indexed {} authors", authors.size());
} else {
logger.info(" No authors to index");
}
// Index all collections
logger.info("📂 Indexing collections...");
List<Collection> collections = collectionRepository.findAllWithTags();
if (!collections.isEmpty()) {
searchServiceAdapter.bulkIndexCollections(collections);
logger.info("✅ Indexed {} collections", collections.size());
} else {
logger.info(" No collections to index");
}
long duration = System.currentTimeMillis() - startTime;
logger.info("========================================");
logger.info("✅ Bulk reindexing completed successfully in {}ms", duration);
logger.info("📊 Total indexed: {} stories, {} authors, {} collections",
stories.size(), authors.size(), collections.size());
logger.info("========================================");
} catch (Exception e) {
logger.error("========================================");
logger.error("❌ Bulk reindexing failed", e);
logger.error("========================================");
// Don't throw the exception - let the application start even if indexing fails
// This allows the application to be functional even with search issues
}
}
}

View File

@@ -1,11 +1,17 @@
package com.storycove.controller;
import com.storycove.entity.RefreshToken;
import com.storycove.service.LibraryService;
import com.storycove.service.PasswordAuthenticationService;
import com.storycove.service.RefreshTokenService;
import com.storycove.util.JwtUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
@@ -13,59 +19,154 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Arrays;
import java.util.Optional;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
private final PasswordAuthenticationService passwordService;
private final LibraryService libraryService;
private final JwtUtil jwtUtil;
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) {
private final RefreshTokenService refreshTokenService;
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil, RefreshTokenService refreshTokenService) {
this.passwordService = passwordService;
this.libraryService = libraryService;
this.jwtUtil = jwtUtil;
this.refreshTokenService = refreshTokenService;
}
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest, HttpServletResponse response) {
// Use new library-aware authentication
String token = passwordService.authenticateAndSwitchLibrary(request.getPassword());
if (token != null) {
// Set httpOnly cookie
ResponseCookie cookie = ResponseCookie.from("token", token)
// Get library ID from JWT token
String libraryId = jwtUtil.getLibraryIdFromToken(token);
// Get user agent and IP address for refresh token
String userAgent = httpRequest.getHeader("User-Agent");
String ipAddress = getClientIpAddress(httpRequest);
// Create refresh token
RefreshToken refreshToken = refreshTokenService.createRefreshToken(libraryId, userAgent, ipAddress);
// Set access token cookie (24 hours)
ResponseCookie accessCookie = ResponseCookie.from("token", token)
.httpOnly(true)
.secure(false) // Set to true in production with HTTPS
.path("/")
.maxAge(Duration.ofDays(1))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// Set refresh token cookie (14 days)
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken.getToken())
.httpOnly(true)
.secure(false) // Set to true in production with HTTPS
.path("/")
.maxAge(Duration.ofDays(14))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
String libraryInfo = passwordService.getCurrentLibraryInfo();
return ResponseEntity.ok(new LoginResponse("Authentication successful - " + libraryInfo, token));
} else {
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
}
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
// Get refresh token from cookie
String refreshTokenString = getRefreshTokenFromCookies(request);
if (refreshTokenString == null) {
return ResponseEntity.status(401).body(new ErrorResponse("Refresh token not found"));
}
// Verify refresh token
Optional<RefreshToken> refreshTokenOpt = refreshTokenService.verifyRefreshToken(refreshTokenString);
if (refreshTokenOpt.isEmpty()) {
return ResponseEntity.status(401).body(new ErrorResponse("Invalid or expired refresh token"));
}
RefreshToken refreshToken = refreshTokenOpt.get();
String tokenLibraryId = refreshToken.getLibraryId();
// Check if we need to switch libraries based on refresh token's library ID
try {
String currentLibraryId = libraryService.getCurrentLibraryId();
// Switch library if refresh token's library differs from current library
// This handles cross-device library switching on token refresh
if (tokenLibraryId != null && !tokenLibraryId.equals(currentLibraryId)) {
logger.info("Refresh token library '{}' differs from current library '{}', switching libraries",
tokenLibraryId, currentLibraryId);
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
} else if (currentLibraryId == null && tokenLibraryId != null) {
// Handle case after backend restart where no library is active
logger.info("No active library on refresh, switching to refresh token's library: {}", tokenLibraryId);
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
}
} catch (Exception e) {
logger.error("Failed to switch library during token refresh: {}", e.getMessage());
return ResponseEntity.status(500).body(new ErrorResponse("Failed to switch library: " + e.getMessage()));
}
// Generate new access token
String newAccessToken = jwtUtil.generateToken("user", tokenLibraryId);
// Set new access token cookie
ResponseCookie cookie = ResponseCookie.from("token", newAccessToken)
.httpOnly(true)
.secure(false) // Set to true in production with HTTPS
.path("/")
.maxAge(Duration.ofDays(1))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(new LoginResponse("Token refreshed successfully", newAccessToken));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
// Clear authentication state
libraryService.clearAuthentication();
// Clear the cookie
ResponseCookie cookie = ResponseCookie.from("token", "")
// Revoke refresh token if present
String refreshTokenString = getRefreshTokenFromCookies(request);
if (refreshTokenString != null) {
refreshTokenService.findByToken(refreshTokenString).ifPresent(refreshTokenService::revokeToken);
}
// Clear the access token cookie
ResponseCookie accessCookie = ResponseCookie.from("token", "")
.httpOnly(true)
.secure(false)
.path("/")
.maxAge(Duration.ZERO)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
// Clear the refresh token cookie
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(false)
.path("/")
.maxAge(Duration.ZERO)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
return ResponseEntity.ok(new MessageResponse("Logged out successfully"));
}
@@ -77,7 +178,34 @@ public class AuthController {
return ResponseEntity.status(401).body(new ErrorResponse("Token is invalid or expired"));
}
}
// Helper methods
private String getRefreshTokenFromCookies(HttpServletRequest request) {
if (request.getCookies() == null) {
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> "refreshToken".equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return request.getRemoteAddr();
}
// DTOs
public static class LoginRequest {
@NotBlank(message = "Password is required")

View File

@@ -0,0 +1,130 @@
package com.storycove.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private LocalDateTime expiresAt;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime revokedAt;
@Column
private String libraryId;
@Column(nullable = false)
private String userAgent;
@Column(nullable = false)
private String ipAddress;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
// Constructors
public RefreshToken() {
}
public RefreshToken(String token, LocalDateTime expiresAt, String libraryId, String userAgent, String ipAddress) {
this.token = token;
this.expiresAt = expiresAt;
this.libraryId = libraryId;
this.userAgent = userAgent;
this.ipAddress = ipAddress;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getRevokedAt() {
return revokedAt;
}
public void setRevokedAt(LocalDateTime revokedAt) {
this.revokedAt = revokedAt;
}
public String getLibraryId() {
return libraryId;
}
public void setLibraryId(String libraryId) {
this.libraryId = libraryId;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
// Helper methods
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiresAt);
}
public boolean isRevoked() {
return revokedAt != null;
}
public boolean isValid() {
return !isExpired() && !isRevoked();
}
}

View File

@@ -0,0 +1,30 @@
package com.storycove.repository;
import com.storycove.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
Optional<RefreshToken> findByToken(String token);
@Modifying
@Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :now")
void deleteExpiredTokens(@Param("now") LocalDateTime now);
@Modifying
@Query("UPDATE RefreshToken rt SET rt.revokedAt = :now WHERE rt.libraryId = :libraryId AND rt.revokedAt IS NULL")
void revokeAllByLibraryId(@Param("libraryId") String libraryId, @Param("now") LocalDateTime now);
@Modifying
@Query("UPDATE RefreshToken rt SET rt.revokedAt = :now WHERE rt.revokedAt IS NULL")
void revokeAll(@Param("now") LocalDateTime now);
}

View File

@@ -1,11 +1,14 @@
package com.storycove.security;
import com.storycove.service.LibraryService;
import com.storycove.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
@@ -17,11 +20,15 @@ import java.util.ArrayList;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
private final LibraryService libraryService;
public JwtAuthenticationFilter(JwtUtil jwtUtil, LibraryService libraryService) {
this.jwtUtil = jwtUtil;
this.libraryService = libraryService;
}
@Override
@@ -52,9 +59,31 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (token != null && jwtUtil.validateToken(token) && !jwtUtil.isTokenExpired(token)) {
String subject = jwtUtil.getSubjectFromToken(token);
// Check if we need to switch libraries based on token's library ID
try {
String tokenLibraryId = jwtUtil.getLibraryIdFromToken(token);
String currentLibraryId = libraryService.getCurrentLibraryId();
// Switch library if token's library differs from current library
// This handles cross-device library switching automatically
if (tokenLibraryId != null && !tokenLibraryId.equals(currentLibraryId)) {
logger.info("Token library '{}' differs from current library '{}', switching libraries",
tokenLibraryId, currentLibraryId);
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
} else if (currentLibraryId == null && tokenLibraryId != null) {
// Handle case after backend restart where no library is active
logger.info("No active library, switching to token's library: {}", tokenLibraryId);
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
}
} catch (Exception e) {
logger.error("Failed to switch library from token: {}", e.getMessage());
// Don't fail the request - authentication can still proceed
// but user might see wrong library data until next login
}
if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken =
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(subject, null, new ArrayList<>());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);

View File

@@ -1,5 +1,6 @@
package com.storycove.service;
import com.storycove.dto.CollectionDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StoryReadingDto;
import com.storycove.dto.TagDto;
@@ -50,14 +51,31 @@ public class CollectionService {
}
/**
* Search collections using Typesense (MANDATORY for all search/filter operations)
* Search collections using Solr (MANDATORY for all search/filter operations)
* This method MUST be used instead of JPA queries for listing collections
*/
public SearchResultDto<Collection> searchCollections(String query, List<String> tags, boolean includeArchived, int page, int limit) {
// Collections are currently handled at database level, not indexed in search engine
// Return empty result for now as collections search is not implemented in Solr
logger.warn("Collections search not yet implemented in Solr, returning empty results");
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
try {
// Use SearchServiceAdapter to search collections
SearchResultDto<CollectionDto> searchResult = searchServiceAdapter.searchCollections(query, tags, includeArchived, page, limit);
// Convert CollectionDto back to Collection entities by fetching from database
List<Collection> collections = new ArrayList<>();
for (CollectionDto dto : searchResult.getResults()) {
try {
Collection collection = findByIdBasic(dto.getId());
collections.add(collection);
} catch (ResourceNotFoundException e) {
logger.warn("Collection {} found in search index but not in database", dto.getId());
}
}
return new SearchResultDto<>(collections, (int) searchResult.getTotalHits(), page, limit,
query != null ? query : "", searchResult.getSearchTimeMs());
} catch (Exception e) {
logger.error("Collection search failed, falling back to empty results", e);
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
}
}
/**

View File

@@ -0,0 +1,91 @@
package com.storycove.service;
import com.storycove.entity.RefreshToken;
import com.storycove.repository.RefreshTokenRepository;
import com.storycove.util.JwtUtil;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
public class RefreshTokenService {
private static final Logger logger = LoggerFactory.getLogger(RefreshTokenService.class);
private final RefreshTokenRepository refreshTokenRepository;
private final JwtUtil jwtUtil;
public RefreshTokenService(RefreshTokenRepository refreshTokenRepository, JwtUtil jwtUtil) {
this.refreshTokenRepository = refreshTokenRepository;
this.jwtUtil = jwtUtil;
}
/**
* Create a new refresh token
*/
public RefreshToken createRefreshToken(String libraryId, String userAgent, String ipAddress) {
String token = jwtUtil.generateRefreshToken();
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(jwtUtil.getRefreshExpirationMs() / 1000);
RefreshToken refreshToken = new RefreshToken(token, expiresAt, libraryId, userAgent, ipAddress);
return refreshTokenRepository.save(refreshToken);
}
/**
* Find a refresh token by its token string
*/
public Optional<RefreshToken> findByToken(String token) {
return refreshTokenRepository.findByToken(token);
}
/**
* Verify and validate a refresh token
*/
public Optional<RefreshToken> verifyRefreshToken(String token) {
return refreshTokenRepository.findByToken(token)
.filter(RefreshToken::isValid);
}
/**
* Revoke a specific refresh token
*/
@Transactional
public void revokeToken(RefreshToken token) {
token.setRevokedAt(LocalDateTime.now());
refreshTokenRepository.save(token);
}
/**
* Revoke all refresh tokens for a specific library
*/
@Transactional
public void revokeAllByLibraryId(String libraryId) {
refreshTokenRepository.revokeAllByLibraryId(libraryId, LocalDateTime.now());
logger.info("Revoked all refresh tokens for library: {}", libraryId);
}
/**
* Revoke all refresh tokens (e.g., for logout all)
*/
@Transactional
public void revokeAll() {
refreshTokenRepository.revokeAll(LocalDateTime.now());
logger.info("Revoked all refresh tokens");
}
/**
* Clean up expired tokens periodically
* Runs daily at 3 AM
*/
@Scheduled(cron = "0 0 3 * * ?")
@Transactional
public void cleanupExpiredTokens() {
refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now());
logger.info("Cleaned up expired refresh tokens");
}
}

View File

@@ -1,9 +1,11 @@
package com.storycove.service;
import com.storycove.dto.AuthorSearchDto;
import com.storycove.dto.CollectionDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto;
import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.Story;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -119,6 +121,14 @@ public class SearchServiceAdapter {
return solrService.getTagSuggestions(query, limit);
}
/**
* Search collections with unified interface
*/
public SearchResultDto<CollectionDto> searchCollections(String query, List<String> tags,
boolean includeArchived, int page, int limit) {
return solrService.searchCollections(query, tags, includeArchived, page, limit);
}
// ===============================
// INDEX OPERATIONS
// ===============================
@@ -211,6 +221,50 @@ public class SearchServiceAdapter {
}
}
/**
* Index a collection in Solr
*/
public void indexCollection(Collection collection) {
try {
solrService.indexCollection(collection);
} catch (Exception e) {
logger.error("Failed to index collection {}", collection.getId(), e);
}
}
/**
* Update a collection in Solr
*/
public void updateCollection(Collection collection) {
try {
solrService.updateCollection(collection);
} catch (Exception e) {
logger.error("Failed to update collection {}", collection.getId(), e);
}
}
/**
* Delete a collection from Solr
*/
public void deleteCollection(UUID collectionId) {
try {
solrService.deleteCollection(collectionId);
} catch (Exception e) {
logger.error("Failed to delete collection {}", collectionId, e);
}
}
/**
* Bulk index collections in Solr
*/
public void bulkIndexCollections(List<Collection> collections) {
try {
solrService.bulkIndexCollections(collections);
} catch (Exception e) {
logger.error("Failed to bulk index {} collections", collections.size(), e);
}
}
// ===============================
// UTILITY METHODS
// ===============================

View File

@@ -2,10 +2,12 @@ package com.storycove.service;
import com.storycove.config.SolrProperties;
import com.storycove.dto.AuthorSearchDto;
import com.storycove.dto.CollectionDto;
import com.storycove.dto.FacetCountDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto;
import com.storycove.entity.Author;
import com.storycove.entity.Collection;
import com.storycove.entity.Story;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrQuery;
@@ -63,6 +65,7 @@ public class SolrService {
logger.debug("Testing Solr cores availability...");
testCoreAvailability(properties.getCores().getStories());
testCoreAvailability(properties.getCores().getAuthors());
testCoreAvailability(properties.getCores().getCollections());
logger.debug("Solr cores are available");
} catch (Exception e) {
logger.error("Failed to test Solr cores availability", e);
@@ -190,6 +193,61 @@ public class SolrService {
}
}
// ===============================
// COLLECTION INDEXING
// ===============================
public void indexCollection(Collection collection) throws IOException {
if (!isAvailable()) {
logger.debug("Solr not available - skipping collection indexing");
return;
}
try {
logger.debug("Indexing collection: {} ({})", collection.getName(), collection.getId());
SolrInputDocument doc = createCollectionDocument(collection);
UpdateResponse response = solrClient.add(properties.getCores().getCollections(), doc,
properties.getCommit().getCommitWithin());
if (response.getStatus() == 0) {
logger.debug("Successfully indexed collection: {}", collection.getId());
} else {
logger.warn("Collection indexing returned non-zero status: {}", response.getStatus());
}
} catch (SolrServerException e) {
logger.error("Failed to index collection: {}", collection.getId(), e);
throw new IOException("Failed to index collection", e);
}
}
public void updateCollection(Collection collection) throws IOException {
// For Solr, update is the same as index (upsert behavior)
indexCollection(collection);
}
public void deleteCollection(UUID collectionId) throws IOException {
if (!isAvailable()) {
logger.debug("Solr not available - skipping collection deletion");
return;
}
try {
logger.debug("Deleting collection from index: {}", collectionId);
UpdateResponse response = solrClient.deleteById(properties.getCores().getCollections(),
collectionId.toString(), properties.getCommit().getCommitWithin());
if (response.getStatus() == 0) {
logger.debug("Successfully deleted collection: {}", collectionId);
} else {
logger.warn("Collection deletion returned non-zero status: {}", response.getStatus());
}
} catch (SolrServerException e) {
logger.error("Failed to delete collection: {}", collectionId, e);
throw new IOException("Failed to delete collection", e);
}
}
// ===============================
// BULK OPERATIONS
// ===============================
@@ -246,6 +304,32 @@ public class SolrService {
}
}
public void bulkIndexCollections(List<Collection> collections) throws IOException {
if (!isAvailable() || collections.isEmpty()) {
logger.debug("Solr not available or empty collections list - skipping bulk indexing");
return;
}
try {
logger.debug("Bulk indexing {} collections", collections.size());
List<SolrInputDocument> docs = collections.stream()
.map(this::createCollectionDocument)
.collect(Collectors.toList());
UpdateResponse response = solrClient.add(properties.getCores().getCollections(), docs,
properties.getCommit().getCommitWithin());
if (response.getStatus() == 0) {
logger.debug("Successfully bulk indexed {} collections", collections.size());
} else {
logger.warn("Bulk collection indexing returned non-zero status: {}", response.getStatus());
}
} catch (SolrServerException e) {
logger.error("Failed to bulk index collections", e);
throw new IOException("Failed to bulk index collections", e);
}
}
// ===============================
// DOCUMENT CREATION
// ===============================
@@ -349,6 +433,52 @@ public class SolrService {
return doc;
}
private SolrInputDocument createCollectionDocument(Collection collection) {
SolrInputDocument doc = new SolrInputDocument();
doc.addField("id", collection.getId().toString());
doc.addField("name", collection.getName());
doc.addField("description", collection.getDescription());
doc.addField("rating", collection.getRating());
doc.addField("coverImagePath", collection.getCoverImagePath());
doc.addField("isArchived", collection.getIsArchived());
// Calculate derived fields
doc.addField("storyCount", collection.getStoryCount());
doc.addField("totalWordCount", collection.getTotalWordCount());
doc.addField("estimatedReadingTime", collection.getEstimatedReadingTime());
Double avgRating = collection.getAverageStoryRating();
if (avgRating != null && avgRating > 0) {
doc.addField("averageStoryRating", avgRating);
}
// Handle tags
if (collection.getTags() != null && !collection.getTags().isEmpty()) {
List<String> tagNames = collection.getTags().stream()
.map(tag -> tag.getName())
.collect(Collectors.toList());
doc.addField("tagNames", tagNames);
}
doc.addField("createdAt", formatDateTime(collection.getCreatedAt()));
doc.addField("updatedAt", formatDateTime(collection.getUpdatedAt()));
// Add library ID for multi-tenant separation
String currentLibraryId = getCurrentLibraryId();
try {
if (currentLibraryId != null) {
doc.addField("libraryId", currentLibraryId);
}
} catch (Exception e) {
// If libraryId field doesn't exist, log warning and continue without it
// This allows indexing to work even if schema migration hasn't completed
logger.warn("Could not add libraryId field to document (field may not exist in schema): {}", e.getMessage());
}
return doc;
}
private String formatDateTime(LocalDateTime dateTime) {
if (dateTime == null) return null;
return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + "Z";
@@ -648,6 +778,67 @@ public class SolrService {
}
}
public SearchResultDto<CollectionDto> searchCollections(String query, List<String> tags,
boolean includeArchived, int page, int limit) {
if (!isAvailable()) {
logger.debug("Solr not available - returning empty collection search results");
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
}
try {
SolrQuery solrQuery = new SolrQuery();
// Set query
if (query == null || query.trim().isEmpty()) {
solrQuery.setQuery("*:*");
} else {
solrQuery.setQuery(query);
solrQuery.set("defType", "edismax");
solrQuery.set("qf", "name^3.0 description^2.0 tagNames^1.0");
}
// Add library filter for multi-tenant separation
String currentLibraryId = getCurrentLibraryId();
solrQuery.addFilterQuery("libraryId:\"" + escapeQueryChars(currentLibraryId) + "\"");
// Tag filters
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:\"" + escapeQueryChars(tag) + "\"")
.collect(Collectors.joining(" AND "));
solrQuery.addFilterQuery(tagFilter);
}
// Archive filter
if (!includeArchived) {
solrQuery.addFilterQuery("isArchived:false");
}
// Pagination
solrQuery.setStart(page * limit);
solrQuery.setRows(limit);
// Sorting - by name ascending
solrQuery.setSort("name", SolrQuery.ORDER.asc);
// Explicitly disable faceting
solrQuery.setFacet(false);
logger.info("SolrService: Executing Collection search query: {}", solrQuery);
QueryResponse response = solrClient.query(properties.getCores().getCollections(), solrQuery);
logger.info("SolrService: Collection query executed successfully, found {} results",
response.getResults().getNumFound());
return buildCollectionSearchResult(response, page, limit, query);
} catch (Exception e) {
logger.error("Collection search failed for query: {}", query, e);
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
}
}
public List<String> getTagSuggestions(String query, int limit) {
if (!isAvailable()) {
return Collections.emptyList();
@@ -762,6 +953,19 @@ public class SolrService {
.collect(Collectors.toList());
}
private SearchResultDto<CollectionDto> buildCollectionSearchResult(QueryResponse response, int page, int limit, String query) {
SolrDocumentList results = response.getResults();
List<CollectionDto> collections = new ArrayList<>();
for (SolrDocument doc : results) {
CollectionDto collection = convertToCollectionDto(doc);
collections.add(collection);
}
return new SearchResultDto<>(collections, (int) results.getNumFound(), page, limit,
query != null ? query : "", 0);
}
private StorySearchDto convertToStorySearchDto(SolrDocument doc) {
StorySearchDto story = new StorySearchDto();
@@ -797,7 +1001,7 @@ public class SolrService {
story.setSeriesName((String) doc.getFieldValue("seriesName"));
// Handle tags
Collection<Object> tagValues = doc.getFieldValues("tagNames");
java.util.Collection<Object> tagValues = doc.getFieldValues("tagNames");
if (tagValues != null) {
List<String> tagNames = tagValues.stream()
.map(Object::toString)
@@ -824,7 +1028,7 @@ public class SolrService {
}
// Handle URLs
Collection<Object> urlValues = doc.getFieldValues("urls");
java.util.Collection<Object> urlValues = doc.getFieldValues("urls");
if (urlValues != null) {
List<String> urls = urlValues.stream()
.map(Object::toString)
@@ -839,6 +1043,40 @@ public class SolrService {
return author;
}
private CollectionDto convertToCollectionDto(SolrDocument doc) {
CollectionDto collection = new CollectionDto();
collection.setId(UUID.fromString((String) doc.getFieldValue("id")));
collection.setName((String) doc.getFieldValue("name"));
collection.setDescription((String) doc.getFieldValue("description"));
collection.setRating((Integer) doc.getFieldValue("rating"));
collection.setCoverImagePath((String) doc.getFieldValue("coverImagePath"));
collection.setIsArchived((Boolean) doc.getFieldValue("isArchived"));
collection.setStoryCount((Integer) doc.getFieldValue("storyCount"));
collection.setTotalWordCount((Integer) doc.getFieldValue("totalWordCount"));
collection.setEstimatedReadingTime((Integer) doc.getFieldValue("estimatedReadingTime"));
Double avgRating = (Double) doc.getFieldValue("averageStoryRating");
if (avgRating != null) {
collection.setAverageStoryRating(avgRating);
}
// Handle tags
java.util.Collection<Object> tagValues = doc.getFieldValues("tagNames");
if (tagValues != null) {
List<String> tagNames = tagValues.stream()
.map(Object::toString)
.collect(Collectors.toList());
collection.setTagNames(tagNames);
}
// Handle dates
collection.setCreatedAt(parseDateTimeFromSolr(doc.getFieldValue("createdAt")));
collection.setUpdatedAt(parseDateTimeFromSolr(doc.getFieldValue("updatedAt")));
return collection;
}
private LocalDateTime parseDateTime(String dateStr) {
if (dateStr == null || dateStr.isEmpty()) {
return null;

View File

@@ -16,15 +16,18 @@ import java.util.Date;
@Component
public class JwtUtil {
private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class);
// Security: Generate new secret on each startup to invalidate all existing tokens
private String secret;
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default (access token)
private Long expiration;
@Value("${storycove.jwt.refresh-expiration:1209600000}") // 14 days default (refresh token)
private Long refreshExpiration;
@PostConstruct
public void initialize() {
// Generate a new random secret on startup to invalidate all existing JWT tokens
@@ -33,10 +36,21 @@ public class JwtUtil {
byte[] secretBytes = new byte[64]; // 512 bits
random.nextBytes(secretBytes);
this.secret = Base64.getEncoder().encodeToString(secretBytes);
logger.info("JWT secret rotated on startup - all existing tokens invalidated");
logger.info("Users will need to re-authenticate after application restart for security");
}
public Long getRefreshExpirationMs() {
return refreshExpiration;
}
public String generateRefreshToken() {
SecureRandom random = new SecureRandom();
byte[] tokenBytes = new byte[32]; // 256 bits
random.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());

View File

@@ -42,7 +42,8 @@ storycove:
allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925}
jwt:
secret: ${JWT_SECRET} # REQUIRED: Must be at least 32 characters, no default for security
expiration: 86400000 # 24 hours
expiration: 86400000 # 24 hours (access token)
refresh-expiration: 1209600000 # 14 days (refresh token)
auth:
password: ${APP_PASSWORD} # REQUIRED: No default password for security
search: