feat: add audit log system for tracking admin-significant actions (#2759)

This commit is contained in:
ACX
2026-02-15 00:24:25 -07:00
committed by GitHub
parent c7f0a910e0
commit c9551ef4ab
61 changed files with 1022 additions and 87 deletions

View File

@@ -26,6 +26,8 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Slf4j
@AllArgsConstructor
@@ -39,6 +41,7 @@ public class AuthenticationService {
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
private final DefaultSettingInitializer defaultSettingInitializer;
private final AuditService auditService;
public BookLoreUser getAuthenticatedUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -87,9 +90,13 @@ public class AuthenticationService {
}
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername()));
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for unknown user: " + loginRequest.getUsername());
return ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername());
});
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) {
auditService.log(AuditAction.LOGIN_FAILED, "Login failed for user: " + loginRequest.getUsername());
throw ApiError.INVALID_CREDENTIALS.createException();
}
@@ -125,6 +132,7 @@ public class AuthenticationService {
.build();
refreshTokenRepository.save(refreshTokenEntity);
auditService.log(AuditAction.LOGIN_SUCCESS, "User", user.getId(), "Login successful for user: " + user.getUsername());
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,

View File

@@ -0,0 +1,57 @@
package org.booklore.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.booklore.model.dto.response.AuditLogDto;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@AllArgsConstructor
@RestController
@RequestMapping("/api/v1/audit-logs")
@Tag(name = "Audit Logs", description = "Endpoints for viewing audit logs")
public class AuditLogController {
private final AuditService auditService;
@Operation(summary = "Get audit logs", description = "Retrieve paginated audit logs with optional filters. Requires admin.")
@ApiResponse(responseCode = "200", description = "Audit logs returned successfully")
@GetMapping
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<Page<AuditLogDto>> getAuditLogs(
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "25") int size,
@Parameter(description = "Filter by action") @RequestParam(required = false) AuditAction action,
@Parameter(description = "Filter by user ID") @RequestParam(required = false) Long userId,
@Parameter(description = "Filter by username") @RequestParam(required = false) String username,
@Parameter(description = "Filter from date") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@Parameter(description = "Filter to date") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
Page<AuditLogDto> logs;
if (action == null && userId == null && username == null && from == null && to == null) {
logs = auditService.getAuditLogs(PageRequest.of(page, size));
} else {
logs = auditService.getAuditLogs(action, userId, username, from, to, PageRequest.of(page, size));
}
return ResponseEntity.ok(logs);
}
@Operation(summary = "Get distinct usernames", description = "Retrieve distinct usernames from audit logs for filtering.")
@GetMapping("/usernames")
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<List<String>> getDistinctUsernames() {
return ResponseEntity.ok(auditService.getDistinctUsernames());
}
}

View File

