Fixing Database switching functionality.

This commit is contained in:
Stefan Hardegger
2025-08-21 08:54:28 +02:00
parent 4ee5fa2330
commit 87a4999ffe
17 changed files with 1743 additions and 246 deletions

View File

@@ -48,22 +48,17 @@ public class DatabaseConfig {
} }
/** /**
* Primary datasource bean - using fallback for stability. * Primary datasource bean - uses smart routing that excludes authentication operations
* Library-specific routing will be handled at the service layer instead.
*/ */
@Bean(name = "dataSource") @Bean(name = "dataSource")
@Primary @Primary
public DataSource primaryDataSource() { @DependsOn("libraryService")
return fallbackDataSource(); public DataSource primaryDataSource(LibraryService libraryService) {
} SmartRoutingDataSource routingDataSource = new SmartRoutingDataSource(
libraryService, baseDbUrl, dbUsername, dbPassword);
/**
* Optional routing datasource for future use if needed
*/
@Bean(name = "libraryRoutingDataSource")
public DataSource libraryAwareDataSource(LibraryService libraryService) {
LibraryAwareDataSource routingDataSource = new LibraryAwareDataSource(libraryService);
routingDataSource.setDefaultTargetDataSource(fallbackDataSource()); routingDataSource.setDefaultTargetDataSource(fallbackDataSource());
routingDataSource.setTargetDataSources(new java.util.HashMap<>());
return routingDataSource; return routingDataSource;
} }
} }

View File

@@ -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);
}
}

View File

@@ -1,5 +1,6 @@
package com.storycove.controller; package com.storycove.controller;
import com.storycove.service.LibraryService;
import com.storycove.service.PasswordAuthenticationService; import com.storycove.service.PasswordAuthenticationService;
import com.storycove.util.JwtUtil; import com.storycove.util.JwtUtil;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@@ -18,10 +19,12 @@ import java.time.Duration;
public class AuthController { public class AuthController {
private final PasswordAuthenticationService passwordService; private final PasswordAuthenticationService passwordService;
private final LibraryService libraryService;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
public AuthController(PasswordAuthenticationService passwordService, JwtUtil jwtUtil) { public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) {
this.passwordService = passwordService; this.passwordService = passwordService;
this.libraryService = libraryService;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
} }
@@ -50,6 +53,9 @@ public class AuthController {
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) { public ResponseEntity<?> logout(HttpServletResponse response) {
// Clear authentication state
libraryService.clearAuthentication();
// Clear the cookie // Clear the cookie
ResponseCookie cookie = ResponseCookie.from("token", "") ResponseCookie cookie = ResponseCookie.from("token", "")
.httpOnly(true) .httpOnly(true)

View File

@@ -200,4 +200,43 @@ public class LibraryController {
return ResponseEntity.internalServerError().body(Map.of("error", "Server error")); 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"));
}
}
} }

View File

