Fixing Database switching functionality.
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
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)
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,37 +63,9 @@ 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
|
|
||||||
if (libraryAwareService.hasActiveLibrary()) {
|
|
||||||
return findByIdFromCurrentLibrary(id);
|
|
||||||
} else {
|
|
||||||
return authorRepository.findById(id)
|
return authorRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
|
.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)
|
||||||
public Optional<Author> findByIdOptional(UUID id) {
|
public Optional<Author> findByIdOptional(UUID id) {
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typesense client doesn't need explicit cleanup
|
/**
|
||||||
currentDataSource = null;
|
* Update library metadata (name and description)
|
||||||
currentTypesenseClient = null;
|
*/
|
||||||
currentLibraryId = null;
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
if (libraryAwareService.hasActiveLibrary()) {
|
|
||||||
return findByIdFromCurrentLibrary(id);
|
|
||||||
} else {
|
|
||||||
return storyRepository.findById(id)
|
return storyRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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.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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user