@@ -28,6 +28,8 @@ import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.List;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@RestController
@RequestMapping("/api/v1/books")
@@ -41,6 +43,7 @@ public class MetadataController {
private final MetadataMatchService metadataMatchService;
private final BookRepository bookRepository;
private final MetadataManagementService metadataManagementService;
private final AuditService auditService;
@Operation(summary = "Get prospective metadata for a book", description = "Fetch prospective metadata for a book by its ID. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "200", description = "Prospective metadata returned successfully")
@@ -78,6 +81,7 @@ public class MetadataController {
bookMetadataUpdater.setBookMetadata(context);
bookRepository.save(bookEntity);
auditService.log(AuditAction.METADATA_UPDATED, "Book", bookId, "Updated metadata for book: " + bookEntity.getMetadata().getTitle());
BookMetadata bookMetadata = bookMetadataMapper.toBookMetadata(bookEntity.getMetadata(), true);
return ResponseEntity.ok(bookMetadata);
}

View File

@@ -0,0 +1,25 @@
package org.booklore.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.booklore.model.enums.AuditAction;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuditLogDto {
private Long id;
private Long userId;
private String username;
private AuditAction action;
private String entityType;
private Long entityId;
private String description;
private String ipAddress;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,53 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.booklore.model.enums.AuditAction;
import java.time.LocalDateTime;
@Entity
@Table(name = "audit_log")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuditLogEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id")
private Long userId;
@Column(name = "username", nullable = false)
private String username;
@Enumerated(EnumType.STRING)
@Column(name = "action", nullable = false, length = 100)
private AuditAction action;
@Column(name = "entity_type", length = 100)
private String entityType;
@Column(name = "entity_id")
private Long entityId;
@Column(name = "description", nullable = false, length = 1024)
private String description;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,35 @@
package org.booklore.model.enums;
public enum AuditAction {
LOGIN_SUCCESS,
LOGIN_FAILED,
USER_CREATED,
USER_UPDATED,
USER_DELETED,
PASSWORD_CHANGED,
PERMISSIONS_CHANGED,
LIBRARY_CREATED,
LIBRARY_UPDATED,
LIBRARY_DELETED,
LIBRARY_SCANNED,
BOOK_UPLOADED,
BOOK_DELETED,
BOOK_SENT,
METADATA_UPDATED,
SETTINGS_UPDATED,
OIDC_CONFIG_CHANGED,
TASK_EXECUTED,
SHELF_CREATED,
SHELF_UPDATED,
SHELF_DELETED,
MAGIC_SHELF_CREATED,
MAGIC_SHELF_UPDATED,
MAGIC_SHELF_DELETED,
EMAIL_PROVIDER_CREATED,
EMAIL_PROVIDER_UPDATED,
EMAIL_PROVIDER_DELETED,
OPDS_USER_CREATED,
OPDS_USER_DELETED,
OPDS_USER_UPDATED,
NAMING_PATTERN_CHANGED
}

View File

@@ -0,0 +1,39 @@
package org.booklore.repository;
import org.booklore.model.entity.AuditLogEntity;
import org.booklore.model.enums.AuditAction;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface AuditLogRepository extends JpaRepository<AuditLogEntity, Long> {
Page<AuditLogEntity> findAllByOrderByCreatedAtDesc(Pageable pageable);
@Query("""
SELECT a FROM AuditLogEntity a
WHERE (:action IS NULL OR a.action = :action)
AND (:userId IS NULL OR a.userId = :userId)
AND (:username IS NULL OR a.username = :username)
AND (:from IS NULL OR a.createdAt >= :from)
AND (:to IS NULL OR a.createdAt <= :to)
ORDER BY a.createdAt DESC
""")
Page<AuditLogEntity> findFiltered(
@Param("action") AuditAction action,
@Param("userId") Long userId,
@Param("username") String username,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
@Query("SELECT DISTINCT a.username FROM AuditLogEntity a ORDER BY a.username")
List<String> findDistinctUsernames();
}

View File

@@ -1,5 +1,6 @@
package org.booklore.repository;
import org.booklore.model.entity.AuthorEntity;
import org.booklore.model.entity.BookEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -158,7 +159,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
WHERE (b.deleted IS NULL OR b.deleted = false)
ORDER BY a.name
""")
List<org.booklore.model.entity.AuthorEntity> findDistinctAuthors();
List<AuthorEntity> findDistinctAuthors();
@Query("""
SELECT DISTINCT a FROM AuthorEntity a
@@ -168,7 +169,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
AND b.library.id IN :libraryIds
ORDER BY a.name
""")
List<org.booklore.model.entity.AuthorEntity> findDistinctAuthorsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
List<AuthorEntity> findDistinctAuthorsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
// ============================================
// BOOKS BY AUTHOR - Two Query Pattern

View File

@@ -5,6 +5,8 @@ import org.booklore.model.dto.MagicShelf;
import org.booklore.model.entity.MagicShelfEntity;
import org.booklore.repository.MagicShelfRepository;
import lombok.AllArgsConstructor;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -17,6 +19,7 @@ public class MagicShelfService {
private final MagicShelfRepository magicShelfRepository;
private final AuthenticationService authenticationService;
private final AuditService auditService;
public List<MagicShelf> getUserShelves() {
Long userId = authenticationService.getAuthenticatedUser().getId();
@@ -59,12 +62,16 @@ public class MagicShelfService {
existing.setIconType(dto.getIconType());
existing.setFilterJson(dto.getFilterJson());
existing.setPublic(dto.getIsPublic());
return toDto(magicShelfRepository.save(existing));
MagicShelf result = toDto(magicShelfRepository.save(existing));
auditService.log(AuditAction.MAGIC_SHELF_UPDATED, "MagicShelf", dto.getId(), "Updated magic shelf: " + dto.getName());
return result;
}
if (magicShelfRepository.existsByUserIdAndName(userId, dto.getName())) {
throw new IllegalArgumentException("A shelf with the same name already exists for this user.");
}
return toDto(magicShelfRepository.save(toEntity(dto, userId)));
MagicShelf result = toDto(magicShelfRepository.save(toEntity(dto, userId)));
auditService.log(AuditAction.MAGIC_SHELF_CREATED, "MagicShelf", result.getId(), "Created magic shelf: " + dto.getName());
return result;
}
@Transactional
@@ -74,7 +81,9 @@ public class MagicShelfService {
if (!shelf.getUserId().equals(userId)) {
throw new SecurityException("You are not authorized to delete this shelf");
}
String shelfName = shelf.getName();
magicShelfRepository.deleteById(id);
auditService.log(AuditAction.MAGIC_SHELF_DELETED, "MagicShelf", id, "Deleted magic shelf: " + shelfName);
}
private MagicShelf toDto(MagicShelfEntity entity) {

View File

@@ -15,6 +15,8 @@ import org.booklore.repository.BookRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.repository.UserRepository;
import lombok.AllArgsConstructor;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@@ -31,6 +33,7 @@ public class ShelfService {
private final BookMapper bookMapper;
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final AuditService auditService;
public Shelf createShelf(ShelfCreateRequest request) {
Long userId = getAuthenticatedUserId();
@@ -47,7 +50,9 @@ public class ShelfService {
.isPublic(request.isPublicShelf())
.user(fetchUserEntityById(userId))
.build();
return shelfMapper.toShelf(shelfRepository.save(shelfEntity));
Shelf result = shelfMapper.toShelf(shelfRepository.save(shelfEntity));
auditService.log(AuditAction.SHELF_CREATED, "Shelf", shelfEntity.getId(), "Created shelf: " + request.getName());
return result;
}
public Shelf updateShelf(Long id, ShelfCreateRequest request) {
@@ -59,7 +64,9 @@ public class ShelfService {
shelfEntity.setIcon(request.getIcon());
shelfEntity.setIconType(request.getIconType());
shelfEntity.setPublic(request.isPublicShelf());
return shelfMapper.toShelf(shelfRepository.save(shelfEntity));
Shelf result = shelfMapper.toShelf(shelfRepository.save(shelfEntity));
auditService.log(AuditAction.SHELF_UPDATED, "Shelf", id, "Updated shelf: " + request.getName());
return result;
}
public List<Shelf> getShelves() {
@@ -75,6 +82,7 @@ public class ShelfService {
public void deleteShelf(Long shelfId) {
shelfRepository.deleteById(shelfId);
auditService.log(AuditAction.SHELF_DELETED, "Shelf", shelfId, "Deleted shelf: " + shelfId);
}
public Shelf getUserKoboShelf() {

View File

@@ -20,6 +20,8 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@DependsOnDatabaseInitialization
@@ -28,14 +30,16 @@ public class AppSettingService {
private final AppProperties appProperties;
private final SettingPersistenceHelper settingPersistenceHelper;
private final AuthenticationService authenticationService;
private final AuditService auditService;
private volatile AppSettings appSettings;
private final ReentrantLock lock = new ReentrantLock();
public AppSettingService(AppProperties appProperties, SettingPersistenceHelper settingPersistenceHelper, @Lazy AuthenticationService authenticationService) {
public AppSettingService(AppProperties appProperties, SettingPersistenceHelper settingPersistenceHelper, @Lazy AuthenticationService authenticationService, @Lazy AuditService auditService) {
this.appProperties = appProperties;
this.settingPersistenceHelper = settingPersistenceHelper;
this.authenticationService = authenticationService;
this.auditService = auditService;
}
public AppSettings getAppSettings() {
@@ -66,6 +70,8 @@ public class AppSettingService {
setting.setVal(settingPersistenceHelper.serializeSettingValue(key, val));
settingPersistenceHelper.appSettingsRepository.save(setting);
refreshCache();
AuditAction action = key.name().startsWith("OIDC_") ? AuditAction.OIDC_CONFIG_CHANGED : AuditAction.SETTINGS_UPDATED;
auditService.log(action, "Updated setting: " + key);
}
private void validatePermission(AppSettingKey key, BookLoreUser user) {

View File

@@ -0,0 +1,92 @@
package org.booklore.service.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.response.AuditLogDto;
import org.booklore.model.entity.AuditLogEntity;
import org.booklore.model.enums.AuditAction;
import org.booklore.repository.AuditLogRepository;
import org.booklore.util.RequestUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogRepository auditLogRepository;
public void log(AuditAction action, String description) {
log(action, null, null, description);
}
public void log(AuditAction action, String entityType, Long entityId, String description) {
try {
Long userId = null;
String username = "system";
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof BookLoreUser user) {
userId = user.getId();
username = user.getUsername();
}
String ipAddress = null;
try {
ipAddress = RequestUtils.getCurrentRequest().getRemoteAddr();
} catch (Exception ignored) {
// Non-HTTP context (scheduled tasks, etc.)
}
AuditLogEntity entity = AuditLogEntity.builder()
.userId(userId)
.username(username)
.action(action)
.entityType(entityType)
.entityId(entityId)
.description(description)
.ipAddress(ipAddress)
.build();
auditLogRepository.save(entity);
} catch (Exception e) {
log.warn("Failed to write audit log: action={}, description={}", action, description, e);
}
}
public Page<AuditLogDto> getAuditLogs(Pageable pageable) {
return auditLogRepository.findAllByOrderByCreatedAtDesc(pageable)
.map(this::toDto);
}
public Page<AuditLogDto> getAuditLogs(AuditAction action, Long userId, String username, LocalDateTime from, LocalDateTime to, Pageable pageable) {
return auditLogRepository.findFiltered(action, userId, username, from, to, pageable)
.map(this::toDto);
}
public List<String> getDistinctUsernames() {
return auditLogRepository.findDistinctUsernames();
}
private AuditLogDto toDto(AuditLogEntity entity) {
return AuditLogDto.builder()
.id(entity.getId())
.userId(entity.getUserId())
.username(entity.getUsername())
.action(entity.getAction())
.entityType(entity.getEntityType())
.entityId(entity.getEntityId())
.description(entity.getDescription())
.ipAddress(entity.getIpAddress())
.createdAt(entity.getCreatedAt())
.build();
}
}

View File

@@ -46,6 +46,8 @@ import java.sql.Timestamp;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Slf4j
@AllArgsConstructor
@@ -69,6 +71,7 @@ public class BookService {
private final EbookViewerPreferenceRepository ebookViewerPreferencesRepository;
private final SidecarMetadataWriter sidecarMetadataWriter;
private final FileStreamingService fileStreamingService;
private final AuditService auditService;
public List<Book> getBookDTOs(boolean includeDescription) {
@@ -513,6 +516,7 @@ public class BookService {
}
bookRepository.deleteAll(books);
auditService.log(AuditAction.BOOK_DELETED, "Deleted " + ids.size() + " book(s)");
BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions);
return failedFileDeletions.isEmpty()
? ResponseEntity.ok(response)

View File

@@ -13,6 +13,8 @@ import org.booklore.repository.UserEmailProviderPreferenceRepository;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -27,6 +29,7 @@ public class EmailProviderV2Service {
private final UserEmailProviderPreferenceRepository preferenceRepository;
private final EmailProviderV2Mapper mapper;
private final AuthenticationService authService;
private final AuditService auditService;
public List<EmailProviderV2> getEmailProviders() {
BookLoreUser user = authService.getAuthenticatedUser();
@@ -64,6 +67,7 @@ public class EmailProviderV2Service {
}
Long defaultProviderId = getDefaultProviderIdForUser(user.getId());
auditService.log(AuditAction.EMAIL_PROVIDER_CREATED, "EmailProvider", savedEntity.getId(), "Created email provider: " + savedEntity.getHost() + ":" + savedEntity.getPort());
return mapper.toDTO(savedEntity, defaultProviderId);
}
@@ -78,6 +82,7 @@ public class EmailProviderV2Service {
existingProvider.setShared(request.isShared());
}
EmailProviderV2Entity updatedEntity = repository.save(existingProvider);
auditService.log(AuditAction.EMAIL_PROVIDER_UPDATED, "EmailProvider", id, "Updated email provider: " + updatedEntity.getHost() + ":" + updatedEntity.getPort());
Long defaultProviderId = getDefaultProviderIdForUser(user.getId());
return mapper.toDTO(updatedEntity, defaultProviderId);
@@ -118,6 +123,7 @@ public class EmailProviderV2Service {
}
repository.deleteById(id);
auditService.log(AuditAction.EMAIL_PROVIDER_DELETED, "EmailProvider", id, "Deleted email provider");
}
private Long getDefaultProviderIdForUser(Long userId) {

View File

@@ -23,6 +23,8 @@ import jakarta.mail.internet.MimeMessage;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@@ -41,6 +43,7 @@ public class SendEmailV2Service {
private final EmailRecipientV2Repository emailRecipientRepository;
private final NotificationService notificationService;
private final AuthenticationService authenticationService;
private final AuditService auditService;
public void emailBookQuick(Long bookId) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
@@ -72,6 +75,7 @@ public class SendEmailV2Service {
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
sendEmail(emailProvider, recipientEmail, book, bookFile);
auditService.log(AuditAction.BOOK_SENT, "Book", book.getId(), "Sent book: " + bookTitle + " to " + recipientEmail);
String successMessage = "The book: " + bookTitle + " has been successfully sent to " + recipientEmail;
notificationService.sendMessage(Topic.LOG, LogNotification.info(successMessage));
log.info(successMessage);

View File

@@ -16,6 +16,7 @@ import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
import org.booklore.model.enums.AuditAction;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.websocket.Topic;
import org.booklore.repository.BookRepository;
@@ -42,6 +43,7 @@ import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.booklore.service.audit.AuditService;
@Slf4j
@Service
@@ -71,6 +73,7 @@ public class LibraryService {
private final MonitoringService monitoringService;
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final AuditService auditService;
@Transactional
@EventListener(ApplicationReadyEvent.class)
@@ -158,6 +161,7 @@ public class LibraryService {
});
}
auditService.log(AuditAction.LIBRARY_UPDATED, "Library", libraryId, "Updated library: " + library.getName());
return libraryMapper.toLibrary(savedLibrary);
}
@@ -208,11 +212,13 @@ public class LibraryService {
log.info("Parsing task completed!");
});
auditService.log(AuditAction.LIBRARY_CREATED, "Library", libraryEntity.getId(), "Created library: " + libraryEntity.getName());
return libraryMapper.toLibrary(libraryEntity);
}
public void rescanLibrary(long libraryId) {
libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
LibraryEntity lib = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
auditService.log(AuditAction.LIBRARY_SCANNED, "Library", libraryId, "Scanned library: " + lib.getName());
SecurityContextVirtualThread.runWithSecurityContext(() -> {
if (!scanningLibraries.add(libraryId)) {
@@ -265,7 +271,9 @@ public class LibraryService {
monitoringService.unregisterLibrary(id);
Set<Long> bookIds = library.getBookEntities().stream().map(BookEntity::getId).collect(Collectors.toSet());
fileService.deleteBookCovers(bookIds);
String libraryName = library.getName();
libraryRepository.deleteById(id);
auditService.log(AuditAction.LIBRARY_DELETED, "Library", id, "Deleted library: " + libraryName);
log.info("Library deleted successfully: {}", id);
}
@@ -284,7 +292,9 @@ public class LibraryService {
public Library setFileNamingPattern(long libraryId, String pattern) {
LibraryEntity library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
library.setFileNamingPattern(pattern);
return libraryMapper.toLibrary(libraryRepository.save(library));
Library result = libraryMapper.toLibrary(libraryRepository.save(library));
auditService.log(AuditAction.NAMING_PATTERN_CHANGED, "Library", libraryId, "Changed naming pattern for library: " + library.getName() + " to: " + pattern);
return result;
}
public Map<String, Long> getBookCountsByFormat(long libraryId) {

View File

@@ -367,7 +367,7 @@ public class EpubMetadataWriter implements MetadataWriter {
return;
}
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(true);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
Document opfDoc = builder.parse(opfFile);
applyCoverImageToEpub(tempDir, opfDoc, coverData);
@@ -486,7 +486,7 @@ public class EpubMetadataWriter implements MetadataWriter {
throw new IOException("container.xml not found at expected location: " + containerXml);
}
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(false);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false);
Document containerDoc = builder.parse(containerXml.toFile());
Node rootfile = containerDoc.getElementsByTagName("rootfile").item(0);
if (rootfile == null) {

View File

@@ -15,6 +15,7 @@ import org.booklore.model.dto.settings.MetadataPersistenceSettings;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.service.appsettings.AppSettingService;
import org.booklore.util.SecureXmlUtils;
import org.booklore.service.metadata.BookLoreMetadata;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
@@ -247,7 +248,7 @@ public class PdfMetadataWriter implements MetadataWriter {
* - Booklore (booklore:) for series, subtitle, ISBNs, external IDs, ratings, moods, tags, page count
*/
private byte[] addCustomIdentifiersToXmp(byte[] xmpBytes, BookMetadataEntity metadata, MetadataCopyHelper helper, MetadataClearFlags clear) throws Exception {
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(true);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
Document doc = builder.parse(new ByteArrayInputStream(xmpBytes));
Element rdfRoot = (Element) doc.getElementsByTagNameNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "RDF").item(0);
@@ -442,7 +443,7 @@ public class PdfMetadataWriter implements MetadataWriter {
private boolean isXmpMetadataDifferent(byte[] existingBytes, byte[] newBytes) {
if (existingBytes == null || newBytes == null) return true;
try {
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(false);
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false);
Document doc1 = builder.parse(new ByteArrayInputStream(existingBytes));
Document doc2 = builder.parse(new ByteArrayInputStream(newBytes));
return !Objects.equals(

View File

@@ -4,6 +4,7 @@ import org.booklore.exception.ApiError;
import org.booklore.mapper.BookMapper;
import org.booklore.mapper.custom.BookLoreUserTransformer;
import org.booklore.model.dto.*;
import org.booklore.model.entity.AuthorEntity;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.ShelfEntity;
@@ -171,7 +172,7 @@ public class OpdsBookService {
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId));
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
List<org.booklore.model.entity.AuthorEntity> authors;
List<AuthorEntity> authors;
if (user.getPermissions().isAdmin()) {
authors = bookOpdsRepository.findDistinctAuthors();
@@ -183,7 +184,7 @@ public class OpdsBookService {
}
return authors.stream()
.map(org.booklore.model.entity.AuthorEntity::getName)
.map(AuthorEntity::getName)
.filter(Objects::nonNull)
.distinct()
.sorted()

View File

@@ -8,9 +8,12 @@ import org.booklore.model.dto.request.OpdsUserV2CreateRequest;
import org.booklore.model.dto.request.OpdsUserV2UpdateRequest;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.OpdsUserV2Entity;
import org.booklore.model.enums.OpdsSortOrder;
import org.booklore.repository.OpdsUserV2Repository;
import org.booklore.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -28,6 +31,7 @@ public class OpdsUserV2Service {
private final UserRepository userRepository;
private final OpdsUserV2Mapper mapper;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
public List<OpdsUserV2> getOpdsUsers() {
@@ -46,10 +50,12 @@ public class OpdsUserV2Service {
.user(userEntity)
.username(request.getUsername())
.passwordHash(passwordEncoder.encode(request.getPassword()))
.sortOrder(request.getSortOrder() != null ? request.getSortOrder() : org.booklore.model.enums.OpdsSortOrder.RECENT)
.sortOrder(request.getSortOrder() != null ? request.getSortOrder() : OpdsSortOrder.RECENT)
.build();
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
OpdsUserV2 result = mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
auditService.log(AuditAction.OPDS_USER_CREATED, "OpdsUser", result.getId(), "Created OPDS user: " + request.getUsername());
return result;
} catch (DataIntegrityViolationException e) {
if (e.getMostSpecificCause().getMessage().contains("uq_username")) {
throw new DataIntegrityViolationException("Username '" + request.getUsername() + "' is already taken");
@@ -64,7 +70,9 @@ public class OpdsUserV2Service {
if (!user.getUser().getId().equals(bookLoreUser.getId())) {
throw new AccessDeniedException("You are not allowed to delete this user");
}
String username = user.getUsername();
opdsUserV2Repository.delete(user);
auditService.log(AuditAction.OPDS_USER_DELETED, "OpdsUser", userId, "Deleted OPDS user: " + username);
}
public OpdsUserV2 updateOpdsUser(Long userId, OpdsUserV2UpdateRequest request) {
@@ -77,7 +85,9 @@ public class OpdsUserV2Service {
}
user.setSortOrder(request.sortOrder());
return mapper.toDto(opdsUserV2Repository.save(user));
OpdsUserV2 result = mapper.toDto(opdsUserV2Repository.save(user));
auditService.log(AuditAction.OPDS_USER_UPDATED, "OpdsUser", userId, "Updated OPDS user: " + user.getUsername());
return result;
}
public OpdsUserV2Entity findByUsername(String username) {

View File

@@ -15,6 +15,8 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@RequiredArgsConstructor
@@ -22,6 +24,7 @@ import java.util.stream.Collectors;
public class TaskHistoryService {
private final TaskHistoryRepository taskHistoryRepository;
private final AuditService auditService;
@Transactional
public void createTask(String taskId, TaskType type, Long userId, Map<String, Object> options) {
@@ -35,6 +38,23 @@ public class TaskHistoryService {
.taskOptions(options)
.build();
taskHistoryRepository.save(task);
auditService.log(AuditAction.TASK_EXECUTED, "Task", null, buildTaskDescription(type, options));
}
private String buildTaskDescription(TaskType type, Map<String, Object> options) {
String taskName = type != null ? type.getName() : "Unknown";
StringBuilder sb = new StringBuilder("Started task: ").append(taskName);
if (options == null || options.isEmpty()) {
return sb.toString();
}
Object bookIds = options.get("bookIds");
Object libraryId = options.get("libraryId");
if (bookIds instanceof Collection<?> ids && !ids.isEmpty()) {
sb.append(" (Book IDs: ").append(ids.stream().map(Object::toString).collect(Collectors.joining(", "))).append(")");
} else if (libraryId != null) {
sb.append(" (Library ID: ").append(libraryId).append(")");
}
return sb.toString();
}
@Transactional

View File

@@ -34,6 +34,8 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Optional;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@RequiredArgsConstructor
@Service
@@ -54,6 +56,7 @@ public class FileUploadService {
private final AdditionalFileMapper additionalFileMapper;
private final FileMovingHelper fileMovingHelper;
private final MonitoringRegistrationService monitoringRegistrationService;
private final AuditService auditService;
public void uploadFile(MultipartFile file, long libraryId, long pathId) {
validateFile(file);
@@ -78,6 +81,7 @@ public class FileUploadService {
moveFileToFinalLocation(tempPath, finalPath);
log.info("File uploaded to final location: {}", finalPath);
auditService.log(AuditAction.BOOK_UPLOADED, "Library", libraryId, "Uploaded file: " + originalFileName);
} catch (IOException e) {
log.error("Failed to upload file: {}", originalFileName, e);

View File

@@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Pattern;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Slf4j
@Service
@@ -31,6 +33,7 @@ public class UserProvisioningService {
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
private final UserDefaultsService userDefaultsService;
private final AppSettingService appSettingService;
private final AuditService auditService;
public boolean isInitialUserAlreadyProvisioned() {
return userRepository.count() > 0;
@@ -264,6 +267,7 @@ public class UserProvisioningService {
BookLoreUserEntity save = userRepository.save(user);
userDefaultsService.addDefaultShelves(save);
userDefaultsService.addDefaultSettings(save);
auditService.log(AuditAction.USER_CREATED, "User", save.getId(), "Created user: " + save.getUsername());
return save;
}
}

View File

@@ -22,6 +22,8 @@ import tools.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.stream.Collectors;
import org.booklore.model.enums.AuditAction;
import org.booklore.service.audit.AuditService;
@Service
@RequiredArgsConstructor
@@ -33,6 +35,7 @@ public class UserService {
private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
private final BookLoreUserTransformer bookLoreUserTransformer;
private final AuditService auditService;
public List<BookLoreUser> getBookLoreUsers() {
return userRepository.findAll()
@@ -48,6 +51,7 @@ public class UserService {
if (updateRequest.getPermissions() != null && getMyself().getPermissions().isAdmin()) {
UserPermission.copyFromRequestToEntity(updateRequest.getPermissions(), user.getPermissions());
auditService.log(AuditAction.PERMISSIONS_CHANGED, "User", id, "Changed permissions for user: " + user.getUsername());
}
if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) {
@@ -57,6 +61,7 @@ public class UserService {
}
userRepository.save(user);
auditService.log(AuditAction.USER_UPDATED, "User", id, "Updated user: " + user.getUsername());
return bookLoreUserTransformer.toDTO(user);
}
@@ -71,6 +76,7 @@ public class UserService {
throw ApiError.SELF_DELETION_NOT_ALLOWED.createException();
}
userRepository.delete(userToDelete);
auditService.log(AuditAction.USER_DELETED, "User", id, "Deleted user: " + userToDelete.getUsername());
}
public BookLoreUser getBookLoreUser(Long id) {
@@ -107,6 +113,7 @@ public class UserService {
bookLoreUserEntity.setDefaultPassword(false);
bookLoreUserEntity.setPasswordHash(passwordEncoder.encode(changePasswordRequest.getNewPassword()));
userRepository.save(bookLoreUserEntity);
auditService.log(AuditAction.PASSWORD_CHANGED, "User", bookLoreUser.getId(), "Password changed by user: " + bookLoreUser.getUsername());
}
public void changeUserPassword(ChangeUserPasswordRequest request) {
@@ -116,6 +123,7 @@ public class UserService {
}
userEntity.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(userEntity);
auditService.log(AuditAction.PASSWORD_CHANGED, "User", request.getUserId(), "Password changed for user: " + userEntity.getUsername());
}
public void updateUserSetting(Long userId, UpdateUserSettingRequest request) {

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS audit_log
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NULL,
username VARCHAR(255) NOT NULL,
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(100) NULL,
entity_id BIGINT NULL,
description VARCHAR(1024) NOT NULL,
ip_address VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log (created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action);

View File

@@ -11,6 +11,7 @@ import org.booklore.service.progress.ReadingProgressService;
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import org.booklore.service.FileStreamingService;
import org.booklore.service.audit.AuditService;
import org.booklore.util.FileService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -55,6 +56,7 @@ class BookServiceDeleteTests {
BookUpdateService bookUpdateService = Mockito.mock(BookUpdateService.class);
SidecarMetadataWriter sidecarMetadataWriter = Mockito.mock(SidecarMetadataWriter.class);
FileStreamingService fileStreamingService = Mockito.mock(FileStreamingService.class);
AuditService auditService = Mockito.mock(AuditService.class);
bookService = new BookService(
bookRepository,
@@ -73,7 +75,8 @@ class BookServiceDeleteTests {
bookUpdateService,
ebookViewerPreferenceRepository,
sidecarMetadataWriter,
fileStreamingService
fileStreamingService,
auditService
);
}

View File

@@ -8,6 +8,7 @@ import org.booklore.model.dto.BookMetadata;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.repository.BookRepository;
import org.booklore.service.audit.AuditService;
import org.booklore.service.metadata.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,6 +39,8 @@ class MetadataControllerTest {
private BookRepository bookRepository;
@Mock
private MetadataManagementService metadataManagementService;
@Mock
private AuditService auditService;
@InjectMocks
private MetadataController metadataController;

View File

@@ -2,10 +2,12 @@ package org.booklore.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.booklore.BookloreApplication;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
import org.booklore.service.task.TaskCronService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -19,11 +21,12 @@ import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@SpringBootTest(classes = {
org.booklore.BookloreApplication.class
BookloreApplication.class
})
@Transactional
@TestPropertySource(properties = {
@@ -57,13 +60,13 @@ class BookOpdsRepositoryDataJpaTest {
@Bean("flyway")
@Primary
public org.flywaydb.core.Flyway flyway() {
return org.mockito.Mockito.mock(org.flywaydb.core.Flyway.class);
return mock(org.flywaydb.core.Flyway.class);
}
@Bean
@Primary
public org.booklore.service.task.TaskCronService taskCronService() {
return org.mockito.Mockito.mock(org.booklore.service.task.TaskCronService.class);
public TaskCronService taskCronService() {
return mock(TaskCronService.class);
}
}

View File

@@ -2,10 +2,12 @@ package org.booklore.service;
import org.booklore.config.BookmarkProperties;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.APIException;
import org.booklore.mapper.BookMarkMapper;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.BookMark;
import org.booklore.model.dto.CreateBookMarkRequest;
import org.booklore.model.dto.UpdateBookMarkRequest;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.BookMarkEntity;
@@ -112,7 +114,7 @@ class BookMarkServiceTest {
when(authenticationService.getAuthenticatedUser()).thenReturn(userDto);
when(bookMarkRepository.existsByCfiAndBookIdAndUserId("new-cfi", bookId, userId)).thenReturn(true); // Duplicate exists
assertThrows(org.booklore.exception.APIException.class, () -> bookMarkService.createBookmark(request));
assertThrows(APIException.class, () -> bookMarkService.createBookmark(request));
verify(bookMarkRepository, never()).save(any());
}
@@ -153,7 +155,7 @@ class BookMarkServiceTest {
@Test
void updateBookmark_Success() {
var updateRequest = org.booklore.model.dto.UpdateBookMarkRequest.builder()
var updateRequest = UpdateBookMarkRequest.builder()
.title("Updated Title")
.color("#FF0000")
.notes("Updated notes")
@@ -174,7 +176,7 @@ class BookMarkServiceTest {
@Test
void updateBookmark_NotFound() {
var updateRequest = org.booklore.model.dto.UpdateBookMarkRequest.builder()
var updateRequest = UpdateBookMarkRequest.builder()
.title("Updated Title")
.build();

View File

@@ -2,12 +2,14 @@ package org.booklore.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.booklore.BookloreApplication;
import org.booklore.model.dto.*;
import org.booklore.model.entity.*;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.ComicCreatorRole;
import org.booklore.model.enums.ReadStatus;
import org.booklore.repository.BookRepository;
import org.booklore.service.task.TaskCronService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -30,8 +32,9 @@ import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@SpringBootTest(classes = {org.booklore.BookloreApplication.class})
@SpringBootTest(classes = {BookloreApplication.class})
@Transactional
@TestPropertySource(properties = {
"spring.flyway.enabled=false",
@@ -58,13 +61,13 @@ class BookRuleEvaluatorServiceIntegrationTest {
@Bean("flyway")
@Primary
public org.flywaydb.core.Flyway flyway() {
return org.mockito.Mockito.mock(org.flywaydb.core.Flyway.class);
return mock(org.flywaydb.core.Flyway.class);
}
@Bean
@Primary
public org.booklore.service.task.TaskCronService taskCronService() {
return org.mockito.Mockito.mock(org.booklore.service.task.TaskCronService.class);
public TaskCronService taskCronService() {
return mock(TaskCronService.class);
}
}

View File

@@ -3,6 +3,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.dto.Book;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.settings.AppSettings;
import org.booklore.model.dto.kobo.KoboBookMetadata;
import org.booklore.model.dto.kobo.KoboTag;
import org.booklore.model.dto.kobo.KoboTagWrapper;
@@ -144,8 +145,8 @@ class KoboEntitlementServiceTest {
return book;
}
private org.booklore.model.dto.settings.AppSettings createAppSettingsWithKoboSettings() {
var appSettings = new org.booklore.model.dto.settings.AppSettings();
private AppSettings createAppSettingsWithKoboSettings() {
var appSettings = new AppSettings();
KoboSettings koboSettings = KoboSettings.builder()
.convertCbxToEpub(true)
.conversionLimitInMbForCbx(50)

View File

@@ -9,6 +9,7 @@ import org.booklore.exception.APIException;
import org.booklore.model.dto.progress.KoreaderProgress;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.KoreaderUserEntity;
import org.booklore.model.entity.UserBookProgressEntity;
import org.booklore.model.enums.ReadStatus;
import org.booklore.repository.BookRepository;
@@ -73,7 +74,7 @@ class KoreaderServiceTest {
@Test
void authorizeUser_success() {
var userEntity = new org.booklore.model.entity.KoreaderUserEntity();
var userEntity = new KoreaderUserEntity();
userEntity.setPasswordMD5("MD5PWD");
when(koreaderUserRepo.findByUsername("u"))
.thenReturn(Optional.of(userEntity));
@@ -93,7 +94,7 @@ class KoreaderServiceTest {
@Test
void authorizeUser_badPassword() {
var userEntity = new org.booklore.model.entity.KoreaderUserEntity();
var userEntity = new KoreaderUserEntity();
userEntity.setPasswordMD5("OTHER");
when(koreaderUserRepo.findByUsername("u"))
.thenReturn(Optional.of(userEntity));

View File

@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.APIException;
import org.booklore.mapper.KoreaderUserMapper;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.KoreaderUser;
import org.booklore.model.entity.BookLoreUserEntity;
@@ -29,7 +31,7 @@ class KoreaderUserServiceTest {
@Mock AuthenticationService authService;
@Mock UserRepository userRepository;
@Mock KoreaderUserRepository koreaderUserRepository;
@Mock org.booklore.mapper.KoreaderUserMapper koreaderUserMapper;
@Mock KoreaderUserMapper koreaderUserMapper;
@InjectMocks
KoreaderUserService service;
@@ -100,7 +102,7 @@ class KoreaderUserServiceTest {
@Test
void upsertUser_throws_whenOwnerMissing() {
when(userRepository.findById(123L)).thenReturn(Optional.empty());
assertThrows(org.booklore.exception.APIException.class,
assertThrows(APIException.class,
() -> service.upsertUser("x", "y"));
}
@@ -114,7 +116,7 @@ class KoreaderUserServiceTest {
@Test
void getUser_throws_whenNotFound() {
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
assertThrows(org.booklore.exception.APIException.class, () -> service.getUser());
assertThrows(APIException.class, () -> service.getUser());
}
@Test
@@ -128,6 +130,6 @@ class KoreaderUserServiceTest {
@Test
void toggleSync_throws_whenEntityMissing() {
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
assertThrows(org.booklore.exception.APIException.class, () -> service.toggleSync(false));
assertThrows(APIException.class, () -> service.toggleSync(false));
}
}

View File

@@ -2,6 +2,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.MagicShelf;
import org.booklore.model.entity.MagicShelfEntity;
import org.booklore.model.enums.IconType;
@@ -28,6 +29,8 @@ class MagicShelfServiceTest {
private MagicShelfRepository magicShelfRepository;
@Mock
private AuthenticationService authenticationService;
@Mock
private AuditService auditService;
@InjectMocks
private MagicShelfService magicShelfService;

View File

@@ -7,6 +7,7 @@ import org.booklore.model.dto.*;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.entity.UserPermissionsEntity;
import org.booklore.repository.BookOpdsRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.repository.UserRepository;
@@ -123,7 +124,7 @@ class OpdsBookServiceTest {
void getBooksPage_legacyUser_delegatesToLegacyMethod() {
OpdsUserDetails details = legacyUserDetails();
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -182,7 +183,7 @@ class OpdsBookServiceTest {
when(bookOpdsRepository.findAllWithFullMetadataByIdsAndShelfIds(anyList(), anySet())).thenReturn(List.of());
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(true);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -329,7 +330,7 @@ class OpdsBookServiceTest {
void getBooksPageForV2User_throwsForbidden_whenNoPermission() {
OpdsUserV2 v2 = OpdsUserV2.builder().userId(1L).build();
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(false);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -344,7 +345,7 @@ class OpdsBookServiceTest {
void getBooksPage_withSingleShelfId_returnsShelfBooks() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -383,7 +384,7 @@ class OpdsBookServiceTest {
void getBooksPage_withMultipleShelfIds_returnsShelfBooks() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -429,7 +430,7 @@ class OpdsBookServiceTest {
void getBooksPage_withShelfIdAndQuery_searchesInShelf() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -468,7 +469,7 @@ class OpdsBookServiceTest {
void getBooksPage_withShelfId_throwsForbidden_whenNotOwner() {
OpdsUserDetails details = v2UserDetails(1L, false, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(false);
when(entity.getPermissions()).thenReturn(permissionsEntity);
@@ -498,7 +499,7 @@ class OpdsBookServiceTest {
void getBooksPage_withShelfId_allowsAdmin_evenIfNotOwner() {
OpdsUserDetails details = v2UserDetails(1L, true, Set.of(1L));
BookLoreUserEntity entity = mock(BookLoreUserEntity.class);
var permissionsEntity = mock(org.booklore.model.entity.UserPermissionsEntity.class);
var permissionsEntity = mock(UserPermissionsEntity.class);
when(permissionsEntity.isPermissionAccessOpds()).thenReturn(true);
when(permissionsEntity.isPermissionAdmin()).thenReturn(true);
when(entity.getPermissions()).thenReturn(permissionsEntity);

View File

@@ -2,6 +2,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.OpdsUserV2Mapper;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.OpdsUserV2;
import org.booklore.model.dto.request.OpdsUserV2CreateRequest;
@@ -40,6 +41,8 @@ class OpdsUserV2ServiceTest {
private OpdsUserV2Mapper mapper;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private AuditService auditService;
@InjectMocks
private OpdsUserV2Service service;

View File

@@ -2,6 +2,7 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.ShelfMapper;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.Shelf;
import org.booklore.model.dto.request.ShelfCreateRequest;
@@ -38,6 +39,8 @@ class ShelfServiceTest {
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@Mock
private AuditService auditService;
@InjectMocks
private ShelfService shelfService;

View File

@@ -2,6 +2,7 @@ package org.booklore.service.book;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.APIException;
import org.booklore.service.audit.AuditService;
import org.booklore.mapper.BookMapper;
import org.booklore.model.dto.*;
import org.booklore.model.dto.request.ReadProgressRequest;
@@ -69,6 +70,8 @@ class BookServiceTest {
private MonitoringRegistrationService monitoringRegistrationService;
@Mock
private BookUpdateService bookUpdateService;
@Mock
private AuditService auditService;
@InjectMocks
private BookService bookService;

View File

@@ -1,5 +1,6 @@
package org.booklore.service.library;
import org.booklore.model.dto.settings.LibraryFile;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.LibraryEntity;
import org.booklore.model.entity.LibraryPathEntity;
@@ -89,7 +90,7 @@ class LibraryProcessingServiceRegressionTest {
when(libraryRepository.findById(libraryId)).thenReturn(Optional.of(libraryEntity));
when(libraryFileHelper.getLibraryFiles(libraryEntity)).thenReturn(List.of(
org.booklore.model.dto.settings.LibraryFile.builder()
LibraryFile.builder()
.libraryPathEntity(pathEntity)
.fileName("other.epub")
.fileSubPath("")

View File

@@ -2,6 +2,7 @@ package org.booklore.service.library;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.LibraryMapper;
import org.booklore.service.audit.AuditService;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.Library;
import org.booklore.model.dto.LibraryPath;
@@ -60,6 +61,8 @@ class LibraryServiceIconTest {
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@Mock
private AuditService auditService;
@InjectMocks
private LibraryService libraryService;

View File

@@ -279,7 +279,7 @@ class BookCoverServiceTest {
MetadataPersistenceSettings settings = new MetadataPersistenceSettings();
settings.setSaveToOriginalFile(saveToOriginalFileObj);
settings.setConvertCbrCb7ToCbz(convertCbrCb7ToCbz);
return new org.booklore.model.dto.settings.AppSettings() {
return new AppSettings() {
@Override
public MetadataPersistenceSettings getMetadataPersistenceSettings() {
return settings;

View File

@@ -388,8 +388,8 @@ class BookMetadataUpdaterTest {
metadataEntity.setBook(bookEntity);
bookEntity.setMetadata(metadataEntity);
Set<org.booklore.model.entity.AuthorEntity> existingAuthors = new HashSet<>();
existingAuthors.add(org.booklore.model.entity.AuthorEntity.builder().name("Old Author").build());
Set<AuthorEntity> existingAuthors = new HashSet<>();
existingAuthors.add(AuthorEntity.builder().name("Old Author").build());
metadataEntity.setAuthors(existingAuthors);
BookFileEntity primaryFile = new BookFileEntity();
@@ -416,12 +416,12 @@ class BookMetadataUpdaterTest {
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.build();
when(authorRepository.findByName("New Author")).thenReturn(Optional.of(org.booklore.model.entity.AuthorEntity.builder().name("New Author").build()));
when(authorRepository.findByName("New Author")).thenReturn(Optional.of(AuthorEntity.builder().name("New Author").build()));
bookMetadataUpdater.setBookMetadata(context);
// Verify authors are replaced
Set<org.booklore.model.entity.AuthorEntity> authors = bookEntity.getMetadata().getAuthors();
Set<AuthorEntity> authors = bookEntity.getMetadata().getAuthors();
assertEquals(1, authors.size());
assertTrue(authors.stream().anyMatch(a -> a.getName().equals("New Author")));
assertFalse(authors.stream().anyMatch(a -> a.getName().equals("Old Author")));
@@ -436,8 +436,8 @@ class BookMetadataUpdaterTest {
metadataEntity.setBook(bookEntity);
bookEntity.setMetadata(metadataEntity);
Set<org.booklore.model.entity.CategoryEntity> existingCategories = new HashSet<>();
existingCategories.add(org.booklore.model.entity.CategoryEntity.builder().name("Old Category").build());
Set<CategoryEntity> existingCategories = new HashSet<>();
existingCategories.add(CategoryEntity.builder().name("Old Category").build());
metadataEntity.setCategories(existingCategories);
BookFileEntity primaryFile = new BookFileEntity();
@@ -464,12 +464,12 @@ class BookMetadataUpdaterTest {
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.build();
when(categoryRepository.findByName("New Category")).thenReturn(Optional.of(org.booklore.model.entity.CategoryEntity.builder().name("New Category").build()));
when(categoryRepository.findByName("New Category")).thenReturn(Optional.of(CategoryEntity.builder().name("New Category").build()));
bookMetadataUpdater.setBookMetadata(context);
// Verify categories are replaced
Set<org.booklore.model.entity.CategoryEntity> categories = bookEntity.getMetadata().getCategories();
Set<CategoryEntity> categories = bookEntity.getMetadata().getCategories();
assertEquals(1, categories.size());
assertTrue(categories.stream().anyMatch(c -> c.getName().equals("New Category")));
assertFalse(categories.stream().anyMatch(c -> c.getName().equals("Old Category")));

View File

@@ -8,6 +8,7 @@ import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.OpdsSortOrder;
import org.booklore.service.MagicShelfService;
import org.booklore.util.ArchiveUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Page;
@@ -480,7 +481,7 @@ class OpdsFeedServiceTest {
BookFile bookFile = BookFile.builder()
.bookType(BookFileType.CBX)
.fileName("comic.cbz")
.archiveType(org.booklore.util.ArchiveUtils.ArchiveType.UNKNOWN)
.archiveType(ArchiveUtils.ArchiveType.UNKNOWN)
.build();
String mimeType = (String) method.invoke(opdsFeedService, bookFile);
@@ -495,7 +496,7 @@ class OpdsFeedServiceTest {
BookFile bookFile = BookFile.builder()
.bookType(BookFileType.CBX)
.fileName("comic.cbr")
.archiveType(org.booklore.util.ArchiveUtils.ArchiveType.UNKNOWN)
.archiveType(ArchiveUtils.ArchiveType.UNKNOWN)
.build();
String mimeType = (String) method.invoke(opdsFeedService, bookFile);

View File

@@ -5,6 +5,7 @@ import org.booklore.model.dto.response.AudiobookInfo;
import org.booklore.model.dto.response.AudiobookTrack;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.audio.AudioHeader;
@@ -168,7 +169,7 @@ class AudioMetadataServiceTest {
.build()
));
var bookMetadata = new org.booklore.model.entity.BookMetadataEntity();
var bookMetadata = new BookMetadataEntity();
bookMetadata.setTitle("DB Book Title");
bookMetadata.setNarrator("DB Narrator");
bookEntity.setMetadata(bookMetadata);

View File

@@ -6,6 +6,7 @@ import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.booklore.exception.APIException;
import org.booklore.exception.ApiError;
import org.booklore.model.entity.BookEntity;
import org.booklore.repository.BookRepository;
@@ -296,7 +297,7 @@ class CbxReaderServiceTest {
Files.createFile(unknownPath);
Files.setLastModifiedTime(unknownPath, FileTime.fromMillis(System.currentTimeMillis()));
assertThrows(org.booklore.exception.APIException.class, () -> cbxReaderService.getAvailablePages(1L));
assertThrows(APIException.class, () -> cbxReaderService.getAvailablePages(1L));
}
}

View File

@@ -2,6 +2,7 @@ package org.booklore.service.task;
import org.booklore.repository.TaskHistoryRepository;
import org.booklore.model.entity.TaskHistoryEntity;
import org.booklore.service.audit.AuditService;
import org.booklore.task.TaskStatus;
import org.booklore.model.dto.response.TasksHistoryResponse;
import org.booklore.model.enums.TaskType;
@@ -22,6 +23,8 @@ class TaskHistoryServiceTest {
@Mock
private TaskHistoryRepository taskHistoryRepository;
@Mock
private AuditService auditService;
@InjectMocks
private TaskHistoryService taskHistoryService;

View File

@@ -9,6 +9,7 @@ import org.booklore.model.dto.response.CronConfig;
import org.booklore.model.dto.response.TaskCreateResponse;
import org.booklore.model.enums.TaskType;
import org.booklore.task.TaskCancellationManager;
import org.booklore.task.TaskStatus;
import org.booklore.task.tasks.Task;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
@@ -197,7 +198,7 @@ class TaskServiceTest {
TaskCreateResponse resp = taskService.runAsUser(req);
assertEquals(asyncType, resp.getTaskType());
assertEquals(org.booklore.task.TaskStatus.ACCEPTED, resp.getStatus());
assertEquals(TaskStatus.ACCEPTED, resp.getStatus());
}
@Test

View File

@@ -20,6 +20,7 @@ import org.booklore.service.file.FileMovingHelper;
import org.booklore.model.dto.BookMetadata;
import org.booklore.model.enums.BookFileExtension;
import org.booklore.service.metadata.extractor.MetadataExtractorFactory;
import org.booklore.service.audit.AuditService;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -76,6 +77,8 @@ class FileUploadServiceTest {
@Mock
MonitoringRegistrationService monitoringRegistrationService;
@Mock
AuditService auditService;
AppProperties appProperties;
FileUploadService service;
@@ -93,7 +96,7 @@ class FileUploadServiceTest {
service = new FileUploadService(
libraryRepository, bookRepository, bookAdditionalFileRepository,
appSettingService, appProperties, metadataExtractorFactory, additionalFileMapper, fileMovingHelper, monitoringRegistrationService
appSettingService, appProperties, metadataExtractorFactory, additionalFileMapper, fileMovingHelper, monitoringRegistrationService, auditService
);
}

View File

@@ -2,7 +2,7 @@ import {inject, Injectable} from '@angular/core';
import {first, from, Observable, of, throwError} from 'rxjs';
import {HttpClient, HttpParams} from '@angular/common/http';
import {catchError, distinctUntilChanged, filter, finalize, map, shareReplay, switchMap, tap} from 'rxjs/operators';
import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BookStatusUpdateResponse, BookSyncResponse, BookType, CreatePhysicalBookRequest, PersonalRatingUpdateResponse, ReadStatus} from '../model/book.model';
import {Book, BookDeletionResponse, BookRecommendation, BookSetting, BookStatusUpdateResponse, BookSyncResponse, BookType, CreatePhysicalBookRequest, PersonalRatingUpdateResponse, ReadStatus} from '../model/book.model';
import {BookState} from '../model/state/book-state.model';
import {API_CONFIG} from '../../../core/config/api-config';
import {MessageService} from 'primeng/api';
@@ -81,22 +81,18 @@ export class BookService {
)),
switchMap(({cachedBooks, syncTs}) => {
if (cachedBooks.length > 0 && syncTs) {
// Emit cached books immediately for instant render
this.bookStateService.updateBookState({
books: cachedBooks,
loaded: true,
error: null,
});
// Background delta sync
this.deltaSync(syncTs);
return of(cachedBooks);
}
// No cache — full fetch
return this.fetchBooksFullAndCache();
}),
catchError(error => {
// IndexedDB failed — fall back to full fetch
return this.fetchBooksFullAndCache().pipe(
catchError(fetchError => {
const curr = this.bookStateService.getCurrentBookState();
@@ -145,18 +141,15 @@ export class BookService {
const currentState = this.bookStateService.getCurrentBookState();
let books = [...(currentState.books || [])];
// Remove deleted books
if (delta.deletedIds?.length) {
const deletedSet = new Set(delta.deletedIds);
books = books.filter(b => !deletedSet.has(b.id));
this.bookCacheService.deleteMany(delta.deletedIds);
}
// Upsert changed/new books
if (delta.books?.length) {
const updatedMap = new Map(delta.books.map(b => [b.id, b]));
books = books.map(b => updatedMap.get(b.id) ?? b);
// Add new books that weren't in the existing list
const existingIds = new Set(books.map(b => b.id));
for (const book of delta.books) {
if (!existingIds.has(book.id)) {
@@ -166,7 +159,6 @@ export class BookService {
this.bookCacheService.putAll(delta.books);
}
// If total count doesn't match, do a full refresh
if (delta.totalBookCount !== books.length) {
this.refreshBooks();
return;
@@ -183,7 +175,6 @@ export class BookService {
}
}),
catchError(() => {
// Delta sync failed — fall back to full refresh
this.refreshBooks();
return of(null);
})
@@ -377,7 +368,6 @@ export class BookService {
return;
}
// Determine the book type - use explicit type if provided, otherwise use primary
const bookType: BookType | undefined = explicitBookType ?? book.primaryFile?.bookType;
const isAlternativeFormat = explicitBookType && explicitBookType !== book.primaryFile?.bookType;
@@ -416,13 +406,12 @@ export class BookService {
return;
}
// Add bookType to query params if reading an alternative format
if (isAlternativeFormat) {
queryParams['bookType'] = bookType;
}
const hasQueryParams = Object.keys(queryParams).length > 0;
this.router.navigate([`/${baseUrl}/book/${book.id}`], hasQueryParams ? { queryParams } : undefined);
this.router.navigate([`/${baseUrl}/book/${book.id}`], hasQueryParams ? {queryParams} : undefined);
this.updateLastReadTime(book.id);
}
@@ -445,10 +434,6 @@ export class BookService {
return this.bookPatchService.savePdfProgress(bookId, page, percentage, bookFileId);
}
/*saveEpubProgress(bookId: number, cfi: string, href: string, percentage: number): Observable<void> {
return this.bookPatchService.saveEpubProgress(bookId, cfi, href, percentage);
}*/
saveCbxProgress(bookId: number, page: number, percentage: number, bookFileId?: number): Observable<void> {
return this.bookPatchService.saveCbxProgress(bookId, page, percentage, bookFileId);
}
@@ -493,10 +478,6 @@ export class BookService {
this.bookSocketService.handleMultipleBookUpdates(updatedBooks);
}
handleBookMetadataUpdate(bookId: number, updatedMetadata: BookMetadata): void {
this.bookSocketService.handleBookMetadataUpdate(bookId, updatedMetadata);
}
handleMultipleBookCoverPatches(patches: { id: number; coverUpdatedOn: string }[]): void {
this.bookSocketService.handleMultipleBookCoverPatches(patches);
}

View File

@@ -0,0 +1,59 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient, HttpParams} from '@angular/common/http';
import {Observable} from 'rxjs';
import {API_CONFIG} from '../../../core/config/api-config';
export interface AuditLog {
id: number;
userId: number | null;
username: string;
action: string;
entityType: string | null;
entityId: number | null;
description: string;
ipAddress: string | null;
createdAt: string;
}
export interface PageableResponse<T> {
content: T[];
page: {
totalElements: number;
totalPages: number;
number: number;
size: number;
};
}
@Injectable({
providedIn: 'root'
})
export class AuditLogService {
private readonly http = inject(HttpClient);
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/audit-logs`;
getAuditLogs(page: number = 0, size: number = 25, action?: string, username?: string, from?: string, to?: string): Observable<PageableResponse<AuditLog>> {
let params = new HttpParams()
.set('page', page.toString())
.set('size', size.toString());
if (action) {
params = params.set('action', action);
}
if (username) {
params = params.set('username', username);
}
if (from) {
params = params.set('from', from);
}
if (to) {
params = params.set('to', to);
}
return this.http.get<PageableResponse<AuditLog>>(this.url, {params});
}
getDistinctUsernames(): Observable<string[]> {
return this.http.get<string[]>(`${this.url}/usernames`);
}
}

View File

@@ -0,0 +1,98 @@
<ng-container *transloco="let t; prefix: 'settingsAuditLogs'">
<div class="main-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-history"></i>
{{ t('title') }}
</h2>
<p class="settings-description">{{ t('description') }}</p>
</div>
<div class="settings-card">
<div class="section-header">
<h3 class="section-title">
<i class="pi pi-list"></i>
{{ t('sectionTitle') }}
</h3>
<div class="filter-controls">
<p-select
[options]="actionOptions"
[(ngModel)]="selectedAction"
optionLabel="label"
optionValue="value"
[placeholder]="t('filterPlaceholder')"
(onChange)="onFilterChange()"
class="action-filter">
</p-select>
<p-select
[options]="usernameOptions"
[(ngModel)]="selectedUsername"
optionLabel="label"
optionValue="value"
[placeholder]="t('userFilterPlaceholder')"
(onChange)="onFilterChange()"
class="user-filter">
</p-select>
<p-datepicker
[(ngModel)]="dateRange"
(ngModelChange)="onDateRangeChange()"
selectionMode="range"
[placeholder]="t('dateRangePlaceholder')"
dateFormat="yy-mm-dd"
[showIcon]="true"
[showButtonBar]="true"
class="date-range-filter">
</p-datepicker>
</div>
</div>
<div class="section-body">
<p-table
[value]="logs"
[lazy]="true"
[paginator]="true"
[rows]="rows"
[totalRecords]="totalRecords"
[loading]="loading"
[showCurrentPageReport]="true"
[currentPageReportTemplate]="t('pageReport')"
(onLazyLoad)="onLazyLoad($event)"
[rowsPerPageOptions]="[10, 25, 50]"
styleClass="p-datatable-sm p-datatable-striped">
<ng-template pTemplate="header">
<tr>
<th style="width: 175px">{{ t('columns.timestamp') }}</th>
<th style="width: 100px">{{ t('columns.user') }}</th>
<th style="width: 140px">{{ t('columns.action') }}</th>
<th>{{ t('columns.description') }}</th>
<th style="width: 130px">{{ t('columns.ip') }}</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-log>
<tr>
<td class="timestamp-cell">{{ log.createdAt | date:'medium' }}</td>
<td>{{ log.username }}</td>
<td>
<span class="action-badge" [ngClass]="getActionClass(log.action)">
{{ formatAction(log.action) }}
</span>
</td>
<td class="description-cell">{{ log.description }}</td>
<td class="ip-cell">{{ log.ipAddress || '—' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5" class="empty-message">
<i class="pi pi-inbox"></i>
<span>{{ t('empty') }}</span>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</div>
</ng-container>

View File

@@ -0,0 +1,122 @@
@use '../../../shared/styles/settings-shared' as settings;
.main-container {
@include settings.settings-page-container;
}
@include settings.settings-page-header;
.settings-title {
@include settings.settings-page-title;
margin-bottom: 0.75rem;
}
.settings-description {
@include settings.settings-page-description;
}
.settings-card {
@include settings.settings-card;
gap: 1rem;
}
.section-header {
@include settings.settings-section-header;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.section-title {
@include settings.settings-section-title;
}
.section-body {
@include settings.settings-section-body;
padding-left: 0;
}
.filter-controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
::ng-deep .action-filter {
min-width: 180px;
}
::ng-deep .user-filter {
min-width: 140px;
}
::ng-deep .date-range-filter {
min-width: 220px;
}
::ng-deep .p-datatable-table {
table-layout: fixed;
}
.action-badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
&.action-danger {
background: color-mix(in srgb, var(--p-red-500) 15%, transparent);
color: var(--p-red-400);
}
&.action-success {
background: color-mix(in srgb, var(--p-green-500) 15%, transparent);
color: var(--p-green-400);
}
&.action-info {
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
&.action-warning {
background: color-mix(in srgb, var(--p-orange-500) 15%, transparent);
color: var(--p-orange-400);
}
}
.timestamp-cell {
white-space: nowrap;
font-size: 0.875rem;
color: var(--p-text-muted-color);
}
.description-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ip-cell {
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
font-size: 0.8rem;
color: var(--p-text-muted-color);
}
::ng-deep .p-datatable.p-datatable-sm .p-datatable-tbody > tr > td.empty-message {
text-align: center;
padding: 3rem 2rem;
color: var(--p-text-muted-color);
.pi {
display: block;
font-size: 2rem;
margin-bottom: 0.5rem;
}
}

View File

@@ -0,0 +1,152 @@
import {Component, inject, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TableLazyLoadEvent, TableModule} from 'primeng/table';
import {Select} from 'primeng/select';
import {DatePicker} from 'primeng/datepicker';
import {FormsModule} from '@angular/forms';
import {TranslocoDirective} from '@jsverse/transloco';
import {AuditLog, AuditLogService} from './audit-log.service';
interface ActionOption {
label: string;
value: string;
}
interface UsernameOption {
label: string;
value: string;
}
@Component({
selector: 'app-audit-logs',
standalone: true,
imports: [CommonModule, TableModule, Select, DatePicker, FormsModule, TranslocoDirective],
templateUrl: './audit-logs.component.html',
styleUrl: './audit-logs.component.scss'
})
export class AuditLogsComponent implements OnInit {
private readonly auditLogService = inject(AuditLogService);
logs: AuditLog[] = [];
totalRecords = 0;
rows = 25;
loading = false;
selectedAction: string | null = null;
selectedUsername: string | null = null;
dateRange: Date[] | null = null;
usernameOptions: UsernameOption[] = [{label: 'All Users', value: ''}];
actionOptions: ActionOption[] = [
{label: 'All Actions', value: ''},
{label: 'Login Success', value: 'LOGIN_SUCCESS'},
{label: 'Login Failed', value: 'LOGIN_FAILED'},
{label: 'User Created', value: 'USER_CREATED'},
{label: 'User Updated', value: 'USER_UPDATED'},
{label: 'User Deleted', value: 'USER_DELETED'},
{label: 'Password Changed', value: 'PASSWORD_CHANGED'},
{label: 'Library Created', value: 'LIBRARY_CREATED'},
{label: 'Library Updated', value: 'LIBRARY_UPDATED'},
{label: 'Library Deleted', value: 'LIBRARY_DELETED'},
{label: 'Library Scanned', value: 'LIBRARY_SCANNED'},
{label: 'Book Uploaded', value: 'BOOK_UPLOADED'},
{label: 'Book Deleted', value: 'BOOK_DELETED'},
{label: 'Permissions Changed', value: 'PERMISSIONS_CHANGED'},
{label: 'Metadata Updated', value: 'METADATA_UPDATED'},
{label: 'Settings Updated', value: 'SETTINGS_UPDATED'},
{label: 'OIDC Config Changed', value: 'OIDC_CONFIG_CHANGED'},
{label: 'Task Executed', value: 'TASK_EXECUTED'},
{label: 'Book Sent', value: 'BOOK_SENT'},
{label: 'Shelf Created', value: 'SHELF_CREATED'},
{label: 'Shelf Updated', value: 'SHELF_UPDATED'},
{label: 'Shelf Deleted', value: 'SHELF_DELETED'},
{label: 'Magic Shelf Created', value: 'MAGIC_SHELF_CREATED'},
{label: 'Magic Shelf Updated', value: 'MAGIC_SHELF_UPDATED'},
{label: 'Magic Shelf Deleted', value: 'MAGIC_SHELF_DELETED'},
{label: 'Email Provider Created', value: 'EMAIL_PROVIDER_CREATED'},
{label: 'Email Provider Updated', value: 'EMAIL_PROVIDER_UPDATED'},
{label: 'Email Provider Deleted', value: 'EMAIL_PROVIDER_DELETED'},
{label: 'OPDS User Created', value: 'OPDS_USER_CREATED'},
{label: 'OPDS User Deleted', value: 'OPDS_USER_DELETED'},
{label: 'OPDS User Updated', value: 'OPDS_USER_UPDATED'},
{label: 'Naming Pattern Changed', value: 'NAMING_PATTERN_CHANGED'},
];
private currentPage = 0;
ngOnInit(): void {
this.loadUsernames();
this.loadLogs();
}
loadUsernames(): void {
this.auditLogService.getDistinctUsernames().subscribe({
next: (usernames) => {
this.usernameOptions = [
{label: 'All Users', value: ''},
...usernames.map(u => ({label: u, value: u}))
];
}
});
}
loadLogs(): void {
this.loading = true;
const action = this.selectedAction || undefined;
const username = this.selectedUsername || undefined;
const from = this.dateRange?.[0] ? this.formatDateTime(this.dateRange[0]) : undefined;
const to = this.dateRange?.[1] ? this.formatDateTime(this.dateRange[1], true) : undefined;
this.auditLogService.getAuditLogs(this.currentPage, this.rows, action, username, from, to).subscribe({
next: (response) => {
this.logs = response.content;
this.totalRecords = response.page.totalElements;
this.loading = false;
},
error: () => {
this.loading = false;
}
});
}
onLazyLoad(event: TableLazyLoadEvent): void {
this.currentPage = (event.first ?? 0) / (event.rows ?? this.rows);
this.loadLogs();
}
onFilterChange(): void {
this.currentPage = 0;
this.loadLogs();
}
onDateRangeChange(): void {
if (!this.dateRange || (this.dateRange[0] && this.dateRange[1])) {
this.onFilterChange();
}
}
formatAction(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(/\B\w+/g, c => c.toLowerCase());
}
getActionClass(action: string): string {
if (action.includes('FAILED') || action.includes('DELETED')) return 'action-danger';
if (action.includes('CREATED') || action.includes('SUCCESS') || action.includes('UPLOADED')) return 'action-success';
if (action.includes('OIDC') || action.includes('PERMISSIONS')) return 'action-warning';
return 'action-info';
}
private formatDateTime(date: Date, endOfDay = false): string {
const d = new Date(date);
if (endOfDay) {
d.setHours(23, 59, 59);
} else {
d.setHours(0, 0, 0);
}
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0') + 'T' +
String(d.getHours()).padStart(2, '0') + ':' +
String(d.getMinutes()).padStart(2, '0') + ':' +
String(d.getSeconds()).padStart(2, '0');
}
}

View File

@@ -46,6 +46,11 @@
<i class="pi pi-list-check"></i> {{ t('tasks') }}
</p-tab>
}
@if (userState.user.permissions.admin) {
<p-tab [value]="SettingsTab.AuditLogs">
<i class="pi pi-history"></i> {{ t('auditLogs') }}
</p-tab>
}
<p-tab [value]="SettingsTab.OpdsV2">
<i class="pi pi-globe"></i> {{ t('opds') }}
</p-tab>
@@ -116,6 +121,13 @@
</ng-template>
</p-tabpanel>
}
@if (userState.user.permissions.admin) {
<p-tabpanel [value]="SettingsTab.AuditLogs">
<ng-template #content>
<app-audit-logs></app-audit-logs>
</ng-template>
</p-tabpanel>
}
<p-tabpanel [value]="SettingsTab.OpdsV2">
<ng-template #content>
<app-opds-settings></app-opds-settings>

View File

@@ -11,6 +11,7 @@ import {ViewPreferencesParentComponent} from './view-preferences-parent/view-pre
import {ReaderPreferences} from './reader-preferences/reader-preferences.component';
import {FileNamingPatternComponent} from './file-naming-pattern/file-naming-pattern.component';
import {TaskManagementComponent} from './task-management/task-management.component';
import {AuditLogsComponent} from './audit-logs/audit-logs.component';
import {OpdsSettings} from './opds-settings/opds-settings';
import {MetadataSettingsComponent} from './metadata-settings/metadata-settings-component';
import {DeviceSettingsComponent} from './device-settings/device-settings-component';
@@ -32,6 +33,7 @@ export enum SettingsTab {
AuthenticationSettings = 'authentication',
OpdsV2 = 'opds',
Tasks = 'task',
AuditLogs = 'audit-logs',
}
@Component({
@@ -54,6 +56,7 @@ export enum SettingsTab {
OpdsSettings,
LibraryMetadataSettingsComponent,
TaskManagementComponent,
AuditLogsComponent,
EmailV2Component,
TranslocoDirective
],

View File

@@ -14,6 +14,7 @@ import settingsUsers from './settings-users.json';
import settingsNaming from './settings-naming.json';
import settingsOpds from './settings-opds.json';
import settingsTasks from './settings-tasks.json';
import settingsAuditLogs from './settings-audit-logs.json';
import settingsAuth from './settings-auth.json';
import settingsDevice from './settings-device.json';
import settingsProfile from './settings-profile.json';
@@ -35,5 +36,5 @@ import magicShelf from './magic-shelf.json';
// To add a new domain: create the JSON file and add it here.
// Settings tabs each get their own file: settings-email, settings-reader, settings-view, etc.
const translations: Translation = {common, auth, nav, dashboard, settings, settingsEmail, settingsReader, settingsView, settingsMeta, settingsLibMeta, settingsApp, settingsUsers, settingsNaming, settingsOpds, settingsTasks, settingsAuth, settingsDevice, settingsProfile, app, shared, layout, libraryCreator, bookdrop, metadata, notebook, book, readerAudiobook, readerCbx, readerEbook, readerPdf, statsLibrary, statsUser, magicShelf};
const translations: Translation = {common, auth, nav, dashboard, settings, settingsEmail, settingsReader, settingsView, settingsMeta, settingsLibMeta, settingsApp, settingsUsers, settingsNaming, settingsOpds, settingsTasks, settingsAuditLogs, settingsAuth, settingsDevice, settingsProfile, app, shared, layout, libraryCreator, bookdrop, metadata, notebook, book, readerAudiobook, readerCbx, readerEbook, readerPdf, statsLibrary, statsUser, magicShelf};
export default translations;

View File

@@ -0,0 +1,17 @@
{
"title": "Audit Logs",
"description": "View a record of admin-significant actions performed across the system.",
"sectionTitle": "Activity Log",
"filterPlaceholder": "Filter by action",
"userFilterPlaceholder": "Filter by user",
"dateRangePlaceholder": "Filter by date range",
"pageReport": "Showing {first} to {last} of {totalRecords} entries",
"empty": "No audit logs found",
"columns": {
"timestamp": "Date & Time",
"user": "User",
"action": "Action",
"description": "Description",
"ip": "IP Address"
}
}

View File

@@ -10,6 +10,7 @@
"patterns": "Patterns",
"authentication": "Authentication",
"tasks": "Tasks",
"auditLogs": "Audit Logs",
"opds": "OPDS",
"devices": "Devices"
}

View File

@@ -14,6 +14,7 @@ import settingsUsers from './settings-users.json';
import settingsNaming from './settings-naming.json';
import settingsOpds from './settings-opds.json';
import settingsTasks from './settings-tasks.json';
import settingsAuditLogs from './settings-audit-logs.json';
import settingsAuth from './settings-auth.json';
import settingsDevice from './settings-device.json';
import settingsProfile from './settings-profile.json';
@@ -35,5 +36,5 @@ import magicShelf from './magic-shelf.json';
// To add a new domain: create the JSON file and add it here.
// Settings tabs each get their own file: settings-email, settings-reader, settings-view, etc.
const translations: Translation = {common, auth, nav, dashboard, settings, settingsEmail, settingsReader, settingsView, settingsMeta, settingsLibMeta, settingsApp, settingsUsers, settingsNaming, settingsOpds, settingsTasks, settingsAuth, settingsDevice, settingsProfile, app, shared, layout, libraryCreator, bookdrop, metadata, notebook, book, readerAudiobook, readerCbx, readerEbook, readerPdf, statsLibrary, statsUser, magicShelf};
const translations: Translation = {common, auth, nav, dashboard, settings, settingsEmail, settingsReader, settingsView, settingsMeta, settingsLibMeta, settingsApp, settingsUsers, settingsNaming, settingsOpds, settingsTasks, settingsAuditLogs, settingsAuth, settingsDevice, settingsProfile, app, shared, layout, libraryCreator, bookdrop, metadata, notebook, book, readerAudiobook, readerCbx, readerEbook, readerPdf, statsLibrary, statsUser, magicShelf};
export default translations;

View File

@@ -0,0 +1,17 @@
{
"title": "Registro de auditoría",
"description": "Vea un registro de las acciones administrativas realizadas en el sistema.",
"sectionTitle": "Registro de actividad",
"filterPlaceholder": "Filtrar por acción",
"userFilterPlaceholder": "Filtrar por usuario",
"dateRangePlaceholder": "Filtrar por rango de fechas",
"pageReport": "Mostrando {first} a {last} de {totalRecords} entradas",
"empty": "No se encontraron registros de auditoría",
"columns": {
"timestamp": "Fecha y hora",
"user": "Usuario",
"action": "Acción",
"description": "Descripción",
"ip": "Dirección IP"
}
}

View File

@@ -10,6 +10,7 @@
"patterns": "Patrones",
"authentication": "Autenticación",
"tasks": "Tareas",
"auditLogs": "Auditoría",
"opds": "OPDS",
"devices": "Dispositivos"
}