diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index 2a32ad0e7..1bfae4a50 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -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; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java index 697aa9639..85d0a6793 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java @@ -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(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java index c17c9264b..9989b0a39 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java @@ -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> loginUser(UserLoginRequest loginRequest) { BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername())); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/CustomOpdsUserDetailsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/CustomOpdsUserDetailsService.java deleted file mode 100644 index ad5ca0f91..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/CustomOpdsUserDetailsService.java +++ /dev/null @@ -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(); - } -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/OpdsUserDetailsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/OpdsUserDetailsService.java new file mode 100644 index 000000000..671af58f7 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/OpdsUserDetailsService.java @@ -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); + }); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/userdetails/OpdsUserDetails.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/userdetails/OpdsUserDetails.java new file mode 100644 index 000000000..26de1b6a9 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/userdetails/OpdsUserDetails.java @@ -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 getAuthorities() { + return List.of(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java new file mode 100644 index 000000000..f726df463 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java @@ -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 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); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/OpdsUserV2Mapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/OpdsUserV2Mapper.java new file mode 100644 index 000000000..107695652 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/OpdsUserV2Mapper.java @@ -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 toDto(List entities); + + @Mapping(target = "user.id", source = "userId") + OpdsUserV2Entity toEntity(OpdsUserV2 dto); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java index 2edf3392c..2f7832bae 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java @@ -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()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index 81cb176b8..011bbc035 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -32,6 +32,7 @@ public class BookLoreUser { private boolean canSyncKobo; private boolean canEmailBook; private boolean canDeleteBook; + private boolean canAccessOpds; } @Data diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUser.java index 930661a2d..e5530187f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUser.java @@ -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; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java new file mode 100644 index 000000000..11e78ae54 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java @@ -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; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java index bb1de67be..a5ce4311e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java new file mode 100644 index 000000000..88bb86643 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java @@ -0,0 +1,9 @@ +package com.adityachandel.booklore.model.dto.request; + +import lombok.Data; + +@Data +public class OpdsUserV2CreateRequest { + private String username; + private String password; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java index 79dd229db..a23726bc2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java @@ -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; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java new file mode 100644 index 000000000..82895eb4d --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java @@ -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(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java index 5932c70c9..3b4d5bf50 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java index c40b156d0..9c4e28746 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java @@ -9,5 +9,6 @@ public enum PermissionType { DELETE_BOOK, SYNC_KOREADER, SYNC_KOBO, + ACCESS_OPDS, ADMIN } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java index 44f8557b0..2e44fa4a7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java @@ -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, 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 findBooksContainingMetadata(@Param("text") String text); + List 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 searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection libraryIds); @Modifying @Transactional diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/OpdsUserV2Repository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/OpdsUserV2Repository.java new file mode 100644 index 000000000..9a35fad66 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/OpdsUserV2Repository.java @@ -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 { + + Optional findByUsername(String username); + + List findByUserId(Long userId); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java index 6686cb935..7ea6c5d6f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java @@ -52,8 +52,18 @@ public class BookQueryService { return bookRepository.findAllFullBooks(); } - public List getBooksContainingMetadata(String text) { - return bookRepository.findBooksContainingMetadata(text); + public List searchBooksByMetadata(String text) { + List bookEntities = bookRepository.searchByMetadata(text); + return bookEntities.stream() + .map(bookMapperV2::toDTO) + .collect(Collectors.toList()); + } + + public List searchBooksByMetadataInLibraries(String text, Set libraryIds) { + List bookEntities = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds); + return bookEntities.stream() + .map(bookMapperV2::toDTO) + .collect(Collectors.toList()); } public void saveAll(List books) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/OpdsUserV2Service.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/OpdsUserV2Service.java new file mode 100644 index 000000000..23d21763a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/OpdsUserV2Service.java @@ -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 getOpdsUsers() { + BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser(); + List 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); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java index 8d45771eb..94fd7c7ff 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java @@ -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 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 books = List.of(); - if (queryParam != null) { - books = bookQueryService.getBooksContainingMetadata(queryParam); - } else { - books = bookQueryService.getAllFullBookEntities(); - } - var feedVersion = extractVersionFromAcceptHeader(request); - + List books = getAllowedBooks(queryParam); + String feedVersion = extractVersionFromAcceptHeader(request); return switch (feedVersion) { case "2.0" -> generateOpdsV2Feed(books); default -> generateOpdsV1Feed(books); }; } + private List 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 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 books) { + private String generateOpdsV1Feed(List books) { var feed = new StringBuilder(""" @@ -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(""" %s @@ -98,13 +137,13 @@ public class OpdsService { %s - """.formatted(escapeXml(author.getName())))); + """.formatted(escapeXml(author)))); appendOptionalTags(feed, book); feed.append(" "); } - private void appendOptionalTags(StringBuilder feed, BookEntity book) { + private void appendOptionalTags(StringBuilder feed, Book book) { if (book.getMetadata().getPublisher() != null) { feed.append("").append(escapeXml(book.getMetadata().getPublisher())).append(""); } @@ -115,7 +154,7 @@ public class OpdsService { if (book.getMetadata().getCategories() != null) { book.getMetadata().getCategories().forEach(category -> - feed.append("").append(escapeXml(category.getName())).append("") + feed.append("").append(escapeXml(category)).append("") ); } @@ -144,11 +183,11 @@ public class OpdsService { } } - private String generateOpdsV2Feed(List books) { + private String generateOpdsV2Feed(List 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"; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java index 23718975d..802e52521 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java @@ -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); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java index b33e59b47..60430e58d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java @@ -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()); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java index 965ddc55f..9a64c94f1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java @@ -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(); diff --git a/booklore-api/src/main/resources/db/migration/V51__Create_opds_user_credentials.sql b/booklore-api/src/main/resources/db/migration/V51__Create_opds_user_credentials.sql new file mode 100644 index 000000000..e54962aa3 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V51__Create_opds_user_credentials.sql @@ -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; \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/config/security/service/OpdsUserDetailsServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/config/security/service/OpdsUserDetailsServiceTest.java new file mode 100644 index 000000000..c9c463571 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/config/security/service/OpdsUserDetailsServiceTest.java @@ -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 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 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"); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/OpdsUserV2ServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/OpdsUserV2ServiceTest.java new file mode 100644 index 000000000..196f63c9c --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/OpdsUserV2ServiceTest.java @@ -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 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 entities = List.of(entity); + when(opdsUserV2Repository.findByUserId(1L)).thenReturn(entities); + + OpdsUserV2 dto = mock(OpdsUserV2.class); + List dtos = List.of(dto); + when(mapper.toDto(entities)).thenReturn(dtos); + + List 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 emptyEntities = List.of(); + when(opdsUserV2Repository.findByUserId(5L)).thenReturn(emptyEntities); + + List emptyDtos = List.of(); + when(mapper.toDto(emptyEntities)).thenReturn(emptyDtos); + + List 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()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java new file mode 100644 index 000000000..77fe440a8 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java @@ -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("Booklore Catalog")); + 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); + } +} \ No newline at end of file diff --git a/booklore-ui/src/app/core/security/oauth2-management/authentication-settings.component.ts b/booklore-ui/src/app/core/security/oauth2-management/authentication-settings.component.ts index 0601a96be..d95f664d7 100644 --- a/booklore-ui/src/app/core/security/oauth2-management/authentication-settings.component.ts +++ b/booklore-ui/src/app/core/security/oauth2-management/authentication-settings.component.ts @@ -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; diff --git a/booklore-ui/src/app/dialog-launcher.service.ts b/booklore-ui/src/app/dialog-launcher.service.ts index 6eb0e1934..57159ffeb 100644 --- a/booklore-ui/src/app/dialog-launcher.service.ts +++ b/booklore-ui/src/app/dialog-launcher.service.ts @@ -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({ diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html new file mode 100644 index 000000000..6f1040330 --- /dev/null +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html @@ -0,0 +1,200 @@ +
+
+

+ + OPDS Settings +

+

+ 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. +

+
+ + @if (hasPermission) { +
+
+

+ + OPDS Endpoint +

+
+
+ + + +
+
+
+ +
+
+
+

+ + OPDS Users +

+ + +
+
+ +
+ + + + +
+ + Username +
+ + +
+ + Password +
+ + +
+ + Actions +
+ + +
+ + + + + + +
+ + + + +
+ + + + + + +
+ + + +
+ +

No users found

+

Create your first OPDS user to get started

+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+
+ + + } @else { +
+ + + Access to OPDS is restricted. +
+ Please contact your administrator to request permission. +
+
+ } +
diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss new file mode 100644 index 000000000..ac33d33f0 --- /dev/null +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss @@ -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; + } +} diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts new file mode 100644 index 000000000..35c3dbed9 --- /dev/null +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts @@ -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(); + 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(); + } +} diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-v2.service.ts b/booklore-ui/src/app/settings/opds-settings-v2/opds-v2.service.ts new file mode 100644 index 000000000..6a9f23c37 --- /dev/null +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-v2.service.ts @@ -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 { + return this.http.get(this.baseUrl); + } + + createUser(user: OpdsUserV2CreateRequest): Observable { + return this.http.post(this.baseUrl, user); + } + + deleteCredential(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} diff --git a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.html b/booklore-ui/src/app/settings/opds-settings/opds-settings.component.html similarity index 92% rename from booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.html rename to booklore-ui/src/app/settings/opds-settings/opds-settings.component.html index fbb95024b..769a00e16 100644 --- a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.html +++ b/booklore-ui/src/app/settings/opds-settings/opds-settings.component.html @@ -8,6 +8,17 @@ style="cursor: pointer;">

