mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: add audit log system for tracking admin-significant actions (#2759)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
booklore-ui/src/i18n/en/settings-audit-logs.json
Normal file
17
booklore-ui/src/i18n/en/settings-audit-logs.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"patterns": "Patterns",
|
||||
"authentication": "Authentication",
|
||||
"tasks": "Tasks",
|
||||
"auditLogs": "Audit Logs",
|
||||
"opds": "OPDS",
|
||||
"devices": "Devices"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
booklore-ui/src/i18n/es/settings-audit-logs.json
Normal file
17
booklore-ui/src/i18n/es/settings-audit-logs.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"patterns": "Patrones",
|
||||
"authentication": "Autenticación",
|
||||
"tasks": "Tareas",
|
||||
"auditLogs": "Auditoría",
|
||||
"opds": "OPDS",
|
||||
"devices": "Dispositivos"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user