@@ -15,8 +15,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -31,13 +29,11 @@ public class AuthorService {
private final AuthorRepository authorRepository; private final AuthorRepository authorRepository;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final LibraryAwareService libraryAwareService;
@Autowired @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.authorRepository = authorRepository;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
this.libraryAwareService = libraryAwareService;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -67,36 +63,8 @@ public class AuthorService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Author findById(UUID id) { public Author findById(UUID id) {
// Smart routing: use library-specific database if available, otherwise use repository return authorRepository.findById(id)
if (libraryAwareService.hasActiveLibrary()) { .orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
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);
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -394,32 +362,4 @@ public class AuthorService {
existing.getUrls().addAll(urlsCopy); 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;
}
} }

View File

@@ -33,7 +33,6 @@ public class CollectionService {
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final ReadingTimeService readingTimeService; private final ReadingTimeService readingTimeService;
private final LibraryAwareService libraryAwareService;
@Autowired @Autowired
public CollectionService(CollectionRepository collectionRepository, public CollectionService(CollectionRepository collectionRepository,
@@ -41,15 +40,13 @@ public class CollectionService {
StoryRepository storyRepository, StoryRepository storyRepository,
TagRepository tagRepository, TagRepository tagRepository,
@Autowired(required = false) TypesenseService typesenseService, @Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService, ReadingTimeService readingTimeService) {
LibraryAwareService libraryAwareService) {
this.collectionRepository = collectionRepository; this.collectionRepository = collectionRepository;
this.collectionStoryRepository = collectionStoryRepository; this.collectionStoryRepository = collectionStoryRepository;
this.storyRepository = storyRepository; this.storyRepository = storyRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService; this.readingTimeService = readingTimeService;
this.libraryAwareService = libraryAwareService;
} }
/** /**

View File

@@ -3,7 +3,10 @@ package com.storycove.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.storycove.repository.*; import com.storycove.repository.*;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; 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.ByteArrayResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,11 +25,15 @@ import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@Service @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() { private DataSource getDataSource() {
return libraryService.getCurrentDataSource(); return dataSource;
} }
@Autowired @Autowired
@@ -56,6 +63,13 @@ public class DatabaseManagementService {
@Value("${storycove.images.upload-dir:/app/images}") @Value("${storycove.images.upload-dir:/app/images}")
private String uploadDir; 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 * 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."); 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."); System.err.println("Complete backup restore finished successfully.");
} catch (Exception e) { } catch (Exception e) {

View File

@@ -21,7 +21,9 @@ import jakarta.annotation.PreDestroy;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Duration; import java.time.Duration;
@@ -59,10 +61,13 @@ public class LibraryService implements ApplicationContextAware {
// Current active resources // Current active resources
private volatile String currentLibraryId; private volatile String currentLibraryId;
private volatile DataSource currentDataSource;
private volatile Client currentTypesenseClient; 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 String LIBRARIES_CONFIG_PATH = "/app/config/libraries.json";
private static final Path libraryConfigDir = Paths.get("/app/config");
@Override @Override
public void setApplicationContext(ApplicationContext applicationContext) { public void setApplicationContext(ApplicationContext applicationContext) {
@@ -78,35 +83,71 @@ public class LibraryService implements ApplicationContextAware {
createDefaultLibrary(); createDefaultLibrary();
} }
// Set first library as active if none specified // Security: Do NOT automatically switch to any library on startup
if (currentLibraryId == null && !libraries.isEmpty()) { // Users must authenticate before accessing any library
String firstLibraryId = libraries.keySet().iterator().next(); explicitlyAuthenticated = false;
logger.info("No active library set, defaulting to: {}", firstLibraryId); currentLibraryId = null;
try {
switchToLibrary(firstLibraryId); if (!libraries.isEmpty()) {
} catch (Exception e) { logger.info("Loaded {} libraries. Authentication required to access any library.", libraries.size());
logger.error("Failed to initialize default library: {}", firstLibraryId, e); } 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 @PreDestroy
public void cleanup() { 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) { public String authenticateAndGetLibrary(String password) {
for (Library library : libraries.values()) { for (Library library : libraries.values()) {
if (passwordEncoder.matches(password, library.getPasswordHash())) { 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 library.getId();
} }
} }
return null; // Authentication failed 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 { public synchronized void switchToLibrary(String libraryId) throws Exception {
if (libraryId.equals(currentLibraryId)) { switchToLibrary(libraryId, false);
return; // Already active }
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); Library library = libraries.get(libraryId);
@@ -114,33 +155,68 @@ public class LibraryService implements ApplicationContextAware {
throw new IllegalArgumentException("Library not found: " + libraryId); 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 // Close current resources
closeCurrentResources(); closeCurrentResources();
// Create new resources // Set new active library (datasource routing handled by SmartRoutingDataSource)
currentDataSource = createDataSource(library.getDbName());
currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection());
currentLibraryId = libraryId; currentLibraryId = libraryId;
currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection());
// Initialize Typesense collections for this library if they don't exist // Initialize Typesense collections for this library
try { try {
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class); TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
// First ensure collections exist
typesenseService.initializeCollectionsForCurrentLibrary(); typesenseService.initializeCollectionsForCurrentLibrary();
logger.info("Completed Typesense initialization for library: {}", libraryId);
} catch (Exception e) { } 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 // Don't fail the switch - collections can be created later
} }
logger.info("Successfully switched to library: {}", library.getName()); 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() { public DataSource getCurrentDataSource() {
if (currentDataSource == null) { if (currentLibraryId == null) {
throw new IllegalStateException("No active library - please authenticate first"); 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() { public Client getCurrentTypesenseClient() {
@@ -176,6 +252,21 @@ public class LibraryService implements ApplicationContextAware {
return result; 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() { public String getCurrentImagePath() {
Library current = getCurrentLibrary(); Library current = getCurrentLibrary();
return current != null ? current.getImagePath() : "/images/default"; return current != null ? current.getImagePath() : "/images/default";
@@ -689,14 +780,74 @@ public class LibraryService implements ApplicationContextAware {
} }
private void closeCurrentResources() { private void closeCurrentResources() {
if (currentDataSource instanceof HikariDataSource) { // No need to close datasource - SmartRoutingDataSource handles this
logger.info("Closing current DataSource"); // Typesense client doesn't need explicit cleanup
((HikariDataSource) currentDataSource).close(); 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 Library library = libraries.get(libraryId);
currentDataSource = null; if (library == null) {
currentTypesenseClient = null; throw new IllegalArgumentException("Library not found: " + libraryId);
currentLibraryId = null; }
// 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);
} }
} }

View File

@@ -43,8 +43,8 @@ public class PasswordAuthenticationService {
} }
try { try {
// Switch to the authenticated library (may take 2-3 seconds) // Switch to the authenticated library with forced reindexing (may take 2-3 seconds)
libraryService.switchToLibrary(libraryId); libraryService.switchToLibraryAfterAuthentication(libraryId);
// Generate JWT token with library context // Generate JWT token with library context
String token = jwtUtil.generateToken("user", libraryId); String token = jwtUtil.generateToken("user", libraryId);

View File

@@ -5,6 +5,8 @@ import com.storycove.repository.SeriesRepository;
import com.storycove.service.exception.DuplicateResourceException; import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException; import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -21,6 +23,8 @@ import java.util.UUID;
@Transactional @Transactional
public class SeriesService { public class SeriesService {
private static final Logger logger = LoggerFactory.getLogger(SeriesService.class);
private final SeriesRepository seriesRepository; private final SeriesRepository seriesRepository;
@Autowired @Autowired

View File

@@ -19,8 +19,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
@@ -45,7 +43,6 @@ public class StoryService {
private final SeriesService seriesService; private final SeriesService seriesService;
private final HtmlSanitizationService sanitizationService; private final HtmlSanitizationService sanitizationService;
private final TypesenseService typesenseService; private final TypesenseService typesenseService;
private final LibraryAwareService libraryAwareService;
@Autowired @Autowired
public StoryService(StoryRepository storyRepository, public StoryService(StoryRepository storyRepository,
@@ -55,8 +52,7 @@ public class StoryService {
TagService tagService, TagService tagService,
SeriesService seriesService, SeriesService seriesService,
HtmlSanitizationService sanitizationService, HtmlSanitizationService sanitizationService,
@Autowired(required = false) TypesenseService typesenseService, @Autowired(required = false) TypesenseService typesenseService) {
LibraryAwareService libraryAwareService) {
this.storyRepository = storyRepository; this.storyRepository = storyRepository;
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.readingPositionRepository = readingPositionRepository; this.readingPositionRepository = readingPositionRepository;
@@ -65,7 +61,6 @@ public class StoryService {
this.seriesService = seriesService; this.seriesService = seriesService;
this.sanitizationService = sanitizationService; this.sanitizationService = sanitizationService;
this.typesenseService = typesenseService; this.typesenseService = typesenseService;
this.libraryAwareService = libraryAwareService;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -85,47 +80,10 @@ public class StoryService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Story findById(UUID id) { public Story findById(UUID id) {
// Smart routing: use library-specific database if available, otherwise use repository return storyRepository.findById(id)
if (libraryAwareService.hasActiveLibrary()) { .orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
return findByIdFromCurrentLibrary(id);
} else {
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) @Transactional(readOnly = true)
public Optional<Story> findByIdOptional(UUID id) { public Optional<Story> findByIdOptional(UUID id) {
@@ -736,15 +694,15 @@ public class StoryService {
} }
} }
// Fallback to original database-based implementation if Typesense is not available // Fallback to repository-based implementation (global routing handles library selection)
return findRandomStoryFallback(searchQuery, tags); return findRandomStoryFromRepository(searchQuery, tags);
} }
/** /**
* Fallback method for random story selection using database queries. * Find random story using repository methods (for default database or when library-aware fails)
* Used when Typesense is not available or fails.
*/ */
private Optional<Story> findRandomStoryFallback(String searchQuery, List<String> tags) { private Optional<Story> findRandomStoryFromRepository(String searchQuery, List<String> tags) {
// Clean up inputs // Clean up inputs
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null; String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
List<String> cleanTags = (tags != null) ? tags.stream() List<String> cleanTags = (tags != null) ? tags.stream()
@@ -811,82 +769,5 @@ public class StoryService {
return randomStory; 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;
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ import com.storycove.repository.TagAliasRepository;
import com.storycove.service.exception.DuplicateResourceException; import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException; import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -27,6 +29,8 @@ import java.util.UUID;
@Transactional @Transactional
public class TagService { public class TagService {
private static final Logger logger = LoggerFactory.getLogger(TagService.class);
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final TagAliasRepository tagAliasRepository; private final TagAliasRepository tagAliasRepository;

View File

@@ -13,6 +13,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.typesense.api.Client; import org.typesense.api.Client;
import org.typesense.model.*; import org.typesense.model.*;
@@ -47,6 +48,19 @@ public class TypesenseService {
private final CollectionStoryRepository collectionStoryRepository; private final CollectionStoryRepository collectionStoryRepository;
private final ReadingTimeService readingTimeService; 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 @Autowired
public TypesenseService(LibraryService libraryService, public TypesenseService(LibraryService libraryService,
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository, @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 * Create Typesense document from Collection entity
*/ */

View File

@@ -3,21 +3,41 @@ package com.storycove.util;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Date; import java.util.Date;
@Component @Component
public class JwtUtil { 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; private String secret;
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default @Value("${storycove.jwt.expiration:86400000}") // 24 hours default
private Long expiration; 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() { private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes()); return Keys.hmacShaKeyFor(secret.getBytes());
} }

View File

@@ -44,8 +44,8 @@ class AuthorServiceTest {
testAuthor.setId(testId); testAuthor.setId(testId);
testAuthor.setNotes("Test notes"); testAuthor.setNotes("Test notes");
// Initialize service with null TypesenseService and LibraryAwareService (which is allowed for tests) // Initialize service with null TypesenseService (which is allowed for tests)
authorService = new AuthorService(authorRepository, null, null); authorService = new AuthorService(authorRepository, null);
} }
@Test @Test

View File

@@ -53,8 +53,7 @@ class StoryServiceTest {
null, // tagService - not needed for reading progress tests null, // tagService - not needed for reading progress tests
null, // seriesService - not needed for reading progress tests null, // seriesService - not needed for reading progress tests
null, // sanitizationService - not needed for reading progress tests null, // sanitizationService - not needed for reading progress tests
null, // typesenseService - will test both with and without null // typesenseService - will test both with and without
null // libraryAwareService - not needed for these tests
); );
} }