Fixing Database switching functionality.
This commit is contained in:
@@ -48,22 +48,17 @@ public class DatabaseConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary datasource bean - using fallback for stability.
|
||||
* Library-specific routing will be handled at the service layer instead.
|
||||
* Primary datasource bean - uses smart routing that excludes authentication operations
|
||||
*/
|
||||
@Bean(name = "dataSource")
|
||||
@Primary
|
||||
public DataSource primaryDataSource() {
|
||||
return fallbackDataSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional routing datasource for future use if needed
|
||||
*/
|
||||
@Bean(name = "libraryRoutingDataSource")
|
||||
public DataSource libraryAwareDataSource(LibraryService libraryService) {
|
||||
LibraryAwareDataSource routingDataSource = new LibraryAwareDataSource(libraryService);
|
||||
@DependsOn("libraryService")
|
||||
public DataSource primaryDataSource(LibraryService libraryService) {
|
||||
SmartRoutingDataSource routingDataSource = new SmartRoutingDataSource(
|
||||
libraryService, baseDbUrl, dbUsername, dbPassword);
|
||||
routingDataSource.setDefaultTargetDataSource(fallbackDataSource());
|
||||
routingDataSource.setTargetDataSources(new java.util.HashMap<>());
|
||||
return routingDataSource;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.storycove.config;
|
||||
|
||||
import com.storycove.service.LibraryService;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Smart routing datasource that:
|
||||
* 1. Routes to library-specific databases when a library is active
|
||||
* 2. Excludes authentication operations (keeps them on default database)
|
||||
* 3. Uses request context to determine when routing is appropriate
|
||||
*/
|
||||
public class SmartRoutingDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SmartRoutingDataSource.class);
|
||||
|
||||
private final LibraryService libraryService;
|
||||
private final Map<String, DataSource> libraryDataSources = new ConcurrentHashMap<>();
|
||||
|
||||
// Database connection details - will be injected via constructor
|
||||
private final String baseDbUrl;
|
||||
private final String dbUsername;
|
||||
private final String dbPassword;
|
||||
|
||||
public SmartRoutingDataSource(LibraryService libraryService, String baseDbUrl, String dbUsername, String dbPassword) {
|
||||
this.libraryService = libraryService;
|
||||
this.baseDbUrl = baseDbUrl;
|
||||
this.dbUsername = dbUsername;
|
||||
this.dbPassword = dbPassword;
|
||||
|
||||
logger.info("SmartRoutingDataSource initialized with database: {}", baseDbUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
try {
|
||||
// Check if this is an authentication request - if so, use default database
|
||||
if (isAuthenticationRequest()) {
|
||||
logger.debug("Authentication request detected, using default database");
|
||||
return null; // null means use default datasource
|
||||
}
|
||||
|
||||
// Check if we have an active library
|
||||
if (libraryService != null) {
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
if (currentLibraryId != null && !currentLibraryId.trim().isEmpty()) {
|
||||
logger.info("ROUTING: Directing to library-specific database: {}", currentLibraryId);
|
||||
return currentLibraryId;
|
||||
} else {
|
||||
logger.info("ROUTING: No active library, using default database");
|
||||
}
|
||||
} else {
|
||||
logger.info("ROUTING: LibraryService is null, using default database");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.debug("Error determining lookup key, falling back to default database", e);
|
||||
}
|
||||
|
||||
return null; // Use default datasource
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request is an authentication request that should use the default database
|
||||
*/
|
||||
private boolean isAuthenticationRequest() {
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes != null) {
|
||||
String requestURI = attributes.getRequest().getRequestURI();
|
||||
String method = attributes.getRequest().getMethod();
|
||||
|
||||
// Authentication endpoints that should use default database
|
||||
if (requestURI.contains("/auth/") ||
|
||||
requestURI.contains("/login") ||
|
||||
requestURI.contains("/api/libraries/switch") ||
|
||||
(requestURI.contains("/api/libraries") && "POST".equals(method))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("Could not determine request context", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DataSource determineTargetDataSource() {
|
||||
Object lookupKey = determineCurrentLookupKey();
|
||||
|
||||
if (lookupKey != null) {
|
||||
String libraryId = (String) lookupKey;
|
||||
return getLibraryDataSource(libraryId);
|
||||
}
|
||||
|
||||
return getDefaultDataSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a datasource for the specified library
|
||||
*/
|
||||
private DataSource getLibraryDataSource(String libraryId) {
|
||||
return libraryDataSources.computeIfAbsent(libraryId, id -> {
|
||||
try {
|
||||
HikariConfig config = new HikariConfig();
|
||||
|
||||
// Replace database name in URL with library-specific name
|
||||
String libraryUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + "storycove_" + id);
|
||||
|
||||
config.setJdbcUrl(libraryUrl);
|
||||
config.setUsername(dbUsername);
|
||||
config.setPassword(dbPassword);
|
||||
config.setDriverClassName("org.postgresql.Driver");
|
||||
config.setMaximumPoolSize(5); // Smaller pool for library-specific databases
|
||||
config.setConnectionTimeout(10000);
|
||||
config.setMaxLifetime(600000); // 10 minutes
|
||||
|
||||
logger.info("Created new datasource for library: {} -> {}", id, libraryUrl);
|
||||
return new HikariDataSource(config);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to create datasource for library: {}", id, e);
|
||||
return getDefaultDataSource();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private DataSource getDefaultDataSource() {
|
||||
// Use the default target datasource that was set in the configuration
|
||||
try {
|
||||
return (DataSource) super.determineTargetDataSource();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Could not get default datasource via super method", e);
|
||||
}
|
||||
|
||||
// Fallback: create a basic datasource
|
||||
logger.warn("No default datasource available, creating fallback");
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(baseDbUrl);
|
||||
config.setUsername(dbUsername);
|
||||
config.setPassword(dbPassword);
|
||||
config.setDriverClassName("org.postgresql.Driver");
|
||||
config.setMaximumPoolSize(10);
|
||||
config.setConnectionTimeout(30000);
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.service.LibraryService;
|
||||
import com.storycove.service.PasswordAuthenticationService;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -18,10 +19,12 @@ import java.time.Duration;
|
||||
public class AuthController {
|
||||
|
||||
private final PasswordAuthenticationService passwordService;
|
||||
private final LibraryService libraryService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public AuthController(PasswordAuthenticationService passwordService, JwtUtil jwtUtil) {
|
||||
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) {
|
||||
this.passwordService = passwordService;
|
||||
this.libraryService = libraryService;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
@@ -50,6 +53,9 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
// Clear authentication state
|
||||
libraryService.clearAuthentication();
|
||||
|
||||
// Clear the cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||
.httpOnly(true)
|
||||
|
||||
@@ -200,4 +200,43 @@ public class LibraryController {
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update library metadata (name and description)
|
||||
*/
|
||||
@PutMapping("/{libraryId}/metadata")
|
||||
public ResponseEntity<Map<String, Object>> updateLibraryMetadata(
|
||||
@PathVariable String libraryId,
|
||||
@RequestBody Map<String, String> updates) {
|
||||
|
||||
try {
|
||||
String newName = updates.get("name");
|
||||
String newDescription = updates.get("description");
|
||||
|
||||
if (newName == null || newName.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Library name is required"));
|
||||
}
|
||||
|
||||
// Update the library
|
||||
libraryService.updateLibraryMetadata(libraryId, newName, newDescription);
|
||||
|
||||
// Return updated library info
|
||||
LibraryDto updatedLibrary = libraryService.getLibraryById(libraryId);
|
||||
if (updatedLibrary != null) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "Library metadata updated successfully");
|
||||
response.put("library", updatedLibrary);
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to update library metadata for {}: {}", libraryId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "Failed to update library metadata"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -31,13 +29,11 @@ public class AuthorService {
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
private final TypesenseService typesenseService;
|
||||
private final LibraryAwareService libraryAwareService;
|
||||
|
||||
@Autowired
|
||||
public AuthorService(AuthorRepository authorRepository, @Autowired(required = false) TypesenseService typesenseService, LibraryAwareService libraryAwareService) {
|
||||
public AuthorService(AuthorRepository authorRepository, @Autowired(required = false) TypesenseService typesenseService) {
|
||||
this.authorRepository = authorRepository;
|
||||
this.typesenseService = typesenseService;
|
||||
this.libraryAwareService = libraryAwareService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -67,36 +63,8 @@ public class AuthorService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Author findById(UUID id) {
|
||||
// Smart routing: use library-specific database if available, otherwise use repository
|
||||
if (libraryAwareService.hasActiveLibrary()) {
|
||||
return findByIdFromCurrentLibrary(id);
|
||||
} else {
|
||||
return authorRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find author by ID using the current library's database connection
|
||||
*/
|
||||
private Author findByIdFromCurrentLibrary(UUID id) {
|
||||
try (var connection = libraryAwareService.getCurrentLibraryConnection()) {
|
||||
String query = "SELECT id, name, notes, avatar_image_path, author_rating, created_at, updated_at FROM authors WHERE id = ?";
|
||||
|
||||
try (var stmt = connection.prepareStatement(query)) {
|
||||
stmt.setObject(1, id);
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return createAuthorFromResultSet(rs);
|
||||
} else {
|
||||
throw new ResourceNotFoundException("Author", id.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to find author by ID from current library: {}", id, e);
|
||||
throw new RuntimeException("Database error while fetching author", e);
|
||||
}
|
||||
return authorRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -394,32 +362,4 @@ public class AuthorService {
|
||||
existing.getUrls().addAll(urlsCopy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Author entity from ResultSet for library-aware queries
|
||||
*/
|
||||
private Author createAuthorFromResultSet(ResultSet rs) throws SQLException {
|
||||
var author = new Author();
|
||||
author.setId(UUID.fromString(rs.getString("id")));
|
||||
author.setName(rs.getString("name"));
|
||||
author.setNotes(rs.getString("notes"));
|
||||
author.setAvatarImagePath(rs.getString("avatar_image_path"));
|
||||
|
||||
Integer rating = rs.getInt("author_rating");
|
||||
if (!rs.wasNull()) {
|
||||
author.setAuthorRating(rating);
|
||||
}
|
||||
|
||||
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||
if (createdAtTimestamp != null) {
|
||||
author.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||
if (updatedAtTimestamp != null) {
|
||||
author.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
return author;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ public class CollectionService {
|
||||
private final TagRepository tagRepository;
|
||||
private final TypesenseService typesenseService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
private final LibraryAwareService libraryAwareService;
|
||||
|
||||
@Autowired
|
||||
public CollectionService(CollectionRepository collectionRepository,
|
||||
@@ -41,15 +40,13 @@ public class CollectionService {
|
||||
StoryRepository storyRepository,
|
||||
TagRepository tagRepository,
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
ReadingTimeService readingTimeService,
|
||||
LibraryAwareService libraryAwareService) {
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.collectionRepository = collectionRepository;
|
||||
this.collectionStoryRepository = collectionStoryRepository;
|
||||
this.storyRepository = storyRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.typesenseService = typesenseService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
this.libraryAwareService = libraryAwareService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.storycove.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.storycove.repository.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -22,11 +25,15 @@ import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
public class DatabaseManagementService {
|
||||
public class DatabaseManagementService implements ApplicationContextAware {
|
||||
|
||||
// DataSource is now dynamic based on active library
|
||||
@Autowired
|
||||
@Qualifier("dataSource") // Use the primary routing datasource
|
||||
private DataSource dataSource;
|
||||
|
||||
// Use the routing datasource which automatically handles library switching
|
||||
private DataSource getDataSource() {
|
||||
return libraryService.getCurrentDataSource();
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
@@ -55,6 +62,13 @@ public class DatabaseManagementService {
|
||||
|
||||
@Value("${storycove.images.upload-dir:/app/images}")
|
||||
private String uploadDir;
|
||||
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a comprehensive backup including database and files in ZIP format
|
||||
@@ -131,6 +145,17 @@ public class DatabaseManagementService {
|
||||
System.err.println("No files directory found in backup - skipping file restore.");
|
||||
}
|
||||
|
||||
// 6. Trigger complete Typesense reindex after data restoration
|
||||
try {
|
||||
System.err.println("Starting Typesense reindex after restore...");
|
||||
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||
typesenseService.performCompleteReindex();
|
||||
System.err.println("Typesense reindex completed successfully.");
|
||||
} catch (Exception e) {
|
||||
System.err.println("Warning: Failed to reindex Typesense after restore: " + e.getMessage());
|
||||
// Don't fail the entire restore for Typesense issues
|
||||
}
|
||||
|
||||
System.err.println("Complete backup restore finished successfully.");
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -21,7 +21,9 @@ import jakarta.annotation.PreDestroy;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
@@ -59,10 +61,13 @@ public class LibraryService implements ApplicationContextAware {
|
||||
|
||||
// Current active resources
|
||||
private volatile String currentLibraryId;
|
||||
private volatile DataSource currentDataSource;
|
||||
private volatile Client currentTypesenseClient;
|
||||
|
||||
// Security: Track if user has explicitly authenticated in this session
|
||||
private volatile boolean explicitlyAuthenticated = false;
|
||||
|
||||
private static final String LIBRARIES_CONFIG_PATH = "/app/config/libraries.json";
|
||||
private static final Path libraryConfigDir = Paths.get("/app/config");
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||
@@ -78,35 +83,71 @@ public class LibraryService implements ApplicationContextAware {
|
||||
createDefaultLibrary();
|
||||
}
|
||||
|
||||
// Set first library as active if none specified
|
||||
if (currentLibraryId == null && !libraries.isEmpty()) {
|
||||
String firstLibraryId = libraries.keySet().iterator().next();
|
||||
logger.info("No active library set, defaulting to: {}", firstLibraryId);
|
||||
try {
|
||||
switchToLibrary(firstLibraryId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize default library: {}", firstLibraryId, e);
|
||||
}
|
||||
// Security: Do NOT automatically switch to any library on startup
|
||||
// Users must authenticate before accessing any library
|
||||
explicitlyAuthenticated = false;
|
||||
currentLibraryId = null;
|
||||
|
||||
if (!libraries.isEmpty()) {
|
||||
logger.info("Loaded {} libraries. Authentication required to access any library.", libraries.size());
|
||||
} else {
|
||||
logger.info("No libraries found. A default library will be created on first authentication.");
|
||||
}
|
||||
|
||||
logger.info("Security: Application startup completed. All users must re-authenticate.");
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
closeCurrentResources();
|
||||
currentLibraryId = null;
|
||||
currentTypesenseClient = null;
|
||||
explicitlyAuthenticated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication state (for logout)
|
||||
*/
|
||||
public void clearAuthentication() {
|
||||
explicitlyAuthenticated = false;
|
||||
currentLibraryId = null;
|
||||
currentTypesenseClient = null;
|
||||
logger.info("Authentication cleared - user must re-authenticate to access libraries");
|
||||
}
|
||||
|
||||
|
||||
public String authenticateAndGetLibrary(String password) {
|
||||
for (Library library : libraries.values()) {
|
||||
if (passwordEncoder.matches(password, library.getPasswordHash())) {
|
||||
// Mark as explicitly authenticated for this session
|
||||
explicitlyAuthenticated = true;
|
||||
logger.info("User explicitly authenticated for library: {}", library.getId());
|
||||
return library.getId();
|
||||
}
|
||||
}
|
||||
return null; // Authentication failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to library after authentication with forced reindexing
|
||||
* This ensures Typesense is always up-to-date after login
|
||||
*/
|
||||
public synchronized void switchToLibraryAfterAuthentication(String libraryId) throws Exception {
|
||||
logger.info("Switching to library after authentication: {} (forcing reindex)", libraryId);
|
||||
switchToLibrary(libraryId, true);
|
||||
}
|
||||
|
||||
public synchronized void switchToLibrary(String libraryId) throws Exception {
|
||||
if (libraryId.equals(currentLibraryId)) {
|
||||
return; // Already active
|
||||
switchToLibrary(libraryId, false);
|
||||
}
|
||||
|
||||
public synchronized void switchToLibrary(String libraryId, boolean forceReindex) throws Exception {
|
||||
// Security: Only allow library switching after explicit authentication
|
||||
if (!explicitlyAuthenticated) {
|
||||
throw new IllegalStateException("Library switching requires explicit authentication. Please log in first.");
|
||||
}
|
||||
|
||||
if (libraryId.equals(currentLibraryId) && !forceReindex) {
|
||||
return; // Already active and no forced reindex requested
|
||||
}
|
||||
|
||||
Library library = libraries.get(libraryId);
|
||||
@@ -114,33 +155,68 @@ public class LibraryService implements ApplicationContextAware {
|
||||
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||
}
|
||||
|
||||
logger.info("Switching to library: {} ({})", library.getName(), libraryId);
|
||||
String previousLibraryId = currentLibraryId;
|
||||
|
||||
if (libraryId.equals(currentLibraryId) && forceReindex) {
|
||||
logger.info("Forcing reindex for current library: {} ({})", library.getName(), libraryId);
|
||||
} else {
|
||||
logger.info("Switching to library: {} ({})", library.getName(), libraryId);
|
||||
}
|
||||
|
||||
// Close current resources
|
||||
closeCurrentResources();
|
||||
|
||||
// Create new resources
|
||||
currentDataSource = createDataSource(library.getDbName());
|
||||
currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection());
|
||||
// Set new active library (datasource routing handled by SmartRoutingDataSource)
|
||||
currentLibraryId = libraryId;
|
||||
currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection());
|
||||
|
||||
// Initialize Typesense collections for this library if they don't exist
|
||||
// Initialize Typesense collections for this library
|
||||
try {
|
||||
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||
// First ensure collections exist
|
||||
typesenseService.initializeCollectionsForCurrentLibrary();
|
||||
logger.info("Completed Typesense initialization for library: {}", libraryId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to initialize Typesense collections for library {}: {}", libraryId, e.getMessage());
|
||||
logger.warn("Failed to initialize Typesense for library {}: {}", libraryId, e.getMessage());
|
||||
// Don't fail the switch - collections can be created later
|
||||
}
|
||||
|
||||
logger.info("Successfully switched to library: {}", library.getName());
|
||||
|
||||
// Perform complete reindex AFTER library switch is fully complete
|
||||
// This ensures database routing is properly established
|
||||
if (forceReindex || !libraryId.equals(previousLibraryId)) {
|
||||
logger.info("Starting post-switch Typesense reindex for library: {}", libraryId);
|
||||
|
||||
// Run reindex asynchronously to avoid blocking authentication response
|
||||
// and allow time for database routing to fully stabilize
|
||||
String finalLibraryId = libraryId;
|
||||
new Thread(() -> {
|
||||
try {
|
||||
// Give routing time to stabilize
|
||||
Thread.sleep(500);
|
||||
logger.info("Starting async Typesense reindex for library: {}", finalLibraryId);
|
||||
|
||||
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||
typesenseService.performCompleteReindex();
|
||||
logger.info("Completed async Typesense reindexing for library: {}", finalLibraryId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to async reindex Typesense for library {}: {}", finalLibraryId, e.getMessage());
|
||||
}
|
||||
}, "TypesenseReindex-" + libraryId).start();
|
||||
}
|
||||
}
|
||||
|
||||
public DataSource getCurrentDataSource() {
|
||||
if (currentDataSource == null) {
|
||||
if (currentLibraryId == null) {
|
||||
throw new IllegalStateException("No active library - please authenticate first");
|
||||
}
|
||||
return currentDataSource;
|
||||
// Return the Spring-managed primary datasource which handles routing automatically
|
||||
try {
|
||||
return applicationContext.getBean("dataSource", DataSource.class);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to get routing datasource", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Client getCurrentTypesenseClient() {
|
||||
@@ -176,6 +252,21 @@ public class LibraryService implements ApplicationContextAware {
|
||||
return result;
|
||||
}
|
||||
|
||||
public LibraryDto getLibraryById(String libraryId) {
|
||||
Library library = libraries.get(libraryId);
|
||||
if (library != null) {
|
||||
boolean isActive = library.getId().equals(currentLibraryId);
|
||||
return new LibraryDto(
|
||||
library.getId(),
|
||||
library.getName(),
|
||||
library.getDescription(),
|
||||
isActive,
|
||||
library.isInitialized()
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getCurrentImagePath() {
|
||||
Library current = getCurrentLibrary();
|
||||
return current != null ? current.getImagePath() : "/images/default";
|
||||
@@ -689,14 +780,74 @@ public class LibraryService implements ApplicationContextAware {
|
||||
}
|
||||
|
||||
private void closeCurrentResources() {
|
||||
if (currentDataSource instanceof HikariDataSource) {
|
||||
logger.info("Closing current DataSource");
|
||||
((HikariDataSource) currentDataSource).close();
|
||||
// No need to close datasource - SmartRoutingDataSource handles this
|
||||
// Typesense client doesn't need explicit cleanup
|
||||
currentTypesenseClient = null;
|
||||
// Don't clear currentLibraryId here - only when explicitly switching
|
||||
}
|
||||
|
||||
/**
|
||||
* Update library metadata (name and description)
|
||||
*/
|
||||
public synchronized void updateLibraryMetadata(String libraryId, String newName, String newDescription) throws Exception {
|
||||
if (libraryId == null || libraryId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Library ID cannot be null or empty");
|
||||
}
|
||||
|
||||
// Typesense client doesn't need explicit cleanup
|
||||
currentDataSource = null;
|
||||
currentTypesenseClient = null;
|
||||
currentLibraryId = null;
|
||||
Library library = libraries.get(libraryId);
|
||||
if (library == null) {
|
||||
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||
}
|
||||
|
||||
// Validate new name
|
||||
if (newName == null || newName.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Library name cannot be null or empty");
|
||||
}
|
||||
|
||||
String oldName = library.getName();
|
||||
String oldDescription = library.getDescription();
|
||||
|
||||
// Update the library object
|
||||
library.setName(newName.trim());
|
||||
library.setDescription(newDescription != null ? newDescription.trim() : "");
|
||||
|
||||
try {
|
||||
// Save to configuration file
|
||||
saveLibraryConfiguration(library);
|
||||
|
||||
logger.info("Updated library metadata - ID: {}, Name: '{}' -> '{}', Description: '{}' -> '{}'",
|
||||
libraryId, oldName, newName, oldDescription, library.getDescription());
|
||||
|
||||
} catch (Exception e) {
|
||||
// Rollback changes on failure
|
||||
library.setName(oldName);
|
||||
library.setDescription(oldDescription);
|
||||
throw new RuntimeException("Failed to update library metadata: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save library configuration to file
|
||||
*/
|
||||
private void saveLibraryConfiguration(Library library) throws Exception {
|
||||
Path libraryConfigPath = libraryConfigDir.resolve(library.getId() + ".json");
|
||||
|
||||
// Create library configuration object
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("id", library.getId());
|
||||
config.put("name", library.getName());
|
||||
config.put("description", library.getDescription());
|
||||
config.put("passwordHash", library.getPasswordHash());
|
||||
config.put("dbName", library.getDbName());
|
||||
config.put("typesenseCollection", library.getTypesenseCollection());
|
||||
config.put("imagePath", library.getImagePath());
|
||||
config.put("initialized", library.isInitialized());
|
||||
|
||||
// Write to file
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String configJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
|
||||
Files.writeString(libraryConfigPath, configJson, StandardCharsets.UTF_8);
|
||||
|
||||
logger.debug("Saved library configuration to: {}", libraryConfigPath);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@ public class PasswordAuthenticationService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Switch to the authenticated library (may take 2-3 seconds)
|
||||
libraryService.switchToLibrary(libraryId);
|
||||
// Switch to the authenticated library with forced reindexing (may take 2-3 seconds)
|
||||
libraryService.switchToLibraryAfterAuthentication(libraryId);
|
||||
|
||||
// Generate JWT token with library context
|
||||
String token = jwtUtil.generateToken("user", libraryId);
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.storycove.repository.SeriesRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -20,6 +22,8 @@ import java.util.UUID;
|
||||
@Validated
|
||||
@Transactional
|
||||
public class SeriesService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SeriesService.class);
|
||||
|
||||
private final SeriesRepository seriesRepository;
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
@@ -45,7 +43,6 @@ public class StoryService {
|
||||
private final SeriesService seriesService;
|
||||
private final HtmlSanitizationService sanitizationService;
|
||||
private final TypesenseService typesenseService;
|
||||
private final LibraryAwareService libraryAwareService;
|
||||
|
||||
@Autowired
|
||||
public StoryService(StoryRepository storyRepository,
|
||||
@@ -55,8 +52,7 @@ public class StoryService {
|
||||
TagService tagService,
|
||||
SeriesService seriesService,
|
||||
HtmlSanitizationService sanitizationService,
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
LibraryAwareService libraryAwareService) {
|
||||
@Autowired(required = false) TypesenseService typesenseService) {
|
||||
this.storyRepository = storyRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.readingPositionRepository = readingPositionRepository;
|
||||
@@ -65,7 +61,6 @@ public class StoryService {
|
||||
this.seriesService = seriesService;
|
||||
this.sanitizationService = sanitizationService;
|
||||
this.typesenseService = typesenseService;
|
||||
this.libraryAwareService = libraryAwareService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -85,47 +80,10 @@ public class StoryService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Story findById(UUID id) {
|
||||
// Smart routing: use library-specific database if available, otherwise use repository
|
||||
if (libraryAwareService.hasActiveLibrary()) {
|
||||
return findByIdFromCurrentLibrary(id);
|
||||
} else {
|
||||
return storyRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
||||
}
|
||||
return storyRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find story by ID using the current library's database connection
|
||||
*/
|
||||
private Story findByIdFromCurrentLibrary(UUID id) {
|
||||
try (var connection = libraryAwareService.getCurrentLibraryConnection()) {
|
||||
String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " +
|
||||
"s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " +
|
||||
"s.created_at, s.updated_at, " +
|
||||
"a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " +
|
||||
"a.created_at as author_created_at, a.updated_at as author_updated_at, " +
|
||||
"ser.name as series_name, ser.description as series_description, " +
|
||||
"ser.created_at as series_created_at " +
|
||||
"FROM stories s " +
|
||||
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||
"LEFT JOIN series ser ON s.series_id = ser.id " +
|
||||
"WHERE s.id = ?";
|
||||
|
||||
try (var stmt = connection.prepareStatement(query)) {
|
||||
stmt.setObject(1, id);
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return createStoryFromResultSet(rs);
|
||||
} else {
|
||||
throw new ResourceNotFoundException("Story", id.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to find story by ID from current library: {}", id, e);
|
||||
throw new RuntimeException("Database error while fetching story", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Story> findByIdOptional(UUID id) {
|
||||
@@ -736,15 +694,15 @@ public class StoryService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original database-based implementation if Typesense is not available
|
||||
return findRandomStoryFallback(searchQuery, tags);
|
||||
// Fallback to repository-based implementation (global routing handles library selection)
|
||||
return findRandomStoryFromRepository(searchQuery, tags);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fallback method for random story selection using database queries.
|
||||
* Used when Typesense is not available or fails.
|
||||
* Find random story using repository methods (for default database or when library-aware fails)
|
||||
*/
|
||||
private Optional<Story> findRandomStoryFallback(String searchQuery, List<String> tags) {
|
||||
private Optional<Story> findRandomStoryFromRepository(String searchQuery, List<String> tags) {
|
||||
// Clean up inputs
|
||||
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
|
||||
List<String> cleanTags = (tags != null) ? tags.stream()
|
||||
@@ -811,82 +769,5 @@ public class StoryService {
|
||||
return randomStory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Story entity from ResultSet for library-aware queries
|
||||
*/
|
||||
private Story createStoryFromResultSet(ResultSet rs) throws SQLException {
|
||||
var story = new Story();
|
||||
story.setId(UUID.fromString(rs.getString("id")));
|
||||
story.setTitle(rs.getString("title"));
|
||||
story.setSummary(rs.getString("summary"));
|
||||
story.setDescription(rs.getString("description"));
|
||||
story.setContentHtml(rs.getString("content_html"));
|
||||
story.setSourceUrl(rs.getString("source_url"));
|
||||
story.setCoverPath(rs.getString("cover_path"));
|
||||
story.setWordCount(rs.getInt("word_count"));
|
||||
story.setRating(rs.getInt("rating"));
|
||||
story.setVolume(rs.getInt("volume"));
|
||||
story.setIsRead(rs.getBoolean("is_read"));
|
||||
story.setReadingPosition(rs.getInt("reading_position"));
|
||||
|
||||
var lastReadAtTimestamp = rs.getTimestamp("last_read_at");
|
||||
if (lastReadAtTimestamp != null) {
|
||||
story.setLastReadAt(lastReadAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||
if (createdAtTimestamp != null) {
|
||||
story.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||
if (updatedAtTimestamp != null) {
|
||||
story.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
// Set complete author information
|
||||
String authorIdStr = rs.getString("author_id");
|
||||
if (authorIdStr != null) {
|
||||
var author = new Author();
|
||||
author.setId(UUID.fromString(authorIdStr));
|
||||
author.setName(rs.getString("author_name"));
|
||||
author.setNotes(rs.getString("author_notes"));
|
||||
author.setAvatarImagePath(rs.getString("author_avatar"));
|
||||
|
||||
Integer authorRating = rs.getInt("author_rating");
|
||||
if (!rs.wasNull()) {
|
||||
author.setAuthorRating(authorRating);
|
||||
}
|
||||
|
||||
var authorCreatedAt = rs.getTimestamp("author_created_at");
|
||||
if (authorCreatedAt != null) {
|
||||
author.setCreatedAt(authorCreatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
var authorUpdatedAt = rs.getTimestamp("author_updated_at");
|
||||
if (authorUpdatedAt != null) {
|
||||
author.setUpdatedAt(authorUpdatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
story.setAuthor(author);
|
||||
}
|
||||
|
||||
// Set complete series information
|
||||
String seriesIdStr = rs.getString("series_id");
|
||||
if (seriesIdStr != null) {
|
||||
var series = new Series();
|
||||
series.setId(UUID.fromString(seriesIdStr));
|
||||
series.setName(rs.getString("series_name"));
|
||||
series.setDescription(rs.getString("series_description"));
|
||||
|
||||
var seriesCreatedAt = rs.getTimestamp("series_created_at");
|
||||
if (seriesCreatedAt != null) {
|
||||
series.setCreatedAt(seriesCreatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
story.setSeries(series);
|
||||
}
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
}
|
||||
1192
backend/src/main/java/com/storycove/service/StoryService.java.backup
Normal file
1192
backend/src/main/java/com/storycove/service/StoryService.java.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import com.storycove.repository.TagAliasRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -26,6 +28,8 @@ import java.util.UUID;
|
||||
@Validated
|
||||
@Transactional
|
||||
public class TagService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagService.class);
|
||||
|
||||
private final TagRepository tagRepository;
|
||||
private final TagAliasRepository tagAliasRepository;
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.typesense.api.Client;
|
||||
import org.typesense.model.*;
|
||||
@@ -47,6 +48,19 @@ public class TypesenseService {
|
||||
private final CollectionStoryRepository collectionStoryRepository;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
// Services for complete reindexing (avoiding circular dependencies with @Lazy)
|
||||
@Autowired
|
||||
@Lazy
|
||||
private StoryService storyService;
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private AuthorService authorService;
|
||||
|
||||
@Autowired
|
||||
@Lazy
|
||||
private CollectionService collectionService;
|
||||
|
||||
@Autowired
|
||||
public TypesenseService(LibraryService libraryService,
|
||||
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository,
|
||||
@@ -1343,6 +1357,78 @@ public class TypesenseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a complete reindex of all entity types for the current library.
|
||||
* This should be called when switching libraries to ensure Typesense has accurate data.
|
||||
*/
|
||||
public void performCompleteReindex() {
|
||||
try {
|
||||
logger.info("Starting complete Typesense reindex for current library");
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// Get all data from the current library (SmartRoutingDataSource handles library-specific routing)
|
||||
|
||||
// Reindex stories
|
||||
try {
|
||||
logger.info("DEBUG: About to fetch stories from storyService...");
|
||||
String currentLib = libraryService.getCurrentLibraryId();
|
||||
logger.info("DEBUG: Current library ID during reindex: {}", currentLib);
|
||||
List<Story> allStories = storyService.findAllWithAssociations();
|
||||
logger.info("DEBUG: StoryService returned {} stories", allStories.size());
|
||||
if (!allStories.isEmpty()) {
|
||||
reindexAllStories(allStories);
|
||||
logger.info("Reindexed {} stories", allStories.size());
|
||||
} else {
|
||||
// Still create empty collection for consistency
|
||||
recreateStoriesCollection();
|
||||
logger.info("Created empty stories collection - NO STORIES FOUND IN DATABASE");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex stories during complete reindex", e);
|
||||
}
|
||||
|
||||
// Reindex authors
|
||||
try {
|
||||
logger.info("DEBUG: About to fetch authors from authorService...");
|
||||
List<Author> allAuthors = authorService.findAllWithStories();
|
||||
logger.info("DEBUG: AuthorService returned {} authors", allAuthors.size());
|
||||
if (!allAuthors.isEmpty()) {
|
||||
reindexAllAuthors(allAuthors);
|
||||
logger.info("Reindexed {} authors", allAuthors.size());
|
||||
} else {
|
||||
// Still create empty collection for consistency
|
||||
recreateAuthorsCollection();
|
||||
logger.info("Created empty authors collection - NO AUTHORS FOUND IN DATABASE");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex authors during complete reindex", e);
|
||||
}
|
||||
|
||||
// Reindex collections
|
||||
try {
|
||||
List<Collection> allCollections = collectionService.findAllWithTags();
|
||||
if (!allCollections.isEmpty()) {
|
||||
reindexAllCollections(allCollections);
|
||||
logger.info("Reindexed {} collections", allCollections.size());
|
||||
} else {
|
||||
// Still create empty collection for consistency
|
||||
createCollectionsCollectionIfNotExists();
|
||||
logger.info("Created empty collections collection");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex collections during complete reindex", e);
|
||||
}
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
long duration = endTime - startTime;
|
||||
logger.info("Completed full Typesense reindex in {}ms", duration);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to complete full Typesense reindex", e);
|
||||
throw new RuntimeException("Complete reindex failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Typesense document from Collection entity
|
||||
*/
|
||||
|
||||
@@ -3,21 +3,41 @@ package com.storycove.util;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${storycove.jwt.secret}")
|
||||
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
|
||||
private Long expiration;
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
// Generate a new random secret on startup to invalidate all existing JWT tokens
|
||||
// This ensures users must re-authenticate after application restart
|
||||
SecureRandom random = new SecureRandom();
|
||||
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");
|
||||
}
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ class AuthorServiceTest {
|
||||
testAuthor.setId(testId);
|
||||
testAuthor.setNotes("Test notes");
|
||||
|
||||
// Initialize service with null TypesenseService and LibraryAwareService (which is allowed for tests)
|
||||
authorService = new AuthorService(authorRepository, null, null);
|
||||
// Initialize service with null TypesenseService (which is allowed for tests)
|
||||
authorService = new AuthorService(authorRepository, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -53,8 +53,7 @@ class StoryServiceTest {
|
||||
null, // tagService - not needed for reading progress tests
|
||||
null, // seriesService - not needed for reading progress tests
|
||||
null, // sanitizationService - not needed for reading progress tests
|
||||
null, // typesenseService - will test both with and without
|
||||
null // libraryAwareService - not needed for these tests
|
||||
null // typesenseService - will test both with and without
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user