Implement OPDS v2 to return user-specific book feeds

This commit is contained in:
aditya.chandel
2025-08-30 16:47:46 -06:00
committed by Aditya Chandel
parent 4cff107a67
commit 443dcad59a
49 changed files with 1611 additions and 67 deletions

View File

@@ -5,7 +5,7 @@ import com.adityachandel.booklore.config.security.filter.CoverJwtFilter;
import com.adityachandel.booklore.config.security.filter.DualJwtAuthenticationFilter;
import com.adityachandel.booklore.config.security.filter.KoboAuthFilter;
import com.adityachandel.booklore.config.security.filter.KoreaderAuthFilter;
import com.adityachandel.booklore.config.security.service.CustomOpdsUserDetailsService;
import com.adityachandel.booklore.config.security.service.OpdsUserDetailsService;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -37,7 +37,7 @@ import jakarta.servlet.http.HttpServletResponse;
@Configuration
public class SecurityConfig {
private final CustomOpdsUserDetailsService customOpdsUserDetailsService;
private final OpdsUserDetailsService opdsUserDetailsService;
private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter;
private final AppProperties appProperties;
@@ -157,7 +157,7 @@ public class SecurityConfig {
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(customOpdsUserDetailsService);
provider.setUserDetailsService(opdsUserDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}

View File

@@ -69,6 +69,11 @@ public class SecurityUtil {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanDeleteBook();
}
public boolean canAccessOpds() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessOpds();
}
public boolean canViewUserProfile(Long userId) {
var user = getCurrentUser();

View File

@@ -2,11 +2,14 @@ package com.adityachandel.booklore.config.security.service;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.config.security.JwtUtils;
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
import com.adityachandel.booklore.model.entity.RefreshTokenEntity;
import com.adityachandel.booklore.repository.OpdsUserRepository;
import com.adityachandel.booklore.repository.RefreshTokenRepository;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.user.DefaultSettingInitializer;
@@ -16,6 +19,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -35,6 +39,7 @@ public class AuthenticationService {
private final PasswordEncoder passwordEncoder;
private final JwtUtils jwtUtils;
private final DefaultSettingInitializer defaultSettingInitializer;
private final OpdsUserRepository opdsUserRepository;
public BookLoreUser getAuthenticatedUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -46,6 +51,15 @@ public class AuthenticationService {
throw new IllegalStateException("Authenticated principal is not of type BookLoreUser");
}
public OpdsUserDetails getOpdsUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof OpdsUserDetails opdsUser) {
return opdsUser;
}
throw new IllegalStateException("No OPDS user authenticated");
}
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername()));

View File

@@ -1,28 +0,0 @@
package com.adityachandel.booklore.config.security.service;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
import com.adityachandel.booklore.repository.OpdsUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomOpdsUserDetailsService implements UserDetailsService {
private final OpdsUserRepository opdsUserRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
OpdsUserEntity user = opdsUserRepository.findByUsername(username).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles("USER")
.build();
}
}

View File

@@ -0,0 +1,38 @@
package com.adityachandel.booklore.config.security.service;
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.mapper.OpdsUserMapper;
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
import com.adityachandel.booklore.repository.OpdsUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class OpdsUserDetailsService implements UserDetailsService {
private final OpdsUserRepository opdsUserRepository;
private final OpdsUserV2Repository opdsUserV2Repository;
private final OpdsUserMapper opdsUserMapper;
private final OpdsUserV2Mapper opdsUserV2Mapper;
@Override
public OpdsUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return opdsUserRepository.findByUsername(username)
.map(user -> {
var mappedUser = opdsUserMapper.toOpdsUser(user);
return new OpdsUserDetails(null, mappedUser);
})
.orElseGet(() -> {
var userV2 = opdsUserV2Repository.findByUsername(username)
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
var mappedCredential = opdsUserV2Mapper.toDto(userV2);
return new OpdsUserDetails(mappedCredential, null);
});
}
}

View File

@@ -0,0 +1,38 @@
package com.adityachandel.booklore.config.security.userdetails;
import com.adityachandel.booklore.model.dto.OpdsUser;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Getter
@RequiredArgsConstructor
public class OpdsUserDetails implements UserDetails {
private final OpdsUserV2 opdsUserV2;
private final OpdsUser opdsUser;
@Override
public String getUsername() {
if (opdsUserV2 != null) return opdsUserV2.getUsername();
if (opdsUser != null) return opdsUser.getUsername();
throw new IllegalStateException("No username available");
}
@Override
public String getPassword() {
if (opdsUserV2 != null) return opdsUserV2.getPasswordHash();
if (opdsUser != null) return opdsUser.getPassword();
throw new IllegalStateException("No password available");
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
}

View File

@@ -0,0 +1,37 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
import com.adityachandel.booklore.service.OpdsUserV2Service;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v2/opds-users")
@RequiredArgsConstructor
public class OpdsUserV2Controller {
private final OpdsUserV2Service service;
@GetMapping
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
public List<OpdsUserV2> getUsers() {
return service.getOpdsUsers();
}
@PostMapping
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
public OpdsUserV2 createUser(@RequestBody OpdsUserV2CreateRequest createRequest) {
return service.createOpdsUser(createRequest);
}
@DeleteMapping("/{id}")
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
public void deleteUser(@PathVariable Long id) {
service.deleteOpdsUser(id);
}
}

View File

@@ -0,0 +1,23 @@
package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
import java.util.List;
@Mapper(componentModel = "spring")
public interface OpdsUserV2Mapper {
OpdsUserV2Mapper INSTANCE = Mappers.getMapper(OpdsUserV2Mapper.class);
@Mapping(source = "user.id", target = "userId")
OpdsUserV2 toDto(OpdsUserV2Entity entity);
List<OpdsUserV2> toDto(List<OpdsUserV2Entity> entities);
@Mapping(target = "user.id", source = "userId")
OpdsUserV2Entity toEntity(OpdsUserV2 dto);
}

View File

@@ -32,6 +32,7 @@ public class BookLoreUserTransformer {
permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook());
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary());
permissions.setCanAccessOpds(userEntity.getPermissions().isPermissionAccessOpds());
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo());

View File

@@ -32,6 +32,7 @@ public class BookLoreUser {
private boolean canSyncKobo;
private boolean canEmailBook;
private boolean canDeleteBook;
private boolean canAccessOpds;
}
@Data

View File

@@ -1,10 +1,13 @@
package com.adityachandel.booklore.model.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
@Data
public class OpdsUser {
private Long id;
private String username;
@JsonIgnore
private String password;
}

View File

@@ -0,0 +1,17 @@
package com.adityachandel.booklore.model.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OpdsUserV2 {
private Long id;
private Long userId;
private String username;
@JsonIgnore
private String passwordHash;
}

View File