+ +
+
+ +

+ Deprecated: OPDS (v1) support will be removed in a future release. + Please migrate to OPDS v2 for continued support and improvements. +

+
+
+
@@ -53,7 +64,7 @@
- + Username diff --git a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.scss b/booklore-ui/src/app/settings/opds-settings/opds-settings.component.scss similarity index 100% rename from booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.scss rename to booklore-ui/src/app/settings/opds-settings/opds-settings.component.scss diff --git a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.ts b/booklore-ui/src/app/settings/opds-settings/opds-settings.component.ts similarity index 96% rename from booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.ts rename to booklore-ui/src/app/settings/opds-settings/opds-settings.component.ts index 67c1bb445..9031e2ce8 100644 --- a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-settings.component.ts +++ b/booklore-ui/src/app/settings/opds-settings/opds-settings.component.ts @@ -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({ diff --git a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-user.service.ts b/booklore-ui/src/app/settings/opds-settings/opds-user.service.ts similarity index 94% rename from booklore-ui/src/app/settings/global-preferences/opds-settings/opds-user.service.ts rename to booklore-ui/src/app/settings/opds-settings/opds-user.service.ts index d66cdb8c5..83a52eba2 100644 --- a/booklore-ui/src/app/settings/global-preferences/opds-settings/opds-user.service.ts +++ b/booklore-ui/src/app/settings/opds-settings/opds-user.service.ts @@ -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'; diff --git a/booklore-ui/src/app/settings/settings.component.html b/booklore-ui/src/app/settings/settings.component.html index e49c1da1c..b8520f465 100644 --- a/booklore-ui/src/app/settings/settings.component.html +++ b/booklore-ui/src/app/settings/settings.component.html @@ -29,9 +29,12 @@ Authentication - OPDS + OPDS (Deprecated) } + + OPDS V2 + Devices @@ -66,6 +69,9 @@ } + + + diff --git a/booklore-ui/src/app/settings/settings.component.ts b/booklore-ui/src/app/settings/settings.component.ts index 491d0a4e2..4201814ff 100644 --- a/booklore-ui/src/app/settings/settings.component.ts +++ b/booklore-ui/src/app/settings/settings.component.ts @@ -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' diff --git a/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.html b/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.html index 989e40250..4e03fd730 100644 --- a/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.html +++ b/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.html @@ -91,6 +91,11 @@
+
+ + +
+
diff --git a/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.ts b/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.ts index 9ebcb84a6..1fb297f62 100644 --- a/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.ts +++ b/booklore-ui/src/app/settings/user-management/create-user-dialog/create-user-dialog.component.ts @@ -50,6 +50,7 @@ export class CreateUserDialogComponent implements OnInit { permissionManipulateLibrary: [false], permissionEmailBook: [false], permissionDeleteBook: [false], + permissionAccessOpds: [false], permissionSyncKoreader: [false], permissionSyncKobo: [false], permissionAdmin: [false], diff --git a/booklore-ui/src/app/settings/user-management/user-management.component.html b/booklore-ui/src/app/settings/user-management/user-management.component.html index 7ff6a4d49..cfdf054ed 100644 --- a/booklore-ui/src/app/settings/user-management/user-management.component.html +++ b/booklore-ui/src/app/settings/user-management/user-management.component.html @@ -22,6 +22,7 @@ Manage Library Email Books Delete Books + Access OPDS KOReader Sync Kobo Sync Edit @@ -127,6 +128,14 @@ } + + @if (user.isEditing) { + + } + @if (!user.isEditing) { + + } + @if (user.isEditing) { diff --git a/booklore-ui/src/app/settings/user-management/user.service.ts b/booklore-ui/src/app/settings/user-management/user.service.ts index 9fa06f960..e3c276f2b 100644 --- a/booklore-ui/src/app/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/settings/user-management/user.service.ts @@ -107,6 +107,7 @@ export interface User { canManipulateLibrary: boolean; canSyncKoReader: boolean; canSyncKobo: boolean; + canAccessOpds: boolean; }; userSettings: UserSettings; provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE'; diff --git a/booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.html b/booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.html similarity index 100% rename from booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.html rename to booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.html diff --git a/booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.scss b/booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.scss similarity index 100% rename from booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.scss rename to booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.scss diff --git a/booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.ts b/booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.ts similarity index 98% rename from booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.ts rename to booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.ts index 074ec4809..c7c594ef0 100644 --- a/booklore-ui/src/app/settings/global-preferences/user-profile-dialog/user-profile-dialog.component.ts +++ b/booklore-ui/src/app/settings/user-profile-dialog/user-profile-dialog.component.ts @@ -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';