@@ -17,6 +17,7 @@ public class UserCreateRequest {
private boolean permissionManipulateLibrary;
private boolean permissionEmailBook;
private boolean permissionDeleteBook;
private boolean permissionAccessOpds;
private boolean permissionSyncKoreader;
private boolean permissionSyncKobo;
private boolean permissionAdmin;

View File

@@ -0,0 +1,9 @@
package com.adityachandel.booklore.model.dto.request;
import lombok.Data;
@Data
public class OpdsUserV2CreateRequest {
private String username;
private String password;
}

View File

@@ -20,6 +20,7 @@ public class UserUpdateRequest {
private boolean canManipulateLibrary;
private boolean canEmailBook;
private boolean canDeleteBook;
private boolean canAccessOpds;
private boolean canSyncKoReader;
private boolean canSyncKobo;
}

View File

@@ -0,0 +1,48 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "opds_user_v2")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OpdsUserV2Entity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private BookLoreUserEntity user;
@Column(nullable = false, length = 100)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
public void prePersist() {
Instant now = Instant.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
public void preUpdate() {
this.updatedAt = Instant.now();
}
}

View File

@@ -41,6 +41,9 @@ public class UserPermissionsEntity {
@Column(name = "permission_sync_koreader", nullable = false)
private boolean permissionSyncKoreader = false;
@Column(name = "permission_access_opds", nullable = false)
private boolean permissionAccessOpds = false;
@Column(name = "permission_sync_kobo", nullable = false)
private boolean permissionSyncKobo = false;

View File

@@ -9,5 +9,6 @@ public enum PermissionType {
DELETE_BOOK,
SYNC_KOREADER,
SYNC_KOBO,
ACCESS_OPDS,
ADMIN
}

View File

@@ -2,7 +2,6 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.BookEntity;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -76,13 +75,28 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.description) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
)
ORDER BY m.title ASC
""")
List<BookEntity> findBooksContainingMetadata(@Param("text") String text);
List<BookEntity> searchByMetadata(@Param("text") String text);
@Query("""
SELECT DISTINCT b FROM BookEntity b
LEFT JOIN FETCH b.metadata m
LEFT JOIN FETCH m.authors a
LEFT JOIN FETCH m.categories
WHERE (b.deleted IS NULL OR b.deleted = false)
AND b.library.id IN :libraryIds
AND (
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
)
ORDER BY m.title ASC
""")
List<BookEntity> searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection<Long> libraryIds);
@Modifying
@Transactional

View File

@@ -0,0 +1,14 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface OpdsUserV2Repository extends JpaRepository<OpdsUserV2Entity, Long> {
Optional<OpdsUserV2Entity> findByUsername(String username);
List<OpdsUserV2Entity> findByUserId(Long userId);
}

View File

@@ -52,8 +52,18 @@ public class BookQueryService {
return bookRepository.findAllFullBooks();
}
public List<BookEntity> getBooksContainingMetadata(String text) {
return bookRepository.findBooksContainingMetadata(text);
public List<Book> searchBooksByMetadata(String text) {
List<BookEntity> bookEntities = bookRepository.searchByMetadata(text);
return bookEntities.stream()
.map(bookMapperV2::toDTO)
.collect(Collectors.toList());
}
public List<Book> searchBooksByMetadataInLibraries(String text, Set<Long> libraryIds) {
List<BookEntity> bookEntities = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds);
return bookEntities.stream()
.map(bookMapperV2::toDTO)
.collect(Collectors.toList());
}
public void saveAll(List<BookEntity> books) {

View File

@@ -0,0 +1,56 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
import com.adityachandel.booklore.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class OpdsUserV2Service {
private final OpdsUserV2Repository opdsUserV2Repository;
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final OpdsUserV2Mapper mapper;
private final PasswordEncoder passwordEncoder;
public List<OpdsUserV2> getOpdsUsers() {
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
List<OpdsUserV2Entity> users = opdsUserV2Repository.findByUserId(bookLoreUser.getId());
return mapper.toDto(users);
}
public OpdsUserV2 createOpdsUser(OpdsUserV2CreateRequest request) {
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
BookLoreUserEntity userEntity = userRepository.findById(bookLoreUser.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + bookLoreUser.getId()));
OpdsUserV2Entity opdsUserV2 = OpdsUserV2Entity.builder()
.user(userEntity)
.username(request.getUsername())
.passwordHash(passwordEncoder.encode(request.getPassword()))
.build();
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
}
public void deleteOpdsUser(Long userId) {
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
OpdsUserV2Entity user = opdsUserV2Repository.findById(userId).orElseThrow(() -> new RuntimeException("User not found with ID: " + userId));
if (!user.getUser().getId().equals(bookLoreUser.getId())) {
throw new AccessDeniedException("You are not allowed to delete this user");
}
opdsUserV2Repository.delete(user);
}
}

View File

@@ -1,14 +1,22 @@
package com.adityachandel.booklore.service.opds;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.*;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.BookQueryService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Service
@@ -16,11 +24,13 @@ import java.util.List;
public class OpdsService {
private final BookQueryService bookQueryService;
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final BookLoreUserTransformer bookLoreUserTransformer;
public String generateCatalogFeed(HttpServletRequest request) {
var books = bookQueryService.getAllFullBookEntities();
var feedVersion = extractVersionFromAcceptHeader(request);
List<Book> books = getAllowedBooks(null);
String feedVersion = extractVersionFromAcceptHeader(request);
return switch (feedVersion) {
case "2.0" -> generateOpdsV2Feed(books);
default -> generateOpdsV1Feed(books);
@@ -28,20 +38,49 @@ public class OpdsService {
}
public String generateSearchResults(HttpServletRequest request, String queryParam) {
List<BookEntity> books = List.of();
if (queryParam != null) {
books = bookQueryService.getBooksContainingMetadata(queryParam);
} else {
books = bookQueryService.getAllFullBookEntities();
}
var feedVersion = extractVersionFromAcceptHeader(request);
List<Book> books = getAllowedBooks(queryParam);
String feedVersion = extractVersionFromAcceptHeader(request);
return switch (feedVersion) {
case "2.0" -> generateOpdsV2Feed(books);
default -> generateOpdsV1Feed(books);
};
}
private List<Book> getAllowedBooks(String queryParam) {
OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser();
OpdsUser opdsUser = opdsUserDetails.getOpdsUser();
if (opdsUser != null) {
return (queryParam != null)
? bookQueryService.searchBooksByMetadata(queryParam)
: bookQueryService.getAllBooks(true);
}
OpdsUserV2 opdsUserV2 = opdsUserDetails.getOpdsUserV2();
BookLoreUserEntity entity = userRepository.findById(opdsUserV2.getUserId())
.orElseThrow(() -> new AccessDeniedException("User not found"));
if (!entity.getPermissions().isPermissionAccessOpds() && !entity.getPermissions().isPermissionAdmin()) {
throw new AccessDeniedException("You are not allowed to access this resource");
}
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
boolean isAdmin = user.getPermissions().isAdmin();
Set<Long> libraryIds = user.getAssignedLibraries().stream()
.map(Library::getId)
.collect(Collectors.toSet());
if (isAdmin) {
return (queryParam != null)
? bookQueryService.searchBooksByMetadata(queryParam)
: bookQueryService.getAllBooks(true);
} else {
return (queryParam != null)
? bookQueryService.searchBooksByMetadataInLibraries(queryParam, libraryIds)
: bookQueryService.getAllBooksByLibraryIds(libraryIds, true);
}
}
public String generateSearchDescription(HttpServletRequest request) {
var feedVersion = extractVersionFromAcceptHeader(request);
@@ -70,7 +109,7 @@ public class OpdsService {
""";
}
private String generateOpdsV1Feed(List<BookEntity> books) {
private String generateOpdsV1Feed(List<Book> books) {
var feed = new StringBuilder("""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/terms/">
@@ -86,7 +125,7 @@ public class OpdsService {
return feed.toString();
}
private void appendBookEntryV1(StringBuilder feed, BookEntity book) {
private void appendBookEntryV1(StringBuilder feed, Book book) {
feed.append("""
<entry>
<title>%s</title>
@@ -98,13 +137,13 @@ public class OpdsService {
<author>
<name>%s</name>
</author>
""".formatted(escapeXml(author.getName()))));
""".formatted(escapeXml(author))));
appendOptionalTags(feed, book);
feed.append(" </entry>");
}
private void appendOptionalTags(StringBuilder feed, BookEntity book) {
private void appendOptionalTags(StringBuilder feed, Book book) {
if (book.getMetadata().getPublisher() != null) {
feed.append("<dc:publisher>").append(escapeXml(book.getMetadata().getPublisher())).append("</dc:publisher>");
}
@@ -115,7 +154,7 @@ public class OpdsService {
if (book.getMetadata().getCategories() != null) {
book.getMetadata().getCategories().forEach(category ->
feed.append("<dc:subject>").append(escapeXml(category.getName())).append("</dc:subject>")
feed.append("<dc:subject>").append(escapeXml(category)).append("</dc:subject>")
);
}
@@ -144,11 +183,11 @@ public class OpdsService {
}
}
private String generateOpdsV2Feed(List<BookEntity> books) {
private String generateOpdsV2Feed(List<Book> books) {
// Placeholder for OPDS v2.0 feed implementation (similar structure as v1)
return "OPDS v2.0 Feed is under construction";
}
private String generateOpdsV2SearchDescription() {
// Placeholder for OPDS v2.0 feed implementation (similar structure as v1)
return "OPDS v2.0 Feed is under construction";
@@ -159,7 +198,10 @@ public class OpdsService {
return DateTimeFormatter.ISO_INSTANT.format(java.time.Instant.now());
}
private String fileMimeType(BookEntity book) {
private String fileMimeType(Book book) {
if (book == null || book.getBookType() == null) {
return "octet-stream";
}
return switch (book.getBookType()) {
case PDF -> "pdf";
case EPUB -> "epub+zip";

View File

@@ -53,6 +53,7 @@ public class UserProvisioningService {
perms.setPermissionManipulateLibrary(true);
perms.setPermissionEmailBook(true);
perms.setPermissionDeleteBook(true);
perms.setPermissionAccessOpds(true);
perms.setPermissionSyncKoreader(true);
perms.setPermissionSyncKobo(true);
@@ -83,6 +84,7 @@ public class UserProvisioningService {
permissions.setPermissionManipulateLibrary(request.isPermissionManipulateLibrary());
permissions.setPermissionEmailBook(request.isPermissionEmailBook());
permissions.setPermissionDeleteBook(request.isPermissionDeleteBook());
permissions.setPermissionAccessOpds(request.isPermissionAccessOpds());
permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader());
permissions.setPermissionSyncKobo(request.isPermissionSyncKobo());
permissions.setPermissionAdmin(request.isPermissionAdmin());
@@ -115,6 +117,7 @@ public class UserProvisioningService {
perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
perms.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
perms.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
}
@@ -164,6 +167,7 @@ public class UserProvisioningService {
permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
permissions.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
permissions.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
} else {
@@ -172,6 +176,7 @@ public class UserProvisioningService {
permissions.setPermissionEditMetadata(false);
permissions.setPermissionManipulateLibrary(false);
permissions.setPermissionEmailBook(false);
permissions.setPermissionAccessOpds(false);
permissions.setPermissionDeleteBook(false);
permissions.setPermissionSyncKoreader(false);
permissions.setPermissionSyncKobo(false);

View File

@@ -53,6 +53,7 @@ public class UserService {
user.getPermissions().setPermissionManipulateLibrary(updateRequest.getPermissions().isCanManipulateLibrary());
user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook());
user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook());
user.getPermissions().setPermissionAccessOpds(updateRequest.getPermissions().isCanAccessOpds());
user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader());
user.getPermissions().setPermissionSyncKobo(updateRequest.getPermissions().isCanSyncKobo());
}

View File

@@ -13,6 +13,7 @@ public class UserPermissionUtils {
case MANIPULATE_LIBRARY -> perms.isPermissionManipulateLibrary();
case EMAIL_BOOK -> perms.isPermissionEmailBook();
case DELETE_BOOK -> perms.isPermissionDeleteBook();
case ACCESS_OPDS -> perms.isPermissionAccessOpds();
case SYNC_KOREADER -> perms.isPermissionSyncKoreader();
case SYNC_KOBO -> perms.isPermissionSyncKobo();
case ADMIN -> perms.isPermissionAdmin();

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS opds_user_v2
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_opds_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT uq_userid_username UNIQUE (user_id, username)
);
ALTER TABLE user_permissions
ADD COLUMN IF NOT EXISTS permission_access_opds BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE user_permissions
SET permission_access_opds = TRUE
WHERE permission_admin = TRUE;

View File

@@ -0,0 +1,78 @@
package com.adityachandel.booklore.config.security.service;
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.mapper.OpdsUserMapper;
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
import com.adityachandel.booklore.repository.OpdsUserRepository;
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OpdsUserDetailsServiceTest {
private OpdsUserRepository opdsUserRepository;
private OpdsUserV2Repository opdsUserV2Repository;
private OpdsUserMapper opdsUserMapper;
private OpdsUserV2Mapper opdsUserV2Mapper;
private OpdsUserDetailsService service;
@BeforeEach
void setUp() {
opdsUserRepository = mock(OpdsUserRepository.class);
opdsUserV2Repository = mock(OpdsUserV2Repository.class);
opdsUserMapper = mock(OpdsUserMapper.class);
opdsUserV2Mapper = mock(OpdsUserV2Mapper.class);
service = new OpdsUserDetailsService(opdsUserRepository, opdsUserV2Repository, opdsUserMapper, opdsUserV2Mapper);
}
@Test
void loadUserByUsername_primaryRepositoryHit_usesPrimaryMapperAndDoesNotCallV2() {
String username = "primaryUser";
Optional<OpdsUserEntity> primaryEntity = Optional.of(mock(OpdsUserEntity.class));
when(opdsUserRepository.findByUsername(username)).thenReturn(primaryEntity);
OpdsUserDetails result = service.loadUserByUsername(username);
assertNotNull(result, "Expected non-null OpdsUserDetails when primary repo returns a user");
verify(opdsUserMapper, times(1)).toOpdsUser(any(OpdsUserEntity.class));
verify(opdsUserV2Repository, never()).findByUsername(anyString());
}
@Test
void loadUserByUsername_primaryEmpty_v2RepositoryHit_usesV2Mapper() {
String username = "v2User";
when(opdsUserRepository.findByUsername(username)).thenReturn(Optional.empty());
Optional<OpdsUserV2Entity> v2Entity = Optional.of(mock(OpdsUserV2Entity.class));
when(opdsUserV2Repository.findByUsername(username)).thenReturn(v2Entity);
OpdsUserDetails result = service.loadUserByUsername(username);
assertNotNull(result, "Expected non-null OpdsUserDetails when v2 repo returns a user");
verify(opdsUserRepository, times(1)).findByUsername(username);
verify(opdsUserV2Repository, times(1)).findByUsername(username);
}
@Test
void loadUserByUsername_bothRepositoriesEmpty_throwsException() {
String username = "missingUser";
when(opdsUserRepository.findByUsername(username)).thenReturn(Optional.empty());
when(opdsUserV2Repository.findByUsername(username)).thenReturn(Optional.empty());
assertThrows(Exception.class, () -> service.loadUserByUsername(username), "Expected an exception when user not found in either repository");
}
}

View File

@@ -0,0 +1,232 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
import com.adityachandel.booklore.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OpdsUserV2ServiceTest {
@Mock
private OpdsUserV2Repository opdsUserV2Repository;
@Mock
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@Mock
private OpdsUserV2Mapper mapper;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private OpdsUserV2Service service;
@Captor
private ArgumentCaptor<OpdsUserV2Entity> entityCaptor;
@Test
void getOpdsUsers_returnsMappedDtos() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(1L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
OpdsUserV2Entity entity = mock(OpdsUserV2Entity.class);
List<OpdsUserV2Entity> entities = List.of(entity);
when(opdsUserV2Repository.findByUserId(1L)).thenReturn(entities);
OpdsUserV2 dto = mock(OpdsUserV2.class);
List<OpdsUserV2> dtos = List.of(dto);
when(mapper.toDto(entities)).thenReturn(dtos);
List<OpdsUserV2> result = service.getOpdsUsers();
assertSame(dtos, result);
verify(opdsUserV2Repository).findByUserId(1L);
verify(mapper).toDto(entities);
}
@Test
void createOpdsUser_success_savesWithEncodedPasswordAndReturnsDto() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(1L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
BookLoreUserEntity userEntity = mock(BookLoreUserEntity.class);
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
when(request.getUsername()).thenReturn("alice");
when(request.getPassword()).thenReturn("plaintext");
when(passwordEncoder.encode("plaintext")).thenReturn("encoded-pass");
OpdsUserV2Entity savedEntity = mock(OpdsUserV2Entity.class);
OpdsUserV2 dto = mock(OpdsUserV2.class);
when(opdsUserV2Repository.save(any())).thenReturn(savedEntity);
when(mapper.toDto(savedEntity)).thenReturn(dto);
OpdsUserV2 result = service.createOpdsUser(request);
assertSame(dto, result);
verify(passwordEncoder).encode("plaintext");
verify(opdsUserV2Repository).save(entityCaptor.capture());
OpdsUserV2Entity captured = entityCaptor.getValue();
// we cannot assume concrete builder internals here; assert via getters that exist on entity
assertEquals("alice", captured.getUsername());
assertEquals("encoded-pass", captured.getPasswordHash());
assertSame(userEntity, captured.getUser());
verify(mapper).toDto(savedEntity);
}
@Test
void createOpdsUser_userNotFound_throwsUsernameNotFoundException() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(2L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
when(userRepository.findById(2L)).thenReturn(Optional.empty());
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
assertThrows(UsernameNotFoundException.class, () -> service.createOpdsUser(request));
verify(opdsUserV2Repository, never()).save(any());
}
@Test
void deleteOpdsUser_deletesWhenOwner() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(10L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
BookLoreUserEntity ownerEntity = mock(BookLoreUserEntity.class);
when(ownerEntity.getId()).thenReturn(10L);
OpdsUserV2Entity target = mock(OpdsUserV2Entity.class);
when(target.getUser()).thenReturn(ownerEntity);
when(opdsUserV2Repository.findById(100L)).thenReturn(Optional.of(target));
service.deleteOpdsUser(100L);
verify(opdsUserV2Repository).delete(target);
}
@Test
void deleteOpdsUser_throwsAccessDeniedWhenNotOwner() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(11L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
BookLoreUserEntity ownerEntity = mock(BookLoreUserEntity.class);
when(ownerEntity.getId()).thenReturn(9L);
OpdsUserV2Entity target = mock(OpdsUserV2Entity.class);
when(target.getUser()).thenReturn(ownerEntity);
when(opdsUserV2Repository.findById(200L)).thenReturn(Optional.of(target));
assertThrows(AccessDeniedException.class, () -> service.deleteOpdsUser(200L));
verify(opdsUserV2Repository, never()).delete(any());
}
@Test
void getOpdsUsers_returnsEmptyListWhenNoUsers() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(5L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
List<OpdsUserV2Entity> emptyEntities = List.of();
when(opdsUserV2Repository.findByUserId(5L)).thenReturn(emptyEntities);
List<OpdsUserV2> emptyDtos = List.of();
when(mapper.toDto(emptyEntities)).thenReturn(emptyDtos);
List<OpdsUserV2> result = service.getOpdsUsers();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(opdsUserV2Repository).findByUserId(5L);
verify(mapper).toDto(emptyEntities);
}
@Test
void deleteOpdsUser_userNotFound_throwsRuntimeException() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
when(opdsUserV2Repository.findById(300L)).thenReturn(Optional.empty());
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.deleteOpdsUser(300L));
assertTrue(ex.getMessage().contains("User not found with ID: 300"));
verify(opdsUserV2Repository, never()).delete(any());
}
@Test
void createOpdsUser_passwordEncoderThrows_propagatesException() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(6L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
BookLoreUserEntity userEntity = mock(BookLoreUserEntity.class);
when(userRepository.findById(6L)).thenReturn(Optional.of(userEntity));
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
when(request.getUsername()).thenReturn("bob");
when(request.getPassword()).thenReturn("plaintext");
when(passwordEncoder.encode("plaintext")).thenThrow(new IllegalArgumentException("encoding failed"));
assertThrows(IllegalArgumentException.class, () -> service.createOpdsUser(request));
verify(opdsUserV2Repository, never()).save(any());
}
@Test
void createOpdsUser_usernameNull_savedWithNullUsername() {
BookLoreUser authUser = mock(BookLoreUser.class);
when(authUser.getId()).thenReturn(7L);
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
BookLoreUserEntity userEntity = mock(BookLoreUserEntity.class);
when(userRepository.findById(7L)).thenReturn(Optional.of(userEntity));
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
when(request.getUsername()).thenReturn(null);
when(request.getPassword()).thenReturn("secret");
when(passwordEncoder.encode("secret")).thenReturn("encoded-secret");
OpdsUserV2Entity savedEntity = mock(OpdsUserV2Entity.class);
OpdsUserV2 dto = mock(OpdsUserV2.class);
when(opdsUserV2Repository.save(any())).thenReturn(savedEntity);
when(mapper.toDto(savedEntity)).thenReturn(dto);
OpdsUserV2 result = service.createOpdsUser(request);
assertSame(dto, result);
verify(opdsUserV2Repository).save(entityCaptor.capture());
OpdsUserV2Entity captured = entityCaptor.getValue();
assertNull(captured.getUsername());
assertEquals("encoded-secret", captured.getPasswordHash());
assertSame(userEntity, captured.getUser());
}
}

View File

@@ -0,0 +1,169 @@
package com.adityachandel.booklore.service.opds;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.*;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.BookQueryService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.Answers.RETURNS_DEEP_STUBS;
@ExtendWith(MockitoExtension.class)
class OpdsServiceTest {
@Mock
private BookQueryService bookQueryService;
@Mock
private AuthenticationService authenticationService;
@Mock
private UserRepository userRepository;
@Mock
private BookLoreUserTransformer bookLoreUserTransformer;
@Mock
private HttpServletRequest request;
@InjectMocks
private OpdsService service;
@Test
void generateCatalogFeed_defaultsToV1_callsGetAllBooksAndReturnsFeed() {
OpdsUserDetails details = mock(OpdsUserDetails.class);
when(authenticationService.getOpdsUser()).thenReturn(details);
when(details.getOpdsUser()).thenReturn(mock(OpdsUser.class));
when(request.getHeader("Accept")).thenReturn(null);
when(bookQueryService.getAllBooks(true)).thenReturn(List.of()); // minimal stub
String feed = service.generateCatalogFeed(request);
assertNotNull(feed);
assertTrue(feed.contains("<title>Booklore Catalog</title>"));
verify(bookQueryService).getAllBooks(true);
}
@Test
void generateSearchResults_withQuery_usesSearchBooksByMetadata_and_producesFeed() {
OpdsUserDetails details = mock(OpdsUserDetails.class);
when(authenticationService.getOpdsUser()).thenReturn(details);
when(details.getOpdsUser()).thenReturn(mock(OpdsUser.class));
// build a minimal Book mock so feed generation won't NPE
Book book = mock(Book.class);
BookMetadata metadata = mock(BookMetadata.class);
when(book.getMetadata()).thenReturn(metadata);
when(metadata.getTitle()).thenReturn("Searchable Title");
when(metadata.getAuthors()).thenReturn(Set.of("Author One"));
when(book.getId()).thenReturn(11L);
when(book.getAddedOn()).thenReturn(null);
when(book.getBookType()).thenReturn(null); // will map to default mime type
when(bookQueryService.searchBooksByMetadata("query")).thenReturn(List.of(book));
when(request.getHeader("Accept")).thenReturn(null);
String feed = service.generateSearchResults(request, "query");
assertNotNull(feed);
assertTrue(feed.contains("Searchable Title"));
verify(bookQueryService).searchBooksByMetadata("query");
}
@Test
void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Placeholder() {
OpdsUserDetails details = mock(OpdsUserDetails.class);
when(authenticationService.getOpdsUser()).thenReturn(details);
when(details.getOpdsUser()).thenReturn(null);
OpdsUserV2 opdsUserV2 = mock(OpdsUserV2.class);
when(details.getOpdsUserV2()).thenReturn(opdsUserV2);
when(opdsUserV2.getUserId()).thenReturn(9L);
// entity with deep-stubbed permissions for the initial permission check
BookLoreUserEntity entity = mock(BookLoreUserEntity.class, RETURNS_DEEP_STUBS);
when(entity.getPermissions().isPermissionAccessOpds()).thenReturn(true);
when(userRepository.findById(9L)).thenReturn(Optional.of(entity));
// transformer returns DTO indicating admin user
BookLoreUser userDto = mock(BookLoreUser.class);
BookLoreUser.UserPermissions dtoPerms = mock(BookLoreUser.UserPermissions.class);
when(dtoPerms.isAdmin()).thenReturn(true);
when(userDto.getPermissions()).thenReturn(dtoPerms);
when(bookLoreUserTransformer.toDTO(entity)).thenReturn(userDto);
when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0");
when(bookQueryService.getAllBooks(true)).thenReturn(List.of());
String feed = service.generateCatalogFeed(request);
assertEquals("OPDS v2.0 Feed is under construction", feed);
verify(bookQueryService).getAllBooks(true);
verify(userRepository).findById(9L);
}
@Test
void generateCatalogFeed_opdsV2NonAdmin_callsLibraryScopedQueryMethods() {
OpdsUserDetails details = mock(OpdsUserDetails.class);
when(authenticationService.getOpdsUser()).thenReturn(details);
when(details.getOpdsUser()).thenReturn(null);
OpdsUserV2 opdsUserV2 = mock(OpdsUserV2.class);
when(details.getOpdsUserV2()).thenReturn(opdsUserV2);
when(opdsUserV2.getUserId()).thenReturn(21L);
BookLoreUserEntity entity = mock(BookLoreUserEntity.class, RETURNS_DEEP_STUBS);
when(entity.getPermissions().isPermissionAccessOpds()).thenReturn(true); // allow access
when(userRepository.findById(21L)).thenReturn(Optional.of(entity));
// DTO: non-admin with assigned libraries
BookLoreUser userDto = mock(BookLoreUser.class);
BookLoreUser.UserPermissions dtoPerms = mock(BookLoreUser.UserPermissions.class);
when(dtoPerms.isAdmin()).thenReturn(false);
when(userDto.getPermissions()).thenReturn(dtoPerms);
Library lib = mock(Library.class);
when(lib.getId()).thenReturn(7L);
when(userDto.getAssignedLibraries()).thenReturn(List.of(lib));
when(bookLoreUserTransformer.toDTO(entity)).thenReturn(userDto);
when(request.getHeader("Accept")).thenReturn(null); // v1 default; feed version not relevant for allowed-books logic
when(bookQueryService.getAllBooksByLibraryIds(Set.of(7L), true)).thenReturn(List.of());
String feed = service.generateCatalogFeed(request);
assertNotNull(feed);
verify(bookQueryService).getAllBooksByLibraryIds(Set.of(7L), true);
verify(userRepository).findById(21L);
}
@Test
void generateCatalogFeed_opdsV2UserMissingBookLoreUser_throwsAccessDenied() {
OpdsUserDetails details = mock(OpdsUserDetails.class);
when(authenticationService.getOpdsUser()).thenReturn(details);
when(details.getOpdsUser()).thenReturn(null);
OpdsUserV2 opdsUserV2 = mock(OpdsUserV2.class);
when(details.getOpdsUserV2()).thenReturn(opdsUserV2);
when(opdsUserV2.getUserId()).thenReturn(42L);
when(userRepository.findById(42L)).thenReturn(Optional.empty());
AccessDeniedException ex = assertThrows(AccessDeniedException.class, () -> service.generateCatalogFeed(request));
assertTrue(ex.getMessage().contains("User not found"));
verify(userRepository).findById(42L);
}
}

View File

@@ -40,7 +40,8 @@ export class AuthenticationSettingsComponent implements OnInit {
{label: 'Email Book', value: 'permissionEmailBook', selected: false},
{label: 'Delete Book', value: 'permissionDeleteBook', selected: false},
{label: 'KOReader Sync', value: 'permissionSyncKoreader', selected: false},
{label: 'Kobo Sync', value: 'permissionSyncKobo', selected: false}
{label: 'Kobo Sync', value: 'permissionSyncKobo', selected: false},
{label: 'Access OPDS', value: 'permissionAccessOpds', selected: false}
];
internalAuthEnabled = true;

View File

@@ -3,7 +3,7 @@ import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {GithubSupportDialog} from './utilities/component/github-support-dialog/github-support-dialog';
import {LibraryCreatorComponent} from './book/components/library-creator/library-creator.component';
import {BookUploaderComponent} from './utilities/component/book-uploader/book-uploader.component';
import {UserProfileDialogComponent} from './settings/global-preferences/user-profile-dialog/user-profile-dialog.component';
import {UserProfileDialogComponent} from './settings/user-profile-dialog/user-profile-dialog.component';
import {MagicShelfComponent} from './magic-shelf-component/magic-shelf-component';
@Injectable({

View File

@@ -0,0 +1,200 @@
<div class="main-container enclosing-container">
<div class="settings-header">
<h2 class="settings-title">
<i class="pi pi-server"></i>
OPDS Settings
</h2>
<p class="settings-description">
Manage your OPDS credentials and control how your book collection is shared with reading apps.
By default, all libraries you have access to will be included in the OPDS feed.
</p>
</div>
@if (hasPermission) {
<div class="settings-content">
<div class="endpoint-section">
<h3 class="section-title">
<i class="pi pi-link"></i>
OPDS Endpoint
</h3>
<div class="endpoint-form">
<div class="endpoint-field">
<input
id="endpoint-url"
fluid
class="endpoint-input"
type="text"
pInputText
[value]="opdsEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
severity="info"
outlined
size="small"
(onClick)="copyEndpoint()">
</p-button>
</div>
</div>
</div>
<div class="users-section">
<div class="section-header">
<div class="section-title-group">
<h3 class="section-title">
<i class="pi pi-users"></i>
OPDS Users
</h3>
<p-button
icon="pi pi-plus"
label="Add User"
severity="success"
size="small"
(onClick)="showCreateUserDialog = true">
</p-button>
</div>
</div>
<div class="table-card">
<p-table
[value]="users"
[paginator]="users.length > 10"
[rows]="10"
[showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} users">
<ng-template pTemplate="header">
<tr>
<th>
<div class="header-content">
<i class="pi pi-user"></i>
<span>Username</span>
</div>
</th>
<th>
<div class="header-content">
<i class="pi pi-key"></i>
<span>Password</span>
</div>
</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-cog"></i>
<span>Actions</span>
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-user let-rowIndex="rowIndex">
<tr>
<td>
<div class="user-info">
<div class="user-avatar">
{{ user.username.charAt(0).toUpperCase() }}
</div>
<span class="username">{{ user.username }}</span>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<p-password
fluid
class="w-32 md:w-56"
[(ngModel)]="dummyPassword"
[feedback]="false"
size="small"
[disabled]="true"
[toggleMask]="false">
</p-password>
<i
class="pi pi-info-circle text-gray-400"
pTooltip="Passwords are hidden for security reasons. To change, delete the user and create a new one with a new password."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</div>
</td>
<td class="actions-cell">
<p-button
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="confirmDelete(user)"
pTooltip="Delete user">
</p-button>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="3">
<div class="empty-message">
<i class="pi pi-users"></i>
<p class="empty-title">No users found</p>
<p class="empty-subtitle">Create your first OPDS user to get started</p>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</div>
<p-dialog
header="Create New User"
[(visible)]="showCreateUserDialog"
[modal]="true"
styleClass="user-dialog"
[style]="{width: '400px'}">
<div class="dialog-form">
<div class="form-field">
<label for="username">Username</label>
<input
id="username"
type="text"
pInputText
[(ngModel)]="newUser.username"
placeholder="Enter username"/>
</div>
<div class="form-field">
<label for="password">Password</label>
<input
id="password"
type="password"
pInputText
[(ngModel)]="newUser.password"
placeholder="Enter password"/>
</div>
</div>
<ng-template pTemplate="footer">
<div class="dialog-actions">
<p-button
label="Cancel"
severity="secondary"
outlined="true"
(onClick)="cancelCreateUser()">
</p-button>
<p-button
label="Create"
severity="success"
[disabled]="!newUser.username || !newUser.password"
(onClick)="createUser()">
</p-button>
</div>
</ng-template>
</p-dialog>
<p-confirmDialog></p-confirmDialog>
} @else {
<div class="px-4 py-4 rounded-lg bg-red-700/30 border border-red-600 text-red-200 flex items-center gap-2 max-w-lg">
<i class="pi pi-lock text-red-400 text-xl"></i>
<span>
Access to OPDS is restricted.
<br>
Please contact your administrator to request permission.
</span>
</div>
}
</div>

View File

@@ -0,0 +1,268 @@
.main-container {
width: 100%;
padding: 1rem;
height: calc(100dvh - 10.5rem);
overflow-y: auto;
border-width: 1px;
border-radius: 0.5rem;
@media (min-width: 768px) {
height: calc(100dvh - 11.65rem);
}
}
.enclosing-container {
border-color: var(--p-content-border-color);
background: var(--p-content-background);
}
.p-datatable th .header-content {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.p-datatable td {
text-align: left;
vertical-align: middle;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.table-card {
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
overflow: hidden;
background: var(--p-content-background);
}
.p-datatable {
.p-datatable-table {
border-collapse: separate;
border-spacing: 0;
}
.p-datatable-thead > tr > th {
background: var(--p-surface-100);
border-bottom: 2px solid var(--p-content-border-color);
padding: 1rem;
font-weight: 600;
color: var(--p-text-color);
}
.p-datatable-tbody > tr {
border-bottom: 1px solid var(--p-surface-border);
transition: background-color 0.2s;
&:hover {
background: var(--p-surface-50);
}
&:last-child {
border-bottom: none;
}
}
.p-datatable-tbody > tr > td {
padding: 1rem;
border: none;
}
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--p-primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.username {
font-weight: 500;
}
.password-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.password-hidden, .password-visible {
font-family: monospace;
min-width: 80px;
}
.actions-cell {
text-align: center;
}
.empty-message {
text-align: center;
padding: 2rem 1rem;
color: var(--p-text-muted-color);
.pi {
font-size: 3rem;
margin-bottom: 1rem;
color: var(--p-surface-400);
}
.empty-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.empty-subtitle {
font-size: 0.875rem;
}
}
.user-dialog {
.p-dialog-header {
border-bottom: 1px solid var(--p-surface-border);
}
.p-dialog-content {
padding: 1.5rem;
}
.p-dialog-footer {
border-top: 1px solid var(--p-surface-border);
padding: 1rem 1.5rem;
}
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
}
input {
width: 100%;
padding: 0.75rem;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: var(--p-primary-color);
box-shadow: 0 0 0 2px var(--p-primary-color-alpha-20);
}
&::placeholder {
color: var(--p-text-muted-color);
}
}
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.settings-header {
margin-top: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--p-surface-border);
}
.settings-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--p-text-color);
margin: 0 0 0.75rem 0;
.pi {
color: var(--p-primary-color);
font-size: 1.25rem;
}
}
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
line-height: 1.5;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.endpoint-section {
background: var(--p-content-background);
border: 1px solid var(--p-surface-border);
border-radius: 8px;
@media (min-width: 768px) {
padding: 1rem;
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--p-text-color);
.pi {
color: var(--p-primary-color);
}
}
.endpoint-form {
margin-top: 1rem;
}
.endpoint-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.endpoint-input {
min-width: 9rem;
max-width: 50rem;
}
.users-section {
@media (min-width: 768px) {
padding: 1rem;
}
.section-title-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
}

View File

@@ -0,0 +1,150 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {API_CONFIG} from '../../config/api-config';
import {Tooltip} from 'primeng/tooltip';
import {TableModule} from 'primeng/table';
import {Dialog} from 'primeng/dialog';
import {FormsModule} from '@angular/forms';
import {ConfirmDialog} from 'primeng/confirmdialog';
import {ConfirmationService, MessageService} from 'primeng/api';
import {OpdsUserV2, OpdsUserV2CreateRequest, OpdsV2Service} from './opds-v2.service';
import {catchError, filter, takeUntil, tap} from 'rxjs/operators';
import {UserService} from '../user-management/user.service';
import {of, Subject} from 'rxjs';
import {Password} from 'primeng/password';
@Component({
selector: 'app-opds-settings-v2',
imports: [
CommonModule,
Button,
InputText,
Tooltip,
Dialog,
FormsModule,
ConfirmDialog,
TableModule,
Password
],
providers: [ConfirmationService, MessageService],
templateUrl: './opds-settings-v2.html',
styleUrl: './opds-settings-v2.scss'
})
export class OpdsSettingsV2 implements OnInit, OnDestroy {
opdsEndpoint = `${API_CONFIG.BASE_URL}/api/v1/opds/catalog`;
private opdsService = inject(OpdsV2Service);
private confirmationService = inject(ConfirmationService);
private messageService = inject(MessageService);
private userService = inject(UserService);
users: OpdsUserV2[] = [];
loading = false;
showCreateUserDialog = false;
newUser: OpdsUserV2CreateRequest = {username: '', password: ''};
passwordVisibility: boolean[] = [];
hasPermission = false;
private readonly destroy$ = new Subject<void>();
dummyPassword: string = "***********************";
ngOnInit(): void {
this.loading = true;
this.userService.userState$.pipe(
filter(state => !!state?.user && state.loaded),
takeUntil(this.destroy$),
tap(state => {
this.hasPermission = !!(state.user?.permissions.canAccessOpds || state.user?.permissions.admin);
}),
filter(() => this.hasPermission),
tap(() => this.loadUsers())
).subscribe();
}
private loadUsers(): void {
this.opdsService.getUser().pipe(
takeUntil(this.destroy$),
catchError(err => {
console.error('Error loading users:', err);
this.showMessage('error', 'Error', 'Failed to load users');
return of([]);
})
).subscribe(users => {
this.users = users;
this.passwordVisibility = new Array(users.length).fill(false);
this.loading = false;
});
}
createUser(): void {
if (!this.newUser.username || !this.newUser.password) return;
this.opdsService.createUser(this.newUser).pipe(
takeUntil(this.destroy$),
catchError(err => {
console.error('Error creating user:', err);
this.showMessage('error', 'Error', 'Failed to create user');
return of(null);
})
).subscribe(user => {
if (!user) return;
this.users.push(user);
this.resetCreateUserDialog();
this.showMessage('success', 'Success', 'User created successfully');
});
}
confirmDelete(user: OpdsUserV2): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete user "${user.username}"?`,
header: 'Delete Confirmation',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => this.deleteUser(user)
});
}
deleteUser(user: OpdsUserV2): void {
if (!user.id) return;
this.opdsService.deleteCredential(user.id).pipe(
takeUntil(this.destroy$),
catchError(err => {
console.error('Error deleting user:', err);
this.showMessage('error', 'Error', 'Failed to delete user');
return of(null);
})
).subscribe(() => {
this.users = this.users.filter(u => u.id !== user.id);
this.showMessage('success', 'Success', 'User deleted successfully');
});
}
cancelCreateUser(): void {
this.resetCreateUserDialog();
}
copyEndpoint(): void {
navigator.clipboard.writeText(this.opdsEndpoint).then(() => {
this.showMessage('success', 'Copied', 'OPDS endpoint copied to clipboard');
});
}
private resetCreateUserDialog(): void {
this.showCreateUserDialog = false;
this.newUser = {username: '', password: ''};
}
private showMessage(severity: string, summary: string, detail: string): void {
this.messageService.add({severity, summary, detail});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,36 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {API_CONFIG} from '../../config/api-config';
export interface OpdsUserV2CreateRequest {
username: string;
password: string;
}
export interface OpdsUserV2 {
id: number;
userId: number;
username: string;
}
@Injectable({
providedIn: 'root'
})
export class OpdsV2Service {
private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v2/opds-users`;
private http = inject(HttpClient);
getUser(): Observable<OpdsUserV2[]> {
return this.http.get<OpdsUserV2[]>(this.baseUrl);
}
createUser(user: OpdsUserV2CreateRequest): Observable<OpdsUserV2> {
return this.http.post<OpdsUserV2>(this.baseUrl, user);
}
deleteCredential(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}

View File

@@ -8,6 +8,17 @@
style="cursor: pointer;">
</i>
</p>
<div class="px-4 mb-4">
<div class="border-l-8 border border-red-500 text-red-400 p-3 rounded-md flex items-center gap-2">
<i class="pi pi-exclamation-triangle mt-1"></i>
<p class="text-sm">
<b class="text-red-500">Deprecated:</b> OPDS (v1) support will be removed in a future release.
Please migrate to <b>OPDS v2</b> for continued support and improvements.
</p>
</div>
</div>
<div class="flex flex-col space-y-6 p-4 m-4 custom-border">
<div class="flex items-center gap-4">
@@ -53,7 +64,7 @@
</p-button>
</div>
<div class="mt-3">
<p-table [value]="users" [responsiveLayout]="'scroll'">
<p-table [value]="users">
<ng-template pTemplate="header">
<tr>
<th>Username</th>

View File

@@ -10,10 +10,10 @@ import {MessageService} from 'primeng/api';
import {filter, take} from 'rxjs/operators';
import {OpdsUser, OpdsUserService} from './opds-user.service';
import {AppSettingsService} from '../../../core/service/app-settings.service';
import {AppSettingKey, AppSettings} from '../../../core/model/app-settings.model';
import {AppSettingsService} from '../../core/service/app-settings.service';
import {AppSettingKey, AppSettings} from '../../core/model/app-settings.model';
import {Password} from 'primeng/password';
import {API_CONFIG} from '../../../config/api-config';
import {API_CONFIG} from '../../config/api-config';
import {ToggleSwitch} from 'primeng/toggleswitch';
@Component({

View File

@@ -1,5 +1,5 @@
import {inject, Injectable} from '@angular/core';
import {API_CONFIG} from '../../../config/api-config';
import {API_CONFIG} from '../../config/api-config';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';

View File

@@ -29,9 +29,12 @@
<i class="pi pi-lock"></i> Authentication
</p-tab>
<p-tab [value]="SettingsTab.Opds">
<i class="pi pi-globe"></i> OPDS
<i class="pi pi-globe"></i> OPDS (Deprecated)
</p-tab>
}
<p-tab [value]="SettingsTab.OpdsV2">
<i class="pi pi-globe"></i> OPDS V2
</p-tab>
<p-tab [value]="SettingsTab.DeviceSettings">
<i class="pi pi-mobile"></i> Devices
</p-tab>
@@ -66,6 +69,9 @@
<app-opds-settings></app-opds-settings>
</p-tabpanel>
}
<p-tabpanel [value]="SettingsTab.OpdsV2">
<app-opds-settings-v2></app-opds-settings-v2>
</p-tabpanel>
<p-tabpanel [value]="SettingsTab.DeviceSettings">
<app-device-settings-component></app-device-settings-component>
</p-tabpanel>

View File

@@ -11,9 +11,10 @@ import {AuthenticationSettingsComponent} from '../core/security/oauth2-managemen
import {ViewPreferencesParentComponent} from './view-preferences-parent/view-preferences-parent.component';
import {ReaderPreferences} from './reader-preferences/reader-preferences.component';
import {MetadataSettingsComponent} from './metadata-settings-component/metadata-settings-component';
import {OpdsSettingsComponent} from './global-preferences/opds-settings/opds-settings.component';
import {OpdsSettingsComponent} from './opds-settings/opds-settings.component';
import {DeviceSettingsComponent} from './device-settings-component/device-settings-component';
import {FileNamingPatternComponent} from './file-naming-pattern/file-naming-pattern.component';
import {OpdsSettingsV2} from './opds-settings-v2/opds-settings-v2';
export enum SettingsTab {
ReaderSettings = 'reader',
@@ -25,6 +26,7 @@ export enum SettingsTab {
MetadataSettings = 'metadata',
ApplicationSettings = 'application',
AuthenticationSettings = 'authentication',
OpdsV2 = 'opds-v2',
Opds = 'opds'
}
@@ -46,7 +48,8 @@ export enum SettingsTab {
MetadataSettingsComponent,
OpdsSettingsComponent,
DeviceSettingsComponent,
FileNamingPatternComponent
FileNamingPatternComponent,
OpdsSettingsV2
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss'

View File

@@ -91,6 +91,11 @@
<label>Delete Book</label>
</div>
<div class="flex items-center gap-2">
<p-checkbox formControlName="permissionAccessOpds" [binary]="true"></p-checkbox>
<label>Access OPDS</label>
</div>
<div class="flex items-center gap-2">
<p-checkbox formControlName="permissionSyncKoreader" [binary]="true"></p-checkbox>
<label>KOReader Sync</label>

View File

@@ -50,6 +50,7 @@ export class CreateUserDialogComponent implements OnInit {
permissionManipulateLibrary: [false],
permissionEmailBook: [false],
permissionDeleteBook: [false],
permissionAccessOpds: [false],
permissionSyncKoreader: [false],
permissionSyncKobo: [false],
permissionAdmin: [false],

View File

@@ -22,6 +22,7 @@
<th style="width: 80px;">Manage Library</th>
<th style="width: 80px;">Email Books</th>
<th style="width: 80px;">Delete Books</th>
<th style="width: 80px;">Access OPDS</th>
<th style="width: 80px;">KOReader Sync</th>
<th style="width: 80px;">Kobo Sync</th>
<th style="width: 120px;">Edit</th>
@@ -127,6 +128,14 @@
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds" disabled></p-checkbox>
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader"></p-checkbox>

View File

@@ -107,6 +107,7 @@ export interface User {
canManipulateLibrary: boolean;
canSyncKoReader: boolean;
canSyncKobo: boolean;
canAccessOpds: boolean;
};
userSettings: UserSettings;
provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE';

View File

@@ -5,7 +5,7 @@ import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} fr
import {InputText} from 'primeng/inputtext';
import {Password} from 'primeng/password';
import {User, UserService} from '../../user-management/user.service';
import {User, UserService} from '../user-management/user.service';
import {MessageService} from 'primeng/api';
import {Subject} from 'rxjs';
import {Message} from 'primeng